From 566b4dbec75760c87accbb6f57ed15586a7ffefc Mon Sep 17 00:00:00 2001 From: Shuro <944030+Shuro@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:02:52 +0200 Subject: [PATCH 1/2] Add message styling IPC for per-message background, fade and hide --- ChatTwo/Ipc/StyleIpc.cs | 64 +++++++++++++++++ ChatTwo/Message.cs | 5 ++ ChatTwo/MessageManager.cs | 13 ++++ ChatTwo/Plugin.cs | 5 ++ ChatTwo/Ui/ChatLog/ChatLog.Window.cs | 52 ++++++++++++++ ipc.md | 100 +++++++++++++++++++++++++++ 6 files changed, 239 insertions(+) create mode 100644 ChatTwo/Ipc/StyleIpc.cs diff --git a/ChatTwo/Ipc/StyleIpc.cs b/ChatTwo/Ipc/StyleIpc.cs new file mode 100644 index 00000000..68fcaf86 --- /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 033c13b4..8d43561f 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 7800423a..b492e3fd 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 785e4ef8..43a7a79d 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 bda787e9..2c669b9b 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 94aeb51b..91aa8500 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. + } + } +} +``` From 93f619648bd9f768685a1397f947d171066dc648 Mon Sep 17 00:00:00 2001 From: Shuro <944030+Shuro@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:06:48 +0200 Subject: [PATCH 2/2] Add persistent tab identifiers and per-tab style policies to the styling IPC --- ChatTwo/Configuration.cs | 5 ++- ChatTwo/Ipc/StyleIpc.cs | 51 ++++++++++++++++++++++++++++ ChatTwo/Plugin.cs | 4 +++ ChatTwo/Ui/ChatLog/ChatLog.Window.cs | 15 +++++--- ipc.md | 18 ++++++++++ 5 files changed, 88 insertions(+), 5 deletions(-) diff --git a/ChatTwo/Configuration.cs b/ChatTwo/Configuration.cs index d8e0b920..c89bb84c 100755 --- a/ChatTwo/Configuration.cs +++ b/ChatTwo/Configuration.cs @@ -286,7 +286,10 @@ public class Tab [NonSerialized] public UsedChannel CurrentChannel = new(); - [NonSerialized] public Guid Identifier = Guid.NewGuid(); + // Persisted so per-tab settings from other plugins (tab style policies) + // survive restarts. Configs saved before this field existed get a fresh + // Guid on load, which sticks with the next save. + public Guid Identifier = Guid.NewGuid(); public bool Matches(Message message) { diff --git a/ChatTwo/Ipc/StyleIpc.cs b/ChatTwo/Ipc/StyleIpc.cs index 68fcaf86..6814f567 100644 --- a/ChatTwo/Ipc/StyleIpc.cs +++ b/ChatTwo/Ipc/StyleIpc.cs @@ -8,13 +8,25 @@ public sealed class StyleIpc : IDisposable { private const int Version = 1; + // Suppress-flags for SetTabStylePolicies; a tab without an entry has all + // styling enabled. + public const int PolicySuppressBackground = 1; + public const int PolicySuppressFade = 2; + public const int PolicySuppressHide = 4; + private ICallGateProvider VersionGate { get; } private ICallGateProvider SetProviderGate { get; } + private ICallGateProvider> GetTabsGate { get; } + private ICallGateProvider, object?> TabsChangedGate { get; } + private ICallGateProvider, object?> SetTabPoliciesGate { get; } // Written from the registering plugin's thread, read from the message // processing thread; reference reads/writes are atomic. private ICallGateSubscriber? Provider; + // Swapped as a whole on writes, so render-thread reads are safe. + private Dictionary TabPolicies = new(); + public bool HasProvider => Provider != null; public StyleIpc() @@ -24,8 +36,47 @@ public StyleIpc() SetProviderGate = Plugin.Interface.GetIpcProvider("ChatTwo.SetMessageStyleProvider"); SetProviderGate.RegisterAction(SetProvider); + + GetTabsGate = Plugin.Interface.GetIpcProvider>("ChatTwo.GetTabs"); + GetTabsGate.RegisterFunc(BuildTabList); + + TabsChangedGate = Plugin.Interface.GetIpcProvider, object?>("ChatTwo.TabsChanged"); + + SetTabPoliciesGate = Plugin.Interface.GetIpcProvider, object?>("ChatTwo.SetTabStylePolicies"); + // Copy defensively: the render thread reads this dictionary, so it + // must not share state with (or be a null from) the calling plugin. + SetTabPoliciesGate.RegisterAction(policies => TabPolicies = policies is null ? [] : new Dictionary(policies)); } + private static Dictionary BuildTabList() + => Plugin.Config.Tabs + .Where(tab => !tab.IsTempTab) + .ToDictionary(tab => tab.Identifier, tab => tab.Name); + + /// + /// Pushes the current tab list to subscribers; called whenever the + /// configuration is saved. + /// + public void NotifyTabsChanged() + { + try + { + TabsChangedGate.SendMessage(BuildTabList()); + } + catch (Exception ex) + { + // SendMessage runs subscribers synchronously and does not isolate + // their exceptions; a broken consumer must not break config saves. + Plugin.Log.Warning(ex, "Error in a ChatTwo.TabsChanged subscriber"); + } + } + + /// + /// Suppress-flags for a tab; 0 means all styling is enabled. + /// + public int GetTabPolicy(Guid tabIdentifier) + => TabPolicies.GetValueOrDefault(tabIdentifier, 0); + private void SetProvider(string gateName) { Provider = string.IsNullOrEmpty(gateName) diff --git a/ChatTwo/Plugin.cs b/ChatTwo/Plugin.cs index 43a7a79d..4627810c 100755 --- a/ChatTwo/Plugin.cs +++ b/ChatTwo/Plugin.cs @@ -260,6 +260,10 @@ private void Draw() public void SaveConfig() { Interface.SavePluginConfig(Config); + // Config saves are the choke point for tab changes (add, rename, + // delete, reorder). StyleIpc is null while migrations save during + // load. + StyleIpc?.NotifyTabsChanged(); } public void LanguageChanged(string langCode) diff --git a/ChatTwo/Ui/ChatLog/ChatLog.Window.cs b/ChatTwo/Ui/ChatLog/ChatLog.Window.cs index 2c669b9b..18a6f81d 100644 --- a/ChatTwo/Ui/ChatLog/ChatLog.Window.cs +++ b/ChatTwo/Ui/ChatLog/ChatLog.Window.cs @@ -4,6 +4,7 @@ using ChatTwo.Code; using ChatTwo.GameFunctions; using ChatTwo.GameFunctions.Types; +using ChatTwo.Ipc; using ChatTwo.Resources; using ChatTwo.Ui.Handler; using ChatTwo.Util; @@ -674,6 +675,8 @@ private void DrawMessages(Tab tab, PayloadHandler handler, bool isTable, bool mo int? lastMessageHash = null; var sameCount = 0; + var tabPolicy = Plugin.StyleIpc.GetTabPolicy(tab.Identifier); + var maxLines = Plugin.Config.MaxLinesToRender; var startLine = messages.Count > maxLines ? messages.Count - maxLines : 0; for (var i = startLine; i < messages.Count; i++) @@ -689,7 +692,7 @@ private void DrawMessages(Tab tab, PayloadHandler handler, bool isTable, bool mo // stored, logged and exported. Skipped before duplicate // collapsing so a hidden message neither anchors nor counts // toward a collapse run. - if (message.StyleAlpha <= 0) + if (message.StyleAlpha <= 0 && (tabPolicy & StyleIpc.PolicySuppressHide) == 0) { message.IsVisible[tab.Identifier] = false; continue; @@ -774,7 +777,7 @@ [new TextChunk(ChunkSource.None, null, $" ({sameCount + 1}x)") { FallbackColor = message.IsVisible[tab.Identifier] = nowVisible; } - var applyBackground = message.StyleBackground != 0; + var applyBackground = message.StyleBackground != 0 && (tabPolicy & StyleIpc.PolicySuppressBackground) == 0; if (applyBackground && isTable) ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, ColourUtil.RgbaToAbgr(message.StyleBackground)); @@ -795,8 +798,12 @@ [new TextChunk(ChunkSource.None, null, $" ({sameCount + 1}x)") { FallbackColor = backgroundSplitActive = true; } - // Faded by the message style provider. - using var styleAlpha = ImRaii.PushStyle(ImGuiStyleVar.Alpha, ImGui.GetStyle().Alpha * message.StyleAlpha, message.StyleAlpha < 1f); + // Faded by the message style provider. A message that reaches + // this point with alpha <= 0 had its hiding suppressed by the + // tab policy and renders fully visible. + var effectiveStyleAlpha = message.StyleAlpha <= 0 ? 1f : message.StyleAlpha; + var fade = effectiveStyleAlpha < 1f && (tabPolicy & StyleIpc.PolicySuppressFade) == 0; + using var styleAlpha = ImRaii.PushStyle(ImGuiStyleVar.Alpha, ImGui.GetStyle().Alpha * effectiveStyleAlpha, fade); if (tab.DisplayTimestamp) { diff --git a/ipc.md b/ipc.md index 91aa8500..8ed737ef 100755 --- a/ipc.md +++ b/ipc.md @@ -176,6 +176,24 @@ Notes: - With the timestamp table layout ("prettier timestamps") the background tints the whole row; in the classic layout it is drawn behind the message text. +## Tab awareness + +Styling can be limited per tab. Tabs are identified by a persistent `Guid` +that survives renames, reordering and restarts (pop-out tabs keep the +identifier of their tab). + +- `ChatTwo.GetTabs`: call this function to retrieve the current tabs as + `Dictionary` (identifier → tab name). Temporary tabs are not + included. +- `ChatTwo.TabsChanged`: subscribe to this event (`Dictionary`) + to be notified when tabs are added, renamed, removed or reordered. +- `ChatTwo.SetTabStylePolicies`: call this action with a + `Dictionary` of suppress-flags per tab. Tabs without an entry + have all styling enabled. Flags can be combined: + - `1`: no backgrounds in this tab + - `2`: no fading in this tab + - `4`: no hiding in this tab (affected messages render fully visible) + Example: ```cs