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 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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++)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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