diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index 136b23b..0f9c8f4 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -140,6 +140,10 @@ public class Configuration : IPluginConfiguration public bool SeenPopOutHeaderHint; public bool AutoTellTabsOpenAsPopout; + // UI-7: how sender names are rendered in the chat log. + public WorldSuffixMode WorldSuffixMode = WorldSuffixMode.OtherWorldOnly; + public NameFormMode NameFormMode = NameFormMode.Full; + public int GetRetentionDays(ChatType type) { if (RetentionPerChannelDays.TryGetValue(type, out var userOverride)) @@ -386,6 +390,9 @@ public class Configuration : IPluginConfiguration PopOutInputEnabled = other.PopOutInputEnabled; SeenPopOutHeaderHint = other.SeenPopOutHeaderHint; AutoTellTabsOpenAsPopout = other.AutoTellTabsOpenAsPopout; + + WorldSuffixMode = other.WorldSuffixMode; + NameFormMode = other.NameFormMode; } } diff --git a/HellionChat/NameDisplayModes.cs b/HellionChat/NameDisplayModes.cs new file mode 100644 index 0000000..cbdc140 --- /dev/null +++ b/HellionChat/NameDisplayModes.cs @@ -0,0 +1,40 @@ +using HellionChat.Resources; + +namespace HellionChat; + +// UI-7: how a sender's name is rendered in the chat log. Kept in its own file +// (no Dalamud usings) so the SenderNameFormatter pure-helper test stays +// AppDomain-isolated (feedback_dalamud_test_isolation). + +public enum WorldSuffixMode +{ + Never, + OtherWorldOnly, + Always, +} + +public enum NameFormMode +{ + Full, + FirstNameOnly, + Initials, +} + +public static class NameDisplayModeExt +{ + public static string Name(this WorldSuffixMode mode) => mode switch + { + WorldSuffixMode.Never => HellionStrings.NameDisplay_WorldSuffix_Never, + WorldSuffixMode.OtherWorldOnly => HellionStrings.NameDisplay_WorldSuffix_OtherWorldOnly, + WorldSuffixMode.Always => HellionStrings.NameDisplay_WorldSuffix_Always, + _ => mode.ToString(), + }; + + public static string Name(this NameFormMode mode) => mode switch + { + NameFormMode.Full => HellionStrings.NameDisplay_NameForm_Full, + NameFormMode.FirstNameOnly => HellionStrings.NameDisplay_NameForm_FirstNameOnly, + NameFormMode.Initials => HellionStrings.NameDisplay_NameForm_Initials, + _ => mode.ToString(), + }; +} diff --git a/HellionChat/Ui/ChatLogWindow.cs b/HellionChat/Ui/ChatLogWindow.cs index c3ccd90..c2ac93c 100644 --- a/HellionChat/Ui/ChatLogWindow.cs +++ b/HellionChat/Ui/ChatLogWindow.cs @@ -3076,6 +3076,14 @@ public sealed class ChatLogWindow : Window float lineWidth = 0f ) { + // UI-7: render a copy with the sender name reformatted per the user's + // display options. Skipped in screenshot mode so the name-anonymising + // path in DrawChunk stays reliable (privacy wins). ForDisplay returns + // the list unchanged when nothing applies, so non-sender lists and the + // neutral default cost only a quick scan. + if (!ScreenshotMode) + chunks = SenderNameDisplay.ForDisplay(chunks); + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero); for (var i = 0; i < chunks.Count; i++) diff --git a/HellionChat/Ui/SettingsTabs/Chat.cs b/HellionChat/Ui/SettingsTabs/Chat.cs index 65b75d4..66f1804 100644 --- a/HellionChat/Ui/SettingsTabs/Chat.cs +++ b/HellionChat/Ui/SettingsTabs/Chat.cs @@ -157,6 +157,43 @@ internal sealed class Chat : ISettingsTab ref Mutable.NotifyPluginDisclosure ); ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_NotifyPluginDisclosure_Description); + + // UI-7: name display options. + using ( + var combo = ImGuiUtil.BeginComboVertical( + HellionStrings.Settings_Chat_WorldSuffix_Name, + Mutable.WorldSuffixMode.Name() + ) + ) + { + if (combo.Success) + { + foreach (var mode in Enum.GetValues()) + { + if (ImGui.Selectable(mode.Name(), Mutable.WorldSuffixMode == mode)) + Mutable.WorldSuffixMode = mode; + } + } + } + ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_WorldSuffix_Description); + + using ( + var combo = ImGuiUtil.BeginComboVertical( + HellionStrings.Settings_Chat_NameForm_Name, + Mutable.NameFormMode.Name() + ) + ) + { + if (combo.Success) + { + foreach (var mode in Enum.GetValues()) + { + if (ImGui.Selectable(mode.Name(), Mutable.NameFormMode == mode)) + Mutable.NameFormMode = mode; + } + } + } + ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_NameForm_Description); } } diff --git a/HellionChat/Util/SenderNameDisplay.cs b/HellionChat/Util/SenderNameDisplay.cs new file mode 100644 index 0000000..9446076 --- /dev/null +++ b/HellionChat/Util/SenderNameDisplay.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using Dalamud.Game.Text.SeStringHandling.Payloads; +using HellionChat._Helpers; + +namespace HellionChat.Util; + +// UI-7: produces a render-only view of a chunk list with the sender's name +// reformatted per the user's WorldSuffixMode / NameFormMode. Called from +// ChatLogWindow.DrawChunks on every draw — it never mutates the input, so the +// stored message (the Sender BLOB in the DB) stays byte-for-byte unchanged and +// a later settings change reformats history too. +// Known trade-off: at non-default settings, this allocates one List per +// visible message per frame. Lists are small and the path is skipped at the +// neutral defaults, so GC pressure is low in practice. Accepted; noted in +// Cycle Notes. +internal static class SenderNameDisplay +{ + // Returns a copy of the list with the whole ChunkSource.Sender span + // collapsed to one formatted chunk, or the original list when nothing + // should change (neutral defaults, no sender span, or a non-player sender). + internal static IReadOnlyList ForDisplay(IReadOnlyList chunks) + { + var suffixMode = Plugin.Config.WorldSuffixMode; + var formMode = Plugin.Config.NameFormMode; + + // Neutral default = today's rendering. Bail before scanning so the + // common case stays free. + if (suffixMode == WorldSuffixMode.OtherWorldOnly && formMode == NameFormMode.Full) + return chunks; + + // Locate the sender span. A content list has no Sender chunks and is + // returned untouched. + var first = -1; + var last = -1; + for (var i = 0; i < chunks.Count; i++) + { + if (chunks[i].Source != ChunkSource.Sender) + continue; + if (first < 0) + first = i; + last = i; + } + if (first < 0) + return chunks; + + // The PlayerPayload rides on the sender chunks; a system or non-player + // sender has none and is left alone. + PlayerPayload? payload = null; + for (var i = first; i <= last; i++) + { + if (chunks[i].Link is PlayerPayload pp) + { + payload = pp; + break; + } + } + if (payload is null) + return chunks; + + var worldName = payload.World.ValueNullable?.Name.ExtractText() ?? string.Empty; + + // IPlayerState.HomeWorld reads AgentLobby directly without a + // framework-thread guard (reference_dalamud_framework_thread), so this + // call is safe from the draw thread. + // RowId is a value field — safe to read directly without ValueNullable, unlike the world name above. + var isHomeWorld = payload.World.RowId == Plugin.PlayerState.HomeWorld.RowId; + + var formatted = SenderNameFormatter.Format( + payload.PlayerName, worldName, isHomeWorld, formMode, suffixMode); + + // Render-only copy: replace the whole sender span (name text, world + // text, and any cross-world icon) with one formatted chunk that keeps + // the PlayerPayload link so the name stays clickable. Dropping the + // original sender icon is an accepted trade-off and only happens once + // the user moves UI-7 off its defaults. + // Channel brackets and colons are ChunkSource.None wrappers outside the + // Sender span — they are preserved untouched by the copy loops below. + var copy = new List(chunks.Count); + for (var i = 0; i < first; i++) + copy.Add(chunks[i]); + TextChunk? styleSource = null; + for (var i = first; i <= last; i++) + { + if (chunks[i] is TextChunk tc) + { + styleSource = tc; + break; + } + } + var replacement = styleSource is not null + ? styleSource.NewWithStyle(ChunkSource.Sender, payload, formatted) + : new TextChunk(ChunkSource.Sender, payload, formatted); + copy.Add(replacement); + for (var i = last + 1; i < chunks.Count; i++) + copy.Add(chunks[i]); + return copy; + } +} diff --git a/HellionChat/_Helpers/SenderNameFormatter.cs b/HellionChat/_Helpers/SenderNameFormatter.cs new file mode 100644 index 0000000..a17d3dc --- /dev/null +++ b/HellionChat/_Helpers/SenderNameFormatter.cs @@ -0,0 +1,53 @@ +using System; + +namespace HellionChat._Helpers; + +// UI-7 pure decision helper: builds the display string for a sender's name + +// world per the user's NameFormMode / WorldSuffixMode. Dalamud-free so the +// Build Suite can cover every combination; SenderNameDisplay feeds it the name +// and world it pulled from the PlayerPayload. +// TEST-MIRROR: ../../../Hellion Build test/Ui/SenderNameFormatterTests.cs +public static class SenderNameFormatter +{ + public static string Format( + string fullName, + string worldName, + bool isHomeWorld, + NameFormMode formMode, + WorldSuffixMode suffixMode) + { + var name = FormatName(fullName, formMode); + + var showWorld = suffixMode switch + { + WorldSuffixMode.Never => false, + WorldSuffixMode.Always => true, + WorldSuffixMode.OtherWorldOnly => !isHomeWorld, + _ => !isHomeWorld, + }; + + return showWorld && !string.IsNullOrEmpty(worldName) + ? $"{name}@{worldName}" + : name; + } + + private static string FormatName(string fullName, NameFormMode mode) + { + if (mode == NameFormMode.Full) + return fullName; + + // FFXIV character names are two words. Anything else (one word, odd + // spacing) falls back to the full name rather than risking an empty or + // misleading render. + var parts = fullName.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + return fullName; + + return mode switch + { + NameFormMode.FirstNameOnly => parts[0], + NameFormMode.Initials => $"{parts[0][0]}. {parts[^1][0]}.", + _ => fullName, + }; + } +}