feat(ui): add sender name display options

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 17:12:50 +02:00
parent ba4cd918da
commit c652b102fc
6 changed files with 243 additions and 0 deletions
+7
View File
@@ -140,6 +140,10 @@ public class Configuration : IPluginConfiguration
public bool SeenPopOutHeaderHint; public bool SeenPopOutHeaderHint;
public bool AutoTellTabsOpenAsPopout; 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) public int GetRetentionDays(ChatType type)
{ {
if (RetentionPerChannelDays.TryGetValue(type, out var userOverride)) if (RetentionPerChannelDays.TryGetValue(type, out var userOverride))
@@ -386,6 +390,9 @@ public class Configuration : IPluginConfiguration
PopOutInputEnabled = other.PopOutInputEnabled; PopOutInputEnabled = other.PopOutInputEnabled;
SeenPopOutHeaderHint = other.SeenPopOutHeaderHint; SeenPopOutHeaderHint = other.SeenPopOutHeaderHint;
AutoTellTabsOpenAsPopout = other.AutoTellTabsOpenAsPopout; AutoTellTabsOpenAsPopout = other.AutoTellTabsOpenAsPopout;
WorldSuffixMode = other.WorldSuffixMode;
NameFormMode = other.NameFormMode;
} }
} }
+40
View File
@@ -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(),
};
}
+8
View File
@@ -3076,6 +3076,14 @@ public sealed class ChatLogWindow : Window
float lineWidth = 0f 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); using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero);
for (var i = 0; i < chunks.Count; i++) for (var i = 0; i < chunks.Count; i++)
+37
View File
@@ -157,6 +157,43 @@ internal sealed class Chat : ISettingsTab
ref Mutable.NotifyPluginDisclosure ref Mutable.NotifyPluginDisclosure
); );
ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_NotifyPluginDisclosure_Description); 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<WorldSuffixMode>())
{
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<NameFormMode>())
{
if (ImGui.Selectable(mode.Name(), Mutable.NameFormMode == mode))
Mutable.NameFormMode = mode;
}
}
}
ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_NameForm_Description);
} }
} }
+98
View File
@@ -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<Chunk> 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<Chunk> ForDisplay(IReadOnlyList<Chunk> 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<Chunk>(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;
}
}
@@ -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,
};
}
}