diff --git a/ChatTwo/Ipc/StyleIpc.cs b/ChatTwo/Ipc/StyleIpc.cs new file mode 100644 index 0000000..68fcaf8 --- /dev/null +++ b/ChatTwo/Ipc/StyleIpc.cs @@ -0,0 +1,64 @@ +using Dalamud.Plugin.Ipc; + +namespace ChatTwo.Ipc; + +using MessageStyle = (uint BackgroundRgba, float Alpha); + +public sealed class StyleIpc : IDisposable +{ + private const int Version = 1; + + private ICallGateProvider VersionGate { get; } + private ICallGateProvider SetProviderGate { get; } + + // Written from the registering plugin's thread, read from the message + // processing thread; reference reads/writes are atomic. + private ICallGateSubscriber? Provider; + + public bool HasProvider => Provider != null; + + public StyleIpc() + { + VersionGate = Plugin.Interface.GetIpcProvider("ChatTwo.StyleVersion"); + VersionGate.RegisterFunc(() => Version); + + SetProviderGate = Plugin.Interface.GetIpcProvider("ChatTwo.SetMessageStyleProvider"); + SetProviderGate.RegisterAction(SetProvider); + } + + private void SetProvider(string gateName) + { + Provider = string.IsNullOrEmpty(gateName) + ? null + : Plugin.Interface.GetIpcSubscriber(gateName); + } + + /// + /// Asks the registered style provider for a background colour (RGBA, 0 for + /// none) and alpha (1 = normal, 0 < a < 1 = faded, <= 0 = hidden + /// from the log) for a message. Called once per message on the message + /// processing thread; providers must be thread-safe. + /// + public MessageStyle Evaluate(string senderName, string senderWorld, ulong contentId, ushort chatType, string senderRaw, string contentText) + { + if (Provider is not { } provider) + return (0, 1f); + + try + { + return provider.InvokeFunc(senderName, senderWorld, contentId, chatType, senderRaw, contentText); + } + catch (Exception) + { + // A broken or unloaded provider must never break message + // ingestion; unstyled is the safe fallback. + return (0, 1f); + } + } + + public void Dispose() + { + SetProviderGate.UnregisterAction(); + VersionGate.UnregisterFunc(); + } +} diff --git a/ChatTwo/Message.cs b/ChatTwo/Message.cs index 033c13b..8d43561 100755 --- a/ChatTwo/Message.cs +++ b/ChatTwo/Message.cs @@ -34,6 +34,11 @@ public partial class Message public Dictionary Height { get; } = new(); public Dictionary IsVisible { get; } = new(); + // Set once at ingestion via the message styling IPC; messages loaded from + // the database render unstyled. + public uint StyleBackground; // RGBA, 0 = no background + public float StyleAlpha = 1f; // <= 0 hides the message from the log (it stays stored) + public Message(ulong receiver, ulong contentId, ulong accountId, ChatCode code, List sender, List content, SeString senderSource, SeString contentSource) { var extraChatChannel = ExtractExtraChatChannel(contentSource); diff --git a/ChatTwo/MessageManager.cs b/ChatTwo/MessageManager.cs index 7800423..b492e3f 100644 --- a/ChatTwo/MessageManager.cs +++ b/ChatTwo/MessageManager.cs @@ -7,6 +7,7 @@ using Dalamud.Game.Chat; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Hooking; using Dalamud.Interface.ImGuiNotification; using Dalamud.Plugin.Services; @@ -276,6 +277,18 @@ private void ProcessMessage(PendingMessage pendingMessage) var contentChunks = ChunkUtil.ToChunks(pendingMessage.Content, ChunkSource.Content, chatCode.Type).ToList(); var message = new Message(CurrentContentId, pendingMessage.ContentId, pendingMessage.AccountId, chatCode, senderChunks, contentChunks, pendingMessage.Sender, pendingMessage.Content); + if (Plugin.StyleIpc.HasProvider) + { + var senderPayload = pendingMessage.Sender.Payloads.OfType().FirstOrDefault(); + (message.StyleBackground, message.StyleAlpha) = Plugin.StyleIpc.Evaluate( + senderPayload?.PlayerName ?? "", + senderPayload?.World.ValueNullable?.Name.ExtractText() ?? "", + pendingMessage.ContentId, + (ushort) pendingMessage.LogKind, + pendingMessage.Sender.TextValue, + pendingMessage.Content.TextValue); + } + var isBattle = message.Code.IsBattle(); var isCraftOrGather = message.Code.IsCraftOrGather(); if ((!isBattle && !isCraftOrGather) || (isBattle && Plugin.Config.DatabaseBattleMessages) || (isCraftOrGather && Plugin.Config.DatabaseGatherCraftMessages)) diff --git a/ChatTwo/Plugin.cs b/ChatTwo/Plugin.cs index 785e4ef..43a7a79 100755 --- a/ChatTwo/Plugin.cs +++ b/ChatTwo/Plugin.cs @@ -62,6 +62,7 @@ public sealed class Plugin : IDalamudPlugin public IpcManager Ipc { get; } public ExtraChat ExtraChat { get; } public TypingIpc TypingIpc { get; } + public StyleIpc StyleIpc { get; } public FontManager FontManager { get; } public readonly ServerCore ServerCore; @@ -130,6 +131,9 @@ public Plugin() Commands = new Commands(); Functions = new GameFunctions.GameFunctions(this); + // Constructed before IpcManager so the style gates are already + // registered when the ChatTwo.Available broadcast fires. + StyleIpc = new StyleIpc(); Ipc = new IpcManager(); TypingIpc = new TypingIpc(this); ExtraChat = new ExtraChat(); @@ -217,6 +221,7 @@ public void Dispose() SeStringDebugger?.Dispose(); TypingIpc?.Dispose(); + StyleIpc?.Dispose(); ExtraChat?.Dispose(); Ipc?.Dispose(); MessageManager?.DisposeAsync().AsTask().Wait(); diff --git a/ChatTwo/Ui/ChatLog/ChatLog.Window.cs b/ChatTwo/Ui/ChatLog/ChatLog.Window.cs index bda787e..2c669b9 100644 --- a/ChatTwo/Ui/ChatLog/ChatLog.Window.cs +++ b/ChatTwo/Ui/ChatLog/ChatLog.Window.cs @@ -652,6 +652,10 @@ private void DrawLogTableStyle(Tab tab, PayloadHandler handler, bool switchedTab private void DrawMessages(Tab tab, PayloadHandler handler, bool isTable, bool moreCompact = false, float oldCellPaddingY = 0) { + // Set while the classic-layout background has the draw list split, so + // an exception mid-message can't leave the list split (the next + // frame's split would then assert inside ImGui). + var backgroundSplitActive = false; try { // This may produce ApplicationException which is catched below. @@ -681,6 +685,16 @@ private void DrawMessages(Tab tab, PayloadHandler handler, bool isTable, bool mo message.IsVisible[tab.Identifier] = false; } + // Hidden by the message style provider; the message stays + // stored, logged and exported. Skipped before duplicate + // collapsing so a hidden message neither anchors nor counts + // toward a collapse run. + if (message.StyleAlpha <= 0) + { + message.IsVisible[tab.Identifier] = false; + continue; + } + if (Plugin.Config.CollapseDuplicateMessages) { var messageHash = message.Hash; @@ -760,6 +774,30 @@ [new TextChunk(ChunkSource.None, null, $" ({sameCount + 1}x)") { FallbackColor = message.IsVisible[tab.Identifier] = nowVisible; } + var applyBackground = message.StyleBackground != 0; + if (applyBackground && isTable) + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, ColourUtil.RgbaToAbgr(message.StyleBackground)); + + // In the classic layout the background's size is only known + // after the message is drawn (word wrapping), so draw the text + // into the upper draw list channel and fill the rect behind it + // afterwards. + var drawList = ImGui.GetWindowDrawList(); + var splitBackground = applyBackground && !isTable; + var backgroundStart = Vector2.Zero; + var backgroundWidth = 0f; + if (splitBackground) + { + backgroundStart = ImGui.GetCursorScreenPos(); + backgroundWidth = ImGui.GetContentRegionAvail().X; + drawList.ChannelsSplit(2); + drawList.ChannelsSetCurrent(1); + backgroundSplitActive = true; + } + + // Faded by the message style provider. + using var styleAlpha = ImRaii.PushStyle(ImGuiStyleVar.Alpha, ImGui.GetStyle().Alpha * message.StyleAlpha, message.StyleAlpha < 1f); + if (tab.DisplayTimestamp) { var localTime = message.Date.ToLocalTime(); @@ -809,6 +847,15 @@ [new TextChunk(ChunkSource.None, null, $" ({sameCount + 1}x)") { FallbackColor = InputHandler.ChunkHandler.DrawChunks(message.Content, true, handler, lineWidth); message.IsVisible[tab.Identifier] = ImGui.IsItemVisible(); + + if (splitBackground) + { + drawList.ChannelsSetCurrent(0); + var backgroundEnd = new Vector2(backgroundStart.X + backgroundWidth, ImGui.GetItemRectMax().Y); + drawList.AddRectFilled(backgroundStart, backgroundEnd, ColourUtil.RgbaToAbgr(message.StyleBackground)); + drawList.ChannelsMerge(); + backgroundSplitActive = false; + } } } catch (ApplicationException) @@ -820,6 +867,11 @@ [new TextChunk(ChunkSource.None, null, $" ({sameCount + 1}x)") { FallbackColor = { Plugin.Log.Warning(ex, "Error drawing chat log"); } + finally + { + if (backgroundSplitActive) + ImGui.GetWindowDrawList().ChannelsMerge(); + } } private void DrawTabBar() diff --git a/ipc.md b/ipc.md index 94aeb51..91aa850 100755 --- a/ipc.md +++ b/ipc.md @@ -127,3 +127,103 @@ public sealed class TypingIntegration { ``` All integrations are called inside of an ImGui `BeginMenu`. + +# Message Styling IPC + +Plugins can style individual messages in the Chat 2 log — a per-message +background colour, fading, or hiding a message from display (e.g. group +highlighting or fading chat from far-away players). Hidden and faded messages +are only affected visually: they are still stored in the database, exported, +and shown in the web interface. + +- `ChatTwo.StyleVersion`: call this function to retrieve the styling IPC + version (currently `1`). +- `ChatTwo.SetMessageStyleProvider`: call this action with the name of an IPC + gate **your** plugin provides. Chat 2 will invoke that gate once per + incoming message. Pass an empty string to unregister. Only one provider is + active at a time (last writer wins). +- Re-register when the `ChatTwo.Available` event fires (Chat 2 was loaded or + updated). + +Your provider gate must have this signature: + +``` +(string senderName, string senderWorld, ulong contentId, ushort chatType, string senderRaw, string contentText) + → (uint BackgroundRgba, float Alpha) +``` + +- `senderName` / `senderWorld`: name and home world taken from the first + `PlayerPayload` in the sender SeString; empty strings if the sender is not a + player. +- `contentId`: the sender's content ID, or 0 if unknown. Use it transiently + only — the plugin submission rules forbid storing other players' content + IDs. +- `chatType`: the raw `XivChatType` / LogKind value of the message. +- `senderRaw`: the full sender text including party position or friend-group + glyphs and cross-world affixes. +- `contentText`: the message content as plain text (payloads stripped). +- Returns: `BackgroundRgba` as `RR GG BB AA` (use `0` for no background) and + `Alpha` (`1` = normal, between `0` and `1` = faded, `<= 0` = hidden from the + log). + +Notes: + +- The provider is called **once per message at ingestion, on a background + thread** — it must be thread-safe, must not assume the framework thread, and + should return quickly. If it throws, the message renders unstyled. +- Styles are fixed at ingestion. Provider configuration changes only affect + new messages, and messages loaded from the database render unstyled. +- With the timestamp table layout ("prettier timestamps") the background tints + the whole row; in the classic layout it is drawn behind the message text. + +Example: + +```cs +public sealed class StyleIntegration : IDisposable { + private ICallGateSubscriber StyleVersion { get; } + private ICallGateSubscriber SetMessageStyleProvider { get; } + private ICallGateSubscriber Available { get; } + private ICallGateProvider Provider { get; } + + public StyleIntegration(IDalamudPluginInterface pluginInterface) { + StyleVersion = pluginInterface.GetIpcSubscriber("ChatTwo.StyleVersion"); + SetMessageStyleProvider = pluginInterface.GetIpcSubscriber("ChatTwo.SetMessageStyleProvider"); + Available = pluginInterface.GetIpcSubscriber("ChatTwo.Available"); + + Provider = pluginInterface.GetIpcProvider("MyPlugin.MessageStyle"); + Provider.RegisterFunc(GetStyle); + + // Re-register when Chat 2 loads or updates, and register now in case + // it is already loaded. + Available.Subscribe(Register); + Register(); + } + + private void Register() { + try { + if (StyleVersion.InvokeFunc() == 1) + SetMessageStyleProvider.InvokeAction("MyPlugin.MessageStyle"); + } catch { + // Chat 2 is not loaded. + } + } + + private (uint BackgroundRgba, float Alpha) GetStyle(string senderName, string senderWorld, ulong contentId, ushort chatType, string senderRaw, string contentText) { + // Tint messages from a specific player, fade everyone on Novice + // Network, leave everything else untouched. + if (senderName == "Haurchefant Greystone") + return (0x0080FF30u, 1f); + + return (XivChatType) chatType == XivChatType.NoviceNetwork ? (0u, 0.5f) : (0u, 1f); + } + + public void Dispose() { + Available.Unsubscribe(Register); + try { + SetMessageStyleProvider.InvokeAction(""); + } catch { + // Chat 2 is not loaded. + } + } +} +```