feat(ui): add sender name display options
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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++)
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user