build: rename repository folder ChatTwo to HellionChat

Repository folder, csproj, solution and all CI/build paths now use
the consolidated HellionChat name.

- ChatTwo/ → HellionChat/ (git mv preserves history with --follow)
- ChatTwo.csproj → HellionChat.csproj
- ChatTwo.sln → HellionChat.sln; obsolete Tests project entry removed
  (private/untracked sandbox)
- AssemblyInfo.cs InternalsVisibleTo for ChatTwo.Tests removed
  (file emptied; can be repopulated when actual tests land)
- repo.json and yaml image URLs updated (ChatTwo/images/ → HellionChat/images/)
- .github/workflows/{build,codeql,release}.yml csproj paths
- .github/dependabot.yml directory path

Functional behavior unchanged.
This commit is contained in:
2026-05-03 21:30:07 +02:00
parent cd6afb32cb
commit 1f7f0945c5
114 changed files with 18 additions and 25 deletions
+418
View File
@@ -0,0 +1,418 @@
using System;
using System.Collections.Generic;
using System.Linq;
using HellionChat.Code;
using HellionChat.GameFunctions.Types;
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
namespace HellionChat;
// Hellion Chat — Auto-Tell-Tabs.
//
// Spawns a session-only tab per /tell partner so a club greeter can track
// multiple parallel conversations without losing context. Subscribes to
// MessageManager.MessageProcessed for live tells and to ClientState.Logout
// for the cleanup pass; everything else hangs off these two entry points.
//
// See spec: Hellion Chat Auto-Tell-Tabs Spec (Obsidian vault).
internal sealed class AutoTellTabsService : IDisposable
{
private readonly Plugin _plugin;
private readonly MessageManager _messageManager;
private readonly MessageStore _store;
private readonly object _tempTabsLock = new();
private bool _initialized;
internal AutoTellTabsService(Plugin plugin, MessageManager messageManager, MessageStore store)
{
_plugin = plugin;
_messageManager = messageManager;
_store = store;
}
internal int ActiveTempTabCount
{
get
{
lock (_tempTabsLock)
{
return Plugin.Config.Tabs.Count(t => t.IsTempTab);
}
}
}
internal void Initialize()
{
if (_initialized)
{
return;
}
_messageManager.MessageProcessed += HandleTell;
Plugin.ClientState.Logout += OnLogout;
_initialized = true;
}
public void Dispose()
{
if (!_initialized)
{
return;
}
Plugin.ClientState.Logout -= OnLogout;
_messageManager.MessageProcessed -= HandleTell;
_initialized = false;
}
internal void HandleTell(Message message)
{
if (!Plugin.Config.EnableAutoTellTabs)
{
return;
}
if (message.Code.Type != ChatType.TellIncoming && message.Code.Type != ChatType.TellOutgoing)
{
return;
}
var partner = ExtractTellPartner(message);
if (partner == null)
{
// Real message without a player payload — e.g. GM tells, which
// we deliberately skip. The diagnostics make future regressions
// (FFXIV changing tell payload shape, new edge cases) findable
// without having to crank up debug logging at the source.
Plugin.Log.Warning(
$"[AutoTellTabs] Could not extract tell partner. type={message.Code.Type}, " +
$"senderChunks={message.Sender.Count}, contentChunks={message.Content.Count}, " +
$"senderSourcePayloads={message.SenderSource?.Payloads?.Count ?? 0}, " +
$"contentSourcePayloads={message.ContentSource?.Payloads?.Count ?? 0}");
return;
}
lock (_tempTabsLock)
{
var existing = FindTempTab(partner.Value.Name, partner.Value.World);
if (existing != null)
{
// Tab already exists; Tab.Matches has already routed this
// message via the MessageManager pipeline (see Task 2 sender
// filter).
return;
}
if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit)
{
DropOldestTempTab();
}
SpawnTempTab(partner.Value, message);
}
}
private (string Name, uint World)? ExtractTellPartner(Message message)
{
if (message.Code.Type == ChatType.TellIncoming)
{
// Incoming tell: the sender is the conversation partner. The
// PlayerPayload normally rides on a chunk's Link slot, but for
// some tell types FFXIV only puts it in the raw SeString —
// fall back to that before giving up.
var fromSender = ChunkUtil.TryGetPlayerPayload(message.Sender)
?? ChunkUtil.TryGetPlayerPayload(message.SenderSource);
if (fromSender != null)
{
return (fromSender.PlayerName, fromSender.World.RowId);
}
return null;
}
// Outgoing tell: the local player is the sender, the partner shows
// up either as a payload in the content (for tells typed via the
// Chat 2 input bar) or as the channel's tracked tell target (set by
// the SetContextTellTarget game hook). Same SeString fallback.
var fromContent = ChunkUtil.TryGetPlayerPayload(message.Content)
?? ChunkUtil.TryGetPlayerPayload(message.ContentSource)
?? ChunkUtil.TryGetPlayerPayload(message.Sender)
?? ChunkUtil.TryGetPlayerPayload(message.SenderSource);
if (fromContent != null)
{
return (fromContent.PlayerName, fromContent.World.RowId);
}
var current = _plugin.CurrentTab.CurrentChannel.TellTarget
?? _plugin.CurrentTab.CurrentChannel.TempTellTarget;
if (current != null && current.IsSet())
{
return (current.Name, current.World);
}
return null;
}
private Tab? FindTempTab(string name, uint world)
{
return Plugin.Config.Tabs.FirstOrDefault(t =>
t.IsTempTab
&& t.TellTarget != null
&& string.Equals(t.TellTarget.Name, name, StringComparison.OrdinalIgnoreCase)
&& t.TellTarget.World == world);
}
private void DropOldestTempTab()
{
// Greeted tabs are dropped before un-greeted ones (the user said
// "I'm done with that conversation"), and within each bucket we
// pick the oldest LastActivity. This protects active conversations
// and unfinished greetings while still freeing up a slot.
var victim = Plugin.Config.Tabs
.Select((tab, idx) => (Tab: tab, Index: idx))
.Where(t => t.Tab.IsTempTab)
.OrderByDescending(t => t.Tab.IsGreeted)
.ThenBy(t => t.Tab.LastActivity)
.FirstOrDefault();
if (victim.Tab == null)
{
return;
}
// v0.6.1 — if the victim is currently popped out, tear down the
// matching Popout window first. Otherwise the window stays in
// PopOutWindows + WindowSystem and renders empty / re-spawns on the
// next AddPopOutsToDraw tick. Latent since pop-outs were introduced;
// becomes visible with AutoTellTabsOpenAsPopout where dropping a
// popped tab is now a routine code path.
if (victim.Tab.PopOut)
{
var popout = _plugin.ChatLogWindow.ActivePopouts
.FirstOrDefault(p => p.TabIdentifier == victim.Tab.Identifier);
if (popout != null)
{
popout.IsOpen = false;
}
}
Plugin.Config.Tabs.RemoveAt(victim.Index);
// Re-anchor the active tab so the user does not silently end up on
// a different conversation when their tab gets dropped or shifted.
if (victim.Index <= _plugin.LastTab)
{
_plugin.WantedTab = 0;
}
}
private void SpawnTempTab((string Name, uint World) partner, Message currentMessage)
{
var tab = BuildTempTab(partner.Name, partner.World);
// Preload first so the tab opens with chronological history above
// the current message — and so a slow DB query never causes a
// visible "empty tab, then history pops in" effect on screen.
// The current message is already persisted in the store by the
// time MessageProcessed fires (see MessageManager.cs: UpsertMessage
// runs before the event), so we have to exclude it explicitly to
// avoid the separator landing below the live tell.
PreloadHistory(tab, partner.Name, partner.World, currentMessage.Id);
tab.AddMessage(currentMessage, unread: true);
// Hellion Chat v0.6.1 — opt-in: open new /tell tabs directly as a
// pop-out window. Set BEFORE Tabs.Add so the next render-tick's
// AddPopOutsToDraw() sees PopOut=true and spawns the Popout window
// alongside the tab going into the list. No SaveConfig() because
// auto-tell tabs are IsTempTab (session-only, never persisted).
if (Plugin.Config.AutoTellTabsOpenAsPopout)
{
tab.PopOut = true;
}
Plugin.Config.Tabs.Add(tab);
}
private static Tab BuildTempTab(string playerName, uint worldRowId)
{
return new Tab
{
Name = FormatTabName(playerName, worldRowId),
IsTempTab = true,
AllSenderMessages = true,
TellTarget = new TellTarget(playerName, worldRowId, 0, TellReason.Direct),
Channel = InputChannel.Tell,
DisplayTimestamp = true,
UnreadMode = UnreadMode.Unseen,
HideWhenInactive = false,
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
[ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All),
},
};
}
private static string FormatTabName(string playerName, uint worldRowId)
{
if (Sheets.WorldSheet.TryGetRow(worldRowId, out var worldRow))
{
return $"{playerName}@{worldRow.Name}";
}
// World sheet lookup miss is rare (only for FFXIV worlds Dalamud has
// not yet seen). Fall back to the raw RowId so the user still has a
// unique, readable label.
return $"{playerName}@World{worldRowId}";
}
private void PreloadHistory(Tab tab, string senderName, uint senderWorld, Guid currentMessageId)
{
var preloadCount = Plugin.Config.AutoTellTabsHistoryPreload;
if (preloadCount <= 0)
{
return;
}
try
{
// Pull one extra row because the live tell that triggered this
// spawn is already in the store and would otherwise eat one of
// the user's preload-budget slots.
var history = _store.GetTellHistoryWithSender(
_messageManager.CurrentContentId,
senderName,
senderWorld,
preloadCount + 1);
var historicMessages = history
.Where(m => m.Id != currentMessageId)
.Take(preloadCount)
.ToList();
if (historicMessages.Count == 0)
{
// No prior tells with this player — leave the tab to start
// empty so the user does not see a "history loaded" marker
// sitting alone above the very first message.
return;
}
// The history list is already oldest-first, so a plain AddPrune
// loop produces the chronological order the user expects to see
// when the tab opens.
foreach (var message in historicMessages)
{
tab.Messages.AddPrune(message, MessageManager.MessageDisplayLimit);
}
// Visible separator between the loaded history and the live
// tell that triggered this spawn. Goes in last so it sorts
// after the historical messages but before the current one.
tab.Messages.AddPrune(
MakeSystemMarker(HellionStrings.AutoTellTabs_HistorySeparator),
MessageManager.MessageDisplayLimit);
}
catch (Exception ex)
{
// Non-fatal: the tab still spawns, but the user gets a visible
// notice instead of silently missing history. The error logs
// once with full stack trace for diagnosis.
Plugin.Log.Error(ex, "[AutoTellTabs] History preload failed");
tab.Messages.AddPrune(
MakeSystemMarker(HellionStrings.AutoTellTabs_HistoryLoadError),
MessageManager.MessageDisplayLimit);
}
}
private static Message MakeSystemMarker(string text)
{
var seString = new SeStringBuilder().AddText(text).Build();
var chunks = ChunkUtil.ToChunks(seString, ChunkSource.Content, ChatType.System).ToList();
var code = new ChatCode((XivChatType)ChatType.System, 0, 0);
return Message.FakeMessage(chunks, code);
}
internal void MarkGreeted(Tab tab)
{
SetGreeted(tab, true);
}
internal void UnmarkGreeted(Tab tab)
{
SetGreeted(tab, false);
}
internal bool IsGreeted(Tab tab)
{
return tab.IsGreeted;
}
private void SetGreeted(Tab tab, bool greeted)
{
if (tab == null)
{
return;
}
lock (_tempTabsLock)
{
// Frame-race guard (E5): the sidebar might still render a tab
// that has already been removed by LRU drop or logout cleanup.
// Silently skip the toggle so we don't mutate stale state.
if (!Plugin.Config.Tabs.Contains(tab))
{
return;
}
tab.IsGreeted = greeted;
}
}
private void OnLogout(int type, int code)
{
lock (_tempTabsLock)
{
// Snapshot whether the active tab is about to be removed, BEFORE
// we mutate the list — index lookups would lie to us afterwards.
var lastIndex = _plugin.LastTab;
var lastIndexValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count;
var currentWasTempTab = lastIndexValid && Plugin.Config.Tabs[lastIndex].IsTempTab;
// v0.6.1 — symmetric to DropOldestTempTab cleanup: tear down any
// popped-out temp tab windows before removing the tabs themselves,
// otherwise PopOutWindows + WindowSystem keep ghost entries until
// the next plugin reload. Especially relevant once Auto-Pop-Out is
// enabled — every logout would otherwise leak as many ghosts as
// there were active /tell pop-outs.
var poppedTempTabIds = Plugin.Config.Tabs
.Where(t => t.IsTempTab && t.PopOut)
.Select(t => t.Identifier)
.ToList();
if (poppedTempTabIds.Count > 0)
{
var poppedSet = poppedTempTabIds.ToHashSet();
foreach (var popout in _plugin.ChatLogWindow.ActivePopouts
.Where(p => poppedSet.Contains(p.TabIdentifier))
.ToList())
{
popout.IsOpen = false;
}
}
Plugin.Config.Tabs.RemoveAll(t => t.IsTempTab);
// Force a switch to tab 0 if the active tab was a temp tab OR
// if drops before the active index pushed LastTab out of range.
// Otherwise the user keeps their current persistent tab.
var stillValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count;
if (currentWasTempTab || !stillValid)
{
_plugin.WantedTab = 0;
}
}
}
}
+27
View File
@@ -0,0 +1,27 @@
using System.Linq;
using HellionChat.Resources;
using Dalamud.Plugin;
namespace HellionChat;
internal static class ChatTwoConflictDetector
{
private const string UpstreamInternalName = "ChatTwo";
public static void ThrowIfChatTwoIsLoaded(IDalamudPluginInterface pluginInterface)
{
var conflict = pluginInterface.InstalledPlugins
.FirstOrDefault(p =>
p.InternalName == UpstreamInternalName &&
p.IsLoaded);
if (conflict is null)
return;
var message = HellionStrings.ChatTwoConflictTitle + "\n\n" +
HellionStrings.ChatTwoConflictBody + "\n\n" +
HellionStrings.ChatTwoConflictAction;
throw new System.InvalidOperationException(message);
}
}
+129
View File
@@ -0,0 +1,129 @@
using HellionChat.Code;
using Dalamud.Game.Text.SeStringHandling;
using MessagePack;
namespace HellionChat;
[Union(0, typeof(TextChunk))]
[Union(1, typeof(IconChunk))]
[MessagePackObject]
public abstract class Chunk
{
[IgnoreMember]
internal Message? Message { get; set; }
[Key(0)]
public ChunkSource Source { get; set; }
[Key(1)]
[MessagePackFormatter(typeof(PayloadMessagePackFormatter))]
public Payload? Link { get; set; }
protected Chunk(ChunkSource source, Payload? link)
{
Source = source;
Link = link;
}
internal SeString? GetSeString() => Source switch
{
ChunkSource.None => null,
ChunkSource.Sender => Message?.SenderSource,
ChunkSource.Content => Message?.ContentSource,
_ => null,
};
/// <summary>
/// Get some basic text for use in generating hashes.
/// </summary>
internal string StringValue()
{
return this switch
{
TextChunk text => text.Content,
IconChunk icon => icon.Icon.ToString(),
_ => ""
};
}
}
public enum ChunkSource
{
None,
Sender,
Content,
}
[MessagePackObject(AllowPrivate = true)]
public class TextChunk : Chunk
{
[Key(2)] public ChatType? FallbackColour;
[Key(3)] public uint? Foreground;
[Key(4)] public uint? Glow;
[Key(5)] public bool Italic;
[Key(6)] public string Content;
private TextChunk(Chunk chunk, string content) : base(chunk.Source, chunk.Link)
{
Content = content;
}
internal TextChunk(ChunkSource source, Payload? link, string content) : base(source, link)
{
// This has been null in the past, and it broke rendering code.
// ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
Content = content ?? "";
}
// ReSharper disable once UnusedMember.Global // Used by MessagePack
public TextChunk(ChunkSource source, Payload? link, ChatType? fallbackColour, uint? foreground, uint? glow, bool italic, string content) : base(source, link)
{
FallbackColour = fallbackColour;
Foreground = foreground;
Glow = glow;
Italic = italic;
// See above.
// ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
Content = content ?? "";
}
/// <summary>
/// Creates a new TextChunk with identical styling to this one.
/// </summary>
public TextChunk NewWithStyle(ChunkSource source, Payload? link, string content)
{
return new TextChunk(source, link, content)
{
FallbackColour = FallbackColour,
Foreground = Foreground,
Glow = Glow,
Italic = Italic,
};
}
/// <summary>
/// Creates a new TextChunk with identical styling to this one.
/// </summary>
public TextChunk NewWithStyle(Chunk chunk, string content)
{
return new TextChunk(chunk, content)
{
FallbackColour = FallbackColour,
Foreground = Foreground,
Glow = Glow,
Italic = Italic,
};
}
}
[MessagePackObject(AllowPrivate = true)]
public class IconChunk : Chunk
{
[Key(2)]
public BitmapFontIcon Icon { get; set; }
public IconChunk(ChunkSource source, Payload? link, BitmapFontIcon icon) : base(source, link)
{
Icon = icon;
}
}
+107
View File
@@ -0,0 +1,107 @@
using Dalamud.Game.Text;
namespace HellionChat.Code;
public class ChatCode
{
public ChatType Type { get; }
public XivChatRelationKind Source { get; }
public XivChatRelationKind Target { get; }
public ChatCode(XivChatType type, XivChatRelationKind source, XivChatRelationKind target)
{
Type = (ChatType)type;
Source = source;
Target = target;
}
public ChatCode(byte type, byte source, byte target)
: this((XivChatType)type, (XivChatRelationKind)source, (XivChatRelationKind)target) {}
public bool IsBattle()
{
switch (Type)
{
// Error isn't a battle message, but it can be just as spammy if you
// use macros with unavailable actions.
case ChatType.Error:
case ChatType.Damage:
case ChatType.Miss:
case ChatType.Action:
case ChatType.Item:
case ChatType.Healing:
case ChatType.GainBuff:
case ChatType.LoseBuff:
case ChatType.GainDebuff:
case ChatType.LoseDebuff:
case ChatType.BattleSystem:
return true;
default:
return false;
}
}
public bool IsPlayerMessage()
{
switch (Type)
{
case ChatType.Say:
case ChatType.Shout:
case ChatType.TellOutgoing:
case ChatType.TellIncoming:
case ChatType.Party:
case ChatType.CrossParty:
case ChatType.Linkshell1:
case ChatType.Linkshell2:
case ChatType.Linkshell3:
case ChatType.Linkshell4:
case ChatType.Linkshell5:
case ChatType.Linkshell6:
case ChatType.Linkshell7:
case ChatType.Linkshell8:
case ChatType.CrossLinkshell1:
case ChatType.CrossLinkshell2:
case ChatType.CrossLinkshell3:
case ChatType.CrossLinkshell4:
case ChatType.CrossLinkshell5:
case ChatType.CrossLinkshell6:
case ChatType.CrossLinkshell7:
case ChatType.CrossLinkshell8:
case ChatType.FreeCompany:
case ChatType.NoviceNetwork:
case ChatType.Yell:
case ChatType.ExtraChatLinkshell1:
case ChatType.ExtraChatLinkshell2:
case ChatType.ExtraChatLinkshell3:
case ChatType.ExtraChatLinkshell4:
case ChatType.ExtraChatLinkshell5:
case ChatType.ExtraChatLinkshell6:
case ChatType.ExtraChatLinkshell7:
case ChatType.ExtraChatLinkshell8:
return true;
default:
return false;
}
}
public int ToSortCodeV2()
{
return (byte)Type << 16 | (byte)Source << 8 | (byte)Target;
}
public override bool Equals(object? obj)
{
if (obj == null)
return false;
if (obj is not ChatCode code)
return false;
return GetHashCode() == code.GetHashCode();
}
public override int GetHashCode()
{
return (byte)Type << 16 | (byte)Source << 8 | (byte)Target;
}
}
+42
View File
@@ -0,0 +1,42 @@
using Dalamud.Game.Text;
namespace HellionChat.Code;
[Flags]
public enum ChatSource : ushort
{
None = 0,
/// <summary>The player currently controlled by the local client.</summary>
LocalPlayer = 1 << XivChatRelationKind.LocalPlayer,
/// <summary>A player in the same 4-man or 8-man party as the local player.</summary>
PartyMember = 1 << XivChatRelationKind.PartyMember,
/// <summary>A player in the same alliance raid.</summary>
AllianceMember = 1 << XivChatRelationKind.AllianceMember,
/// <summary>A player not in the local player's party or alliance.</summary>
OtherPlayer = 1 << XivChatRelationKind.OtherPlayer,
/// <summary>An enemy entity that is currently in combat with the player or party.</summary>
EngagedEnemy = 1 << XivChatRelationKind.EngagedEnemy,
/// <summary>An enemy entity that is not yet in combat or claimed.</summary>
UnengagedEnemy = 1 << XivChatRelationKind.UnengagedEnemy,
/// <summary>An NPC that is friendly or neutral to the player (e.g., EventNPCs).</summary>
FriendlyNpc = 1 << XivChatRelationKind.FriendlyNpc,
/// <summary>A pet (Summoner/Scholar) or companion (Chocobo) belonging to the local player.</summary>
PetOrCompanion = 1 << XivChatRelationKind.PetOrCompanion,
/// <summary>A pet or companion belonging to a member of the local player's party.</summary>
PetOrCompanionParty = 1 << XivChatRelationKind.PetOrCompanionParty,
/// <summary>A pet or companion belonging to a member of the alliance.</summary>
PetOrCompanionAlliance = 1 << XivChatRelationKind.PetOrCompanionAlliance,
/// <summary>A pet or companion belonging to a player not in the party or alliance.</summary>
PetOrCompanionOther = 1 << XivChatRelationKind.PetOrCompanionOther,
}
+28
View File
@@ -0,0 +1,28 @@
using HellionChat.Resources;
namespace HellionChat.Code;
internal static class ChatSourceExt
{
internal const ChatSource All =
ChatSource.LocalPlayer | ChatSource.PartyMember | ChatSource.AllianceMember |
ChatSource.OtherPlayer | ChatSource.EngagedEnemy | ChatSource.UnengagedEnemy |
ChatSource.FriendlyNpc | ChatSource.PetOrCompanion | ChatSource.PetOrCompanionParty |
ChatSource.PetOrCompanionAlliance | ChatSource.PetOrCompanionOther;
internal static string Name(this ChatSource source) => source switch
{
ChatSource.LocalPlayer => Language.ChatSource_Self,
ChatSource.PartyMember => Language.ChatSource_PartyMember,
ChatSource.AllianceMember => Language.ChatSource_AllianceMember,
ChatSource.OtherPlayer => Language.ChatSource_Other,
ChatSource.EngagedEnemy => Language.ChatSource_EngagedEnemy,
ChatSource.UnengagedEnemy => Language.ChatSource_UnengagedEnemy,
ChatSource.FriendlyNpc => Language.ChatSource_FriendlyNpc,
ChatSource.PetOrCompanion => Language.ChatSource_SelfPet,
ChatSource.PetOrCompanionParty => Language.ChatSource_PartyPet,
ChatSource.PetOrCompanionAlliance => Language.ChatSource_AlliancePet,
ChatSource.PetOrCompanionOther => Language.ChatSource_OtherPet,
_ => throw new ArgumentOutOfRangeException(nameof(source), source, null),
};
}
+98
View File
@@ -0,0 +1,98 @@
namespace HellionChat.Code;
public enum ChatType : ushort
{
Debug = 1,
Urgent = 2,
Notice = 3,
Say = 10,
Shout = 11,
TellOutgoing = 12,
TellIncoming = 13,
Party = 14,
Alliance = 15,
Linkshell1 = 16,
Linkshell2 = 17,
Linkshell3 = 18,
Linkshell4 = 19,
Linkshell5 = 20,
Linkshell6 = 21,
Linkshell7 = 22,
Linkshell8 = 23,
FreeCompany = 24,
NoviceNetwork = 27,
CustomEmote = 28,
StandardEmote = 29,
Yell = 30,
// 31 - also party?
CrossParty = 32,
PvpTeam = 36,
CrossLinkshell1 = 37,
Damage = 41,
Miss = 42,
Action = 43,
Item = 44,
Healing = 45,
GainBuff = 46,
GainDebuff = 47,
LoseBuff = 48,
LoseDebuff = 49,
GlamourNotifications = 54,
Alarm = 55,
Echo = 56,
System = 57,
BattleSystem = 58,
GatheringSystem = 59,
Error = 60,
NpcDialogue = 61,
LootNotice = 62,
Progress = 64,
LootRoll = 65,
Crafting = 66,
Gathering = 67,
NpcAnnouncement = 68,
FreeCompanyAnnouncement = 69,
FreeCompanyLoginLogout = 70,
RetainerSale = 71,
PeriodicRecruitmentNotification = 72,
Sign = 73,
RandomNumber = 74,
NoviceNetworkSystem = 75,
Orchestrion = 76,
PvpTeamAnnouncement = 77,
PvpTeamLoginLogout = 78,
MessageBook = 79,
GmTell = 80,
GmSay = 81,
GmShout = 82,
GmYell = 83,
GmParty = 84,
GmFreeCompany = 85,
GmLinkshell1 = 86,
GmLinkshell2 = 87,
GmLinkshell3 = 88,
GmLinkshell4 = 89,
GmLinkshell5 = 90,
GmLinkshell6 = 91,
GmLinkshell7 = 92,
GmLinkshell8 = 93,
GmNoviceNetwork = 94,
CrossLinkshell2 = 101,
CrossLinkshell3 = 102,
CrossLinkshell4 = 103,
CrossLinkshell5 = 104,
CrossLinkshell6 = 105,
CrossLinkshell7 = 106,
CrossLinkshell8 = 107,
// Custom types:
ExtraChatLinkshell1 = 1001,
ExtraChatLinkshell2 = 1002,
ExtraChatLinkshell3 = 1003,
ExtraChatLinkshell4 = 1004,
ExtraChatLinkshell5 = 1005,
ExtraChatLinkshell6 = 1006,
ExtraChatLinkshell7 = 1007,
ExtraChatLinkshell8 = 1008,
}
+486
View File
@@ -0,0 +1,486 @@
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud.Game.Config;
namespace HellionChat.Code;
internal static class ChatTypeExt
{
internal static IEnumerable<(string, ChatType[])> SortOrder =>
[
(Language.Options_Tabs_ChannelTypes_Special, [ChatType.Debug, ChatType.Urgent, ChatType.Notice]),
(Language.Options_Tabs_ChannelTypes_Chat,
[
ChatType.Say,
ChatType.Yell,
ChatType.Shout,
ChatType.TellIncoming,
ChatType.TellOutgoing,
ChatType.Party,
ChatType.CrossParty,
ChatType.Alliance,
ChatType.FreeCompany,
ChatType.PvpTeam,
ChatType.CrossLinkshell1,
ChatType.CrossLinkshell2,
ChatType.CrossLinkshell3,
ChatType.CrossLinkshell4,
ChatType.CrossLinkshell5,
ChatType.CrossLinkshell6,
ChatType.CrossLinkshell7,
ChatType.CrossLinkshell8,
ChatType.Linkshell1,
ChatType.Linkshell2,
ChatType.Linkshell3,
ChatType.Linkshell4,
ChatType.Linkshell5,
ChatType.Linkshell6,
ChatType.Linkshell7,
ChatType.Linkshell8,
ChatType.NoviceNetwork,
ChatType.StandardEmote,
ChatType.CustomEmote
]),
(Language.Options_Tabs_ChannelTypes_Battle,
[
ChatType.Damage,
ChatType.Miss,
ChatType.Action,
ChatType.Item,
ChatType.Healing,
ChatType.GainBuff,
ChatType.LoseBuff,
ChatType.GainDebuff,
ChatType.LoseDebuff
]),
(Language.Options_Tabs_ChannelTypes_Announcements,
[
ChatType.System,
ChatType.BattleSystem,
ChatType.GatheringSystem,
ChatType.Error,
ChatType.Echo,
ChatType.NoviceNetworkSystem,
ChatType.FreeCompanyAnnouncement,
ChatType.PvpTeamAnnouncement,
ChatType.FreeCompanyLoginLogout,
ChatType.PvpTeamLoginLogout,
ChatType.RetainerSale,
ChatType.NpcDialogue,
ChatType.NpcAnnouncement,
ChatType.LootNotice,
ChatType.Progress,
ChatType.LootRoll,
ChatType.Crafting,
ChatType.Gathering,
ChatType.PeriodicRecruitmentNotification,
ChatType.Sign,
ChatType.RandomNumber,
ChatType.Orchestrion,
ChatType.MessageBook,
ChatType.Alarm,
ChatType.GlamourNotifications
])
// Note: ExtraChat linkshells are handled separately in the tab settings
// UI.
];
internal static string Name(this ChatType type)
{
return type switch
{
ChatType.Debug => Language.ChatType_Debug,
ChatType.Urgent => Language.ChatType_Urgent,
ChatType.Notice => Language.ChatType_Notice,
ChatType.Say => Language.ChatType_Say,
ChatType.Shout => Language.ChatType_Shout,
ChatType.TellOutgoing => Language.ChatType_TellOutgoing,
ChatType.TellIncoming => Language.ChatType_TellIncoming,
ChatType.Party => Language.ChatType_Party,
ChatType.Alliance => Language.ChatType_Alliance,
ChatType.Linkshell1 => Language.ChatType_Linkshell1,
ChatType.Linkshell2 => Language.ChatType_Linkshell2,
ChatType.Linkshell3 => Language.ChatType_Linkshell3,
ChatType.Linkshell4 => Language.ChatType_Linkshell4,
ChatType.Linkshell5 => Language.ChatType_Linkshell5,
ChatType.Linkshell6 => Language.ChatType_Linkshell6,
ChatType.Linkshell7 => Language.ChatType_Linkshell7,
ChatType.Linkshell8 => Language.ChatType_Linkshell8,
ChatType.FreeCompany => Language.ChatType_FreeCompany,
ChatType.NoviceNetwork => Language.ChatType_NoviceNetwork,
ChatType.CustomEmote => Language.ChatType_CustomEmotes,
ChatType.StandardEmote => Language.ChatType_StandardEmotes,
ChatType.Yell => Language.ChatType_Yell,
ChatType.CrossParty => Language.ChatType_CrossWorldParty,
ChatType.PvpTeam => Language.ChatType_PvpTeam,
ChatType.CrossLinkshell1 => Language.ChatType_CrossLinkshell1,
ChatType.Damage => Language.ChatType_Damage,
ChatType.Miss => Language.ChatType_Miss,
ChatType.Action => Language.ChatType_Action,
ChatType.Item => Language.ChatType_Item,
ChatType.Healing => Language.ChatType_Healing,
ChatType.GainBuff => Language.ChatType_GainBuff,
ChatType.GainDebuff => Language.ChatType_GainDebuff,
ChatType.LoseBuff => Language.ChatType_LoseBuff,
ChatType.LoseDebuff => Language.ChatType_LoseDebuff,
ChatType.Alarm => Language.ChatType_Alarm,
ChatType.GlamourNotifications => Language.ChatType_Glamour,
ChatType.Echo => Language.ChatType_Echo,
ChatType.System => Language.ChatType_System,
ChatType.BattleSystem => Language.ChatType_BattleSystem,
ChatType.GatheringSystem => Language.ChatType_GatheringSystem,
ChatType.Error => Language.ChatType_Error,
ChatType.NpcDialogue => Language.ChatType_NpcDialogue,
ChatType.LootNotice => Language.ChatType_LootNotice,
ChatType.Progress => Language.ChatType_Progress,
ChatType.LootRoll => Language.ChatType_LootRoll,
ChatType.Crafting => Language.ChatType_Crafting,
ChatType.Gathering => Language.ChatType_Gathering,
ChatType.NpcAnnouncement => Language.ChatType_NpcAnnouncement,
ChatType.FreeCompanyAnnouncement => Language.ChatType_FreeCompanyAnnouncement,
ChatType.FreeCompanyLoginLogout => Language.ChatType_FreeCompanyLoginLogout,
ChatType.RetainerSale => Language.ChatType_RetainerSale,
ChatType.PeriodicRecruitmentNotification => Language.ChatType_PeriodicRecruitmentNotification,
ChatType.Sign => Language.ChatType_Sign,
ChatType.RandomNumber => Language.ChatType_RandomNumber,
ChatType.NoviceNetworkSystem => Language.ChatType_NoviceNetworkSystem,
ChatType.Orchestrion => Language.ChatType_Orchestrion,
ChatType.PvpTeamAnnouncement => Language.ChatType_PvpTeamAnnouncement,
ChatType.PvpTeamLoginLogout => Language.ChatType_PvpTeamLoginLogout,
ChatType.MessageBook => Language.ChatType_MessageBook,
ChatType.GmTell => Language.ChatType_GmTell,
ChatType.GmSay => Language.ChatType_GmSay,
ChatType.GmShout => Language.ChatType_GmShout,
ChatType.GmYell => Language.ChatType_GmYell,
ChatType.GmParty => Language.ChatType_GmParty,
ChatType.GmFreeCompany => Language.ChatType_GmFreeCompany,
ChatType.GmLinkshell1 => Language.ChatType_GmLinkshell1,
ChatType.GmLinkshell2 => Language.ChatType_GmLinkshell2,
ChatType.GmLinkshell3 => Language.ChatType_GmLinkshell3,
ChatType.GmLinkshell4 => Language.ChatType_GmLinkshell4,
ChatType.GmLinkshell5 => Language.ChatType_GmLinkshell5,
ChatType.GmLinkshell6 => Language.ChatType_GmLinkshell6,
ChatType.GmLinkshell7 => Language.ChatType_GmLinkshell7,
ChatType.GmLinkshell8 => Language.ChatType_GmLinkshell8,
ChatType.GmNoviceNetwork => Language.ChatType_GmNoviceNetwork,
ChatType.CrossLinkshell2 => Language.ChatType_CrossLinkshell2,
ChatType.CrossLinkshell3 => Language.ChatType_CrossLinkshell3,
ChatType.CrossLinkshell4 => Language.ChatType_CrossLinkshell4,
ChatType.CrossLinkshell5 => Language.ChatType_CrossLinkshell5,
ChatType.CrossLinkshell6 => Language.ChatType_CrossLinkshell6,
ChatType.CrossLinkshell7 => Language.ChatType_CrossLinkshell7,
ChatType.CrossLinkshell8 => Language.ChatType_CrossLinkshell8,
ChatType.ExtraChatLinkshell1 => Language.ChatType_ExtraChatLinkshell1,
ChatType.ExtraChatLinkshell2 => Language.ChatType_ExtraChatLinkshell2,
ChatType.ExtraChatLinkshell3 => Language.ChatType_ExtraChatLinkshell3,
ChatType.ExtraChatLinkshell4 => Language.ChatType_ExtraChatLinkshell4,
ChatType.ExtraChatLinkshell5 => Language.ChatType_ExtraChatLinkshell5,
ChatType.ExtraChatLinkshell6 => Language.ChatType_ExtraChatLinkshell6,
ChatType.ExtraChatLinkshell7 => Language.ChatType_ExtraChatLinkshell7,
ChatType.ExtraChatLinkshell8 => Language.ChatType_ExtraChatLinkshell8,
_ => type.ToString(),
};
}
internal static uint? DefaultColor(this ChatType type)
{
switch (type)
{
case ChatType.Debug:
return ColourUtil.ComponentsToRgba(204, 204, 204);
case ChatType.Urgent:
return ColourUtil.ComponentsToRgba(255, 127, 127);
case ChatType.Notice:
return ColourUtil.ComponentsToRgba(179, 140, 255);
case ChatType.Say:
case ChatType.GmSay:
return ColourUtil.ComponentsToRgba(247, 247, 247);
case ChatType.Shout:
case ChatType.GmShout:
return ColourUtil.ComponentsToRgba(255, 166, 102);
case ChatType.TellIncoming:
case ChatType.TellOutgoing:
case ChatType.GmTell:
return ColourUtil.ComponentsToRgba(255, 184, 222);
case ChatType.Party:
case ChatType.CrossParty:
case ChatType.GmParty:
return ColourUtil.ComponentsToRgba(102, 229, 255);
case ChatType.Alliance:
return ColourUtil.ComponentsToRgba(255, 127, 0);
case ChatType.NoviceNetwork:
case ChatType.NoviceNetworkSystem:
case ChatType.GmNoviceNetwork:
return ColourUtil.ComponentsToRgba(212, 255, 125);
case ChatType.Linkshell1:
case ChatType.Linkshell2:
case ChatType.Linkshell3:
case ChatType.Linkshell4:
case ChatType.Linkshell5:
case ChatType.Linkshell6:
case ChatType.Linkshell7:
case ChatType.Linkshell8:
case ChatType.CrossLinkshell1:
case ChatType.CrossLinkshell2:
case ChatType.CrossLinkshell3:
case ChatType.CrossLinkshell4:
case ChatType.CrossLinkshell5:
case ChatType.CrossLinkshell6:
case ChatType.CrossLinkshell7:
case ChatType.CrossLinkshell8:
case ChatType.GmLinkshell1:
case ChatType.GmLinkshell2:
case ChatType.GmLinkshell3:
case ChatType.GmLinkshell4:
case ChatType.GmLinkshell5:
case ChatType.GmLinkshell6:
case ChatType.GmLinkshell7:
case ChatType.GmLinkshell8:
return ColourUtil.ComponentsToRgba(212, 255, 125);
case ChatType.StandardEmote:
return ColourUtil.ComponentsToRgba(186, 255, 240);
case ChatType.CustomEmote:
return ColourUtil.ComponentsToRgba(186, 255, 240);
case ChatType.Yell:
case ChatType.GmYell:
return ColourUtil.ComponentsToRgba(255, 255, 0);
case ChatType.Echo:
return ColourUtil.ComponentsToRgba(204, 204, 204);
case ChatType.System:
case ChatType.GatheringSystem:
case ChatType.PeriodicRecruitmentNotification:
case ChatType.Orchestrion:
case ChatType.Alarm:
case ChatType.GlamourNotifications:
case ChatType.RetainerSale:
case ChatType.Sign:
case ChatType.MessageBook:
return ColourUtil.ComponentsToRgba(204, 204, 204);
case ChatType.NpcAnnouncement:
case ChatType.NpcDialogue:
return ColourUtil.ComponentsToRgba(171, 214, 71);
case ChatType.Error:
return ColourUtil.ComponentsToRgba(255, 74, 74);
case ChatType.FreeCompany:
case ChatType.FreeCompanyAnnouncement:
case ChatType.FreeCompanyLoginLogout:
case ChatType.GmFreeCompany:
return ColourUtil.ComponentsToRgba(171, 219, 229);
case ChatType.PvpTeam:
return ColourUtil.ComponentsToRgba(171, 219, 229);
case ChatType.PvpTeamAnnouncement:
case ChatType.PvpTeamLoginLogout:
return ColourUtil.ComponentsToRgba(171, 219, 229);
case ChatType.Action:
case ChatType.Item:
case ChatType.LootNotice:
return ColourUtil.ComponentsToRgba(255, 255, 176);
case ChatType.Progress:
return ColourUtil.ComponentsToRgba(255, 222, 115);
case ChatType.LootRoll:
case ChatType.RandomNumber:
return ColourUtil.ComponentsToRgba(199, 191, 158);
case ChatType.Crafting:
case ChatType.Gathering:
return ColourUtil.ComponentsToRgba(222, 191, 247);
case ChatType.Damage:
return ColourUtil.ComponentsToRgba(255, 125, 125);
case ChatType.Miss:
return ColourUtil.ComponentsToRgba(204, 204, 204);
case ChatType.Healing:
return ColourUtil.ComponentsToRgba(212, 255, 125);
case ChatType.GainBuff:
case ChatType.LoseBuff:
return ColourUtil.ComponentsToRgba(148, 191, 255);
case ChatType.GainDebuff:
case ChatType.LoseDebuff:
return ColourUtil.ComponentsToRgba(255, 138, 196);
case ChatType.BattleSystem:
return ColourUtil.ComponentsToRgba(204, 204, 204);
default:
return null;
}
}
internal static InputChannel? ToInputChannel(this ChatType type) => type switch
{
ChatType.TellOutgoing => InputChannel.Tell,
ChatType.Say => InputChannel.Say,
ChatType.Party => InputChannel.Party,
ChatType.Alliance => InputChannel.Alliance,
ChatType.Yell => InputChannel.Yell,
ChatType.Shout => InputChannel.Shout,
ChatType.FreeCompany => InputChannel.FreeCompany,
ChatType.PvpTeam => InputChannel.PvpTeam,
ChatType.NoviceNetwork => InputChannel.NoviceNetwork,
ChatType.CrossLinkshell1 => InputChannel.CrossLinkshell1,
ChatType.CrossLinkshell2 => InputChannel.CrossLinkshell2,
ChatType.CrossLinkshell3 => InputChannel.CrossLinkshell3,
ChatType.CrossLinkshell4 => InputChannel.CrossLinkshell4,
ChatType.CrossLinkshell5 => InputChannel.CrossLinkshell5,
ChatType.CrossLinkshell6 => InputChannel.CrossLinkshell6,
ChatType.CrossLinkshell7 => InputChannel.CrossLinkshell7,
ChatType.CrossLinkshell8 => InputChannel.CrossLinkshell8,
ChatType.Linkshell1 => InputChannel.Linkshell1,
ChatType.Linkshell2 => InputChannel.Linkshell2,
ChatType.Linkshell3 => InputChannel.Linkshell3,
ChatType.Linkshell4 => InputChannel.Linkshell4,
ChatType.Linkshell5 => InputChannel.Linkshell5,
ChatType.Linkshell6 => InputChannel.Linkshell6,
ChatType.Linkshell7 => InputChannel.Linkshell7,
ChatType.Linkshell8 => InputChannel.Linkshell8,
_ => null,
};
internal static bool IsGm(this ChatType type) => type switch
{
ChatType.GmTell => true,
ChatType.GmSay => true,
ChatType.GmShout => true,
ChatType.GmYell => true,
ChatType.GmParty => true,
ChatType.GmFreeCompany => true,
ChatType.GmLinkshell1 => true,
ChatType.GmLinkshell2 => true,
ChatType.GmLinkshell3 => true,
ChatType.GmLinkshell4 => true,
ChatType.GmLinkshell5 => true,
ChatType.GmLinkshell6 => true,
ChatType.GmLinkshell7 => true,
ChatType.GmLinkshell8 => true,
ChatType.GmNoviceNetwork => true,
_ => false,
};
internal static bool IsExtraChatLinkshell(this ChatType type) => type switch
{
ChatType.ExtraChatLinkshell1 => true,
ChatType.ExtraChatLinkshell2 => true,
ChatType.ExtraChatLinkshell3 => true,
ChatType.ExtraChatLinkshell4 => true,
ChatType.ExtraChatLinkshell5 => true,
ChatType.ExtraChatLinkshell6 => true,
ChatType.ExtraChatLinkshell7 => true,
ChatType.ExtraChatLinkshell8 => true,
_ => false,
};
public static UiConfigOption ToConfigEntry(this ChatType type) => type switch
{
ChatType.Say => UiConfigOption.ColorSay,
ChatType.Shout => UiConfigOption.ColorShout,
ChatType.TellOutgoing => UiConfigOption.ColorTell,
ChatType.Party => UiConfigOption.ColorParty,
ChatType.Linkshell1 => UiConfigOption.ColorLS1,
ChatType.Linkshell2 => UiConfigOption.ColorLS2,
ChatType.Linkshell3 => UiConfigOption.ColorLS3,
ChatType.Linkshell4 => UiConfigOption.ColorLS4,
ChatType.Linkshell5 => UiConfigOption.ColorLS5,
ChatType.Linkshell6 => UiConfigOption.ColorLS6,
ChatType.Linkshell7 => UiConfigOption.ColorLS7,
ChatType.Linkshell8 => UiConfigOption.ColorLS8,
ChatType.FreeCompany => UiConfigOption.ColorFCompany,
ChatType.NoviceNetwork => UiConfigOption.ColorBeginner,
ChatType.CustomEmote => UiConfigOption.ColorEmoteUser,
ChatType.StandardEmote => UiConfigOption.ColorEmote,
ChatType.Yell => UiConfigOption.ColorYell,
ChatType.GainBuff => UiConfigOption.ColorBuffGive,
ChatType.GainDebuff => UiConfigOption.ColorDebuffGive,
ChatType.System => UiConfigOption.ColorSysMsg,
ChatType.NpcDialogue => UiConfigOption.ColorNpcSay,
ChatType.LootRoll => UiConfigOption.ColorLoot,
ChatType.FreeCompanyAnnouncement => UiConfigOption.ColorFCAnnounce,
ChatType.PvpTeamAnnouncement => UiConfigOption.ColorPvPGroupAnnounce,
_ => UiConfigOption.ColorSay,
};
internal static bool HasSource(this ChatType type) => type switch
{
// Battle
ChatType.Damage => true,
ChatType.Miss => true,
ChatType.Action => true,
ChatType.Item => true,
ChatType.Healing => true,
ChatType.GainBuff => true,
ChatType.LoseBuff => true,
ChatType.GainDebuff => true,
ChatType.LoseDebuff => true,
// Announcements
ChatType.System => true,
ChatType.BattleSystem => true,
ChatType.Error => true,
ChatType.LootNotice => true,
ChatType.Progress => true,
ChatType.LootRoll => true,
ChatType.Crafting => true,
ChatType.Gathering => true,
ChatType.FreeCompanyLoginLogout => true,
ChatType.PvpTeamLoginLogout => true,
_ => false,
};
internal static ChatType Parent(this ChatType type) => type switch
{
ChatType.Say => ChatType.Say,
ChatType.GmSay => ChatType.Say,
ChatType.Shout => ChatType.Shout,
ChatType.GmShout => ChatType.Shout,
ChatType.TellOutgoing => ChatType.TellOutgoing,
ChatType.TellIncoming => ChatType.TellOutgoing,
ChatType.GmTell => ChatType.TellOutgoing,
ChatType.Party => ChatType.Party,
ChatType.CrossParty => ChatType.Party,
ChatType.GmParty => ChatType.Party,
ChatType.Linkshell1 => ChatType.Linkshell1,
ChatType.GmLinkshell1 => ChatType.Linkshell1,
ChatType.Linkshell2 => ChatType.Linkshell2,
ChatType.GmLinkshell2 => ChatType.Linkshell2,
ChatType.Linkshell3 => ChatType.Linkshell3,
ChatType.GmLinkshell3 => ChatType.Linkshell3,
ChatType.Linkshell4 => ChatType.Linkshell4,
ChatType.GmLinkshell4 => ChatType.Linkshell4,
ChatType.Linkshell5 => ChatType.Linkshell5,
ChatType.GmLinkshell5 => ChatType.Linkshell5,
ChatType.Linkshell6 => ChatType.Linkshell6,
ChatType.GmLinkshell6 => ChatType.Linkshell6,
ChatType.Linkshell7 => ChatType.Linkshell7,
ChatType.GmLinkshell7 => ChatType.Linkshell7,
ChatType.Linkshell8 => ChatType.Linkshell8,
ChatType.GmLinkshell8 => ChatType.Linkshell8,
ChatType.FreeCompany => ChatType.FreeCompany,
ChatType.GmFreeCompany => ChatType.FreeCompany,
ChatType.NoviceNetwork => ChatType.NoviceNetwork,
ChatType.GmNoviceNetwork => ChatType.NoviceNetwork,
ChatType.CustomEmote => ChatType.CustomEmote,
ChatType.StandardEmote => ChatType.StandardEmote,
ChatType.Yell => ChatType.Yell,
ChatType.GmYell => ChatType.Yell,
ChatType.GainBuff => ChatType.GainBuff,
ChatType.LoseBuff => ChatType.GainBuff,
ChatType.GainDebuff => ChatType.GainDebuff,
ChatType.LoseDebuff => ChatType.GainDebuff,
ChatType.System => ChatType.System,
ChatType.Alarm => ChatType.System,
ChatType.GlamourNotifications => ChatType.System,
ChatType.RetainerSale => ChatType.System,
ChatType.PeriodicRecruitmentNotification => ChatType.System,
ChatType.Sign => ChatType.System,
ChatType.Orchestrion => ChatType.System,
ChatType.MessageBook => ChatType.System,
ChatType.NpcDialogue => ChatType.NpcDialogue,
ChatType.NpcAnnouncement => ChatType.NpcDialogue,
ChatType.LootRoll => ChatType.LootRoll,
ChatType.RandomNumber => ChatType.LootRoll,
ChatType.FreeCompanyAnnouncement => ChatType.FreeCompanyAnnouncement,
ChatType.FreeCompanyLoginLogout => ChatType.FreeCompanyAnnouncement,
ChatType.PvpTeamAnnouncement => ChatType.PvpTeamAnnouncement,
ChatType.PvpTeamLoginLogout => ChatType.PvpTeamAnnouncement,
_ => type,
};
}
+45
View File
@@ -0,0 +1,45 @@
namespace HellionChat.Code;
public enum InputChannel : uint
{
Tell = 0,
Say = 1,
Party = 2,
Alliance = 3,
Yell = 4,
Shout = 5,
FreeCompany = 6,
PvpTeam = 7,
NoviceNetwork = 8,
CrossLinkshell1 = 9,
CrossLinkshell2 = 10,
CrossLinkshell3 = 11,
CrossLinkshell4 = 12,
CrossLinkshell5 = 13,
CrossLinkshell6 = 14,
CrossLinkshell7 = 15,
CrossLinkshell8 = 16,
// 17 - unused?
// 18 - unused?
Linkshell1 = 19,
Linkshell2 = 20,
Linkshell3 = 21,
Linkshell4 = 22,
Linkshell5 = 23,
Linkshell6 = 24,
Linkshell7 = 25,
Linkshell8 = 26,
// Custom channels:
ExtraChatLinkshell1 = 1001,
ExtraChatLinkshell2 = 1002,
ExtraChatLinkshell3 = 1003,
ExtraChatLinkshell4 = 1004,
ExtraChatLinkshell5 = 1005,
ExtraChatLinkshell6 = 1006,
ExtraChatLinkshell7 = 1007,
ExtraChatLinkshell8 = 1008,
Invalid = 9999,
}
+195
View File
@@ -0,0 +1,195 @@
using Lumina.Excel.Sheets;
namespace HellionChat.Code;
internal static class InputChannelExt
{
internal static ChatType ToChatType(this InputChannel input) => input switch
{
InputChannel.Tell => ChatType.TellOutgoing,
InputChannel.Say => ChatType.Say,
InputChannel.Party => ChatType.Party,
InputChannel.Alliance => ChatType.Alliance,
InputChannel.Yell => ChatType.Yell,
InputChannel.Shout => ChatType.Shout,
InputChannel.FreeCompany => ChatType.FreeCompany,
InputChannel.PvpTeam => ChatType.PvpTeam,
InputChannel.NoviceNetwork => ChatType.NoviceNetwork,
InputChannel.CrossLinkshell1 => ChatType.CrossLinkshell1,
InputChannel.CrossLinkshell2 => ChatType.CrossLinkshell2,
InputChannel.CrossLinkshell3 => ChatType.CrossLinkshell3,
InputChannel.CrossLinkshell4 => ChatType.CrossLinkshell4,
InputChannel.CrossLinkshell5 => ChatType.CrossLinkshell5,
InputChannel.CrossLinkshell6 => ChatType.CrossLinkshell6,
InputChannel.CrossLinkshell7 => ChatType.CrossLinkshell7,
InputChannel.CrossLinkshell8 => ChatType.CrossLinkshell8,
InputChannel.Linkshell1 => ChatType.Linkshell1,
InputChannel.Linkshell2 => ChatType.Linkshell2,
InputChannel.Linkshell3 => ChatType.Linkshell3,
InputChannel.Linkshell4 => ChatType.Linkshell4,
InputChannel.Linkshell5 => ChatType.Linkshell5,
InputChannel.Linkshell6 => ChatType.Linkshell6,
InputChannel.Linkshell7 => ChatType.Linkshell7,
InputChannel.Linkshell8 => ChatType.Linkshell8,
InputChannel.ExtraChatLinkshell1 => ChatType.ExtraChatLinkshell1,
InputChannel.ExtraChatLinkshell2 => ChatType.ExtraChatLinkshell2,
InputChannel.ExtraChatLinkshell3 => ChatType.ExtraChatLinkshell3,
InputChannel.ExtraChatLinkshell4 => ChatType.ExtraChatLinkshell4,
InputChannel.ExtraChatLinkshell5 => ChatType.ExtraChatLinkshell5,
InputChannel.ExtraChatLinkshell6 => ChatType.ExtraChatLinkshell6,
InputChannel.ExtraChatLinkshell7 => ChatType.ExtraChatLinkshell7,
InputChannel.ExtraChatLinkshell8 => ChatType.ExtraChatLinkshell8,
InputChannel.Invalid => ChatType.Echo,
_ => throw new ArgumentOutOfRangeException(nameof(input), input, null),
};
public static uint LinkshellIndex(this InputChannel channel) => channel switch
{
InputChannel.Linkshell1 => 0,
InputChannel.Linkshell2 => 1,
InputChannel.Linkshell3 => 2,
InputChannel.Linkshell4 => 3,
InputChannel.Linkshell5 => 4,
InputChannel.Linkshell6 => 5,
InputChannel.Linkshell7 => 6,
InputChannel.Linkshell8 => 7,
InputChannel.CrossLinkshell1 => 0,
InputChannel.CrossLinkshell2 => 1,
InputChannel.CrossLinkshell3 => 2,
InputChannel.CrossLinkshell4 => 3,
InputChannel.CrossLinkshell5 => 4,
InputChannel.CrossLinkshell6 => 5,
InputChannel.CrossLinkshell7 => 6,
InputChannel.CrossLinkshell8 => 7,
InputChannel.ExtraChatLinkshell1 => 0,
InputChannel.ExtraChatLinkshell2 => 1,
InputChannel.ExtraChatLinkshell3 => 2,
InputChannel.ExtraChatLinkshell4 => 3,
InputChannel.ExtraChatLinkshell5 => 4,
InputChannel.ExtraChatLinkshell6 => 5,
InputChannel.ExtraChatLinkshell7 => 6,
InputChannel.ExtraChatLinkshell8 => 7,
_ => uint.MaxValue,
};
public static string Prefix(this InputChannel channel) => channel switch
{
InputChannel.Tell => "/t",
InputChannel.Say => "/s",
InputChannel.Party => "/p",
InputChannel.Alliance => "/a",
InputChannel.Yell => "/y",
InputChannel.Shout => "/sh",
InputChannel.FreeCompany => "/fc",
InputChannel.PvpTeam => "/pt",
InputChannel.NoviceNetwork => "/b",
InputChannel.CrossLinkshell1 => "/cwl1",
InputChannel.CrossLinkshell2 => "/cwl2",
InputChannel.CrossLinkshell3 => "/cwl3",
InputChannel.CrossLinkshell4 => "/cwl4",
InputChannel.CrossLinkshell5 => "/cwl5",
InputChannel.CrossLinkshell6 => "/cwl6",
InputChannel.CrossLinkshell7 => "/cwl7",
InputChannel.CrossLinkshell8 => "/cwl8",
InputChannel.Linkshell1 => "/l1",
InputChannel.Linkshell2 => "/l2",
InputChannel.Linkshell3 => "/l3",
InputChannel.Linkshell4 => "/l4",
InputChannel.Linkshell5 => "/l5",
InputChannel.Linkshell6 => "/l6",
InputChannel.Linkshell7 => "/l7",
InputChannel.Linkshell8 => "/l8",
InputChannel.ExtraChatLinkshell1 => "/ecl1",
InputChannel.ExtraChatLinkshell2 => "/ecl2",
InputChannel.ExtraChatLinkshell3 => "/ecl3",
InputChannel.ExtraChatLinkshell4 => "/ecl4",
InputChannel.ExtraChatLinkshell5 => "/ecl5",
InputChannel.ExtraChatLinkshell6 => "/ecl6",
InputChannel.ExtraChatLinkshell7 => "/ecl7",
InputChannel.ExtraChatLinkshell8 => "/ecl8",
_ => "/e",
};
public static IEnumerable<TextCommand>? TextCommands(this InputChannel channel)
{
uint[] ids = channel switch
{
InputChannel.Tell => [104, 118],
InputChannel.Say => [102],
InputChannel.Party => [105],
InputChannel.Alliance => [119],
InputChannel.Yell => [117],
InputChannel.Shout => [103],
InputChannel.FreeCompany => [115],
InputChannel.PvpTeam => [91],
InputChannel.NoviceNetwork => [101],
InputChannel.CrossLinkshell1 => [13],
InputChannel.CrossLinkshell2 => [14],
InputChannel.CrossLinkshell3 => [15],
InputChannel.CrossLinkshell4 => [16],
InputChannel.CrossLinkshell5 => [17],
InputChannel.CrossLinkshell6 => [18],
InputChannel.CrossLinkshell7 => [19],
InputChannel.CrossLinkshell8 => [20],
InputChannel.Linkshell1 => [107],
InputChannel.Linkshell2 => [108],
InputChannel.Linkshell3 => [109],
InputChannel.Linkshell4 => [110],
InputChannel.Linkshell5 => [111],
InputChannel.Linkshell6 => [112],
InputChannel.Linkshell7 => [113],
InputChannel.Linkshell8 => [114],
_ => [],
};
if (ids.Length == 0)
return null;
return ids.Where(id => Sheets.TextCommandSheet.HasRow(id)).Select(id => Sheets.TextCommandSheet.GetRow(id));
}
internal static bool IsLinkshell(this InputChannel channel) => channel switch
{
InputChannel.Linkshell1 => true,
InputChannel.Linkshell2 => true,
InputChannel.Linkshell3 => true,
InputChannel.Linkshell4 => true,
InputChannel.Linkshell5 => true,
InputChannel.Linkshell6 => true,
InputChannel.Linkshell7 => true,
InputChannel.Linkshell8 => true,
_ => false,
};
internal static bool IsCrossLinkshell(this InputChannel channel) => channel switch
{
InputChannel.CrossLinkshell1 => true,
InputChannel.CrossLinkshell2 => true,
InputChannel.CrossLinkshell3 => true,
InputChannel.CrossLinkshell4 => true,
InputChannel.CrossLinkshell5 => true,
InputChannel.CrossLinkshell6 => true,
InputChannel.CrossLinkshell7 => true,
InputChannel.CrossLinkshell8 => true,
_ => false,
};
internal static bool IsExtraChatLinkshell(this InputChannel channel) => channel switch
{
InputChannel.ExtraChatLinkshell1 => true,
InputChannel.ExtraChatLinkshell2 => true,
InputChannel.ExtraChatLinkshell3 => true,
InputChannel.ExtraChatLinkshell4 => true,
InputChannel.ExtraChatLinkshell5 => true,
InputChannel.ExtraChatLinkshell6 => true,
InputChannel.ExtraChatLinkshell7 => true,
InputChannel.ExtraChatLinkshell8 => true,
_ => false,
};
internal static bool IsValid(this InputChannel channel) => channel switch
{
InputChannel.Invalid => false,
_ => true,
};
}
+82
View File
@@ -0,0 +1,82 @@
using Dalamud.Game.Command;
namespace HellionChat;
internal sealed class Commands : IDisposable
{
private readonly Dictionary<string, CommandWrapper> Registered = [];
public void Dispose()
{
foreach (var name in Registered.Keys)
Plugin.CommandManager.RemoveHandler(name);
}
internal void Initialise()
{
foreach (var wrapper in Registered.Values)
{
Plugin.CommandManager.AddHandler(wrapper.Name, new CommandInfo(Invoke)
{
HelpMessage = wrapper.Description ?? string.Empty,
ShowInHelp = wrapper.ShowInHelp,
});
}
}
internal CommandWrapper Register(string name, string? description = null, bool? showInHelp = null)
{
if (Registered.TryGetValue(name, out var wrapper))
{
if (description != null)
wrapper.Description = description;
if (showInHelp != null)
wrapper.ShowInHelp = showInHelp.Value;
return wrapper;
}
Registered[name] = new CommandWrapper(name, description, showInHelp ?? true);
return Registered[name];
}
private void Invoke(string command, string arguments)
{
if (!Registered.TryGetValue(command, out var wrapper))
{
Plugin.Log.Warning($"Missing registration for command {command}");
return;
}
try
{
wrapper.Invoke(command, arguments);
}
catch (Exception ex)
{
Plugin.Log.Error(ex, $"Error while executing command {command}");
}
}
}
internal sealed class CommandWrapper
{
internal string Name { get; }
internal string? Description { get; set; }
internal bool ShowInHelp { get; set; }
internal event Action<string, string>? Execute;
internal CommandWrapper(string name, string? description, bool showInHelp)
{
Name = name;
Description = description;
ShowInHelp = showInHelp;
}
internal void Invoke(string command, string arguments)
{
Execute?.Invoke(command, arguments);
}
}
+818
View File
@@ -0,0 +1,818 @@
using System.Collections;
using HellionChat.Code;
using HellionChat.GameFunctions.Types;
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud;
using Dalamud.Configuration;
using Dalamud.Game.ClientState.Keys;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface.FontIdentifier;
using Dalamud.Bindings.ImGui;
namespace HellionChat;
[Serializable]
public class ConfigKeyBind
{
public ModifierFlag Modifier;
public VirtualKey Key;
public override string ToString()
{
var modString = "";
if (Modifier.HasFlag(ModifierFlag.Ctrl))
modString += Language.Keybind_Modifier_Ctrl + " + ";
if (Modifier.HasFlag(ModifierFlag.Shift))
modString += Language.Keybind_Modifier_Shift + " + ";
if (Modifier.HasFlag(ModifierFlag.Alt))
modString += Language.Keybind_Modifier_Alt + " + ";
return modString+Key.GetFancyName();
}
}
[Serializable]
public class Configuration : IPluginConfiguration
{
private const int LatestVersion = 12;
public int Version { get; set; } = LatestVersion;
// Hellion Chat — Privacy filter (DSGVO Art. 25 Privacy by Default).
// Master-switch defaults to true; set false to restore upstream behavior.
public bool PrivacyFilterEnabled = true;
// Empty set means the migration has not run yet — see Plugin.cs v6→v7.
public HashSet<ChatType> PrivacyPersistChannels = [];
// Failsafe for ChatTypes added by future FFXIV patches we don't know about.
public bool PrivacyPersistUnknownChannels;
public bool IsAllowedForStorage(ChatType type)
{
if (!PrivacyFilterEnabled)
return true;
if (PrivacyPersistChannels.Contains(type))
return true;
return PrivacyPersistUnknownChannels;
}
// Hellion Chat — Message retention (GDPR data minimization, time axis).
// Master switch defaults to false; the plugin will not delete history
// until the user explicitly opts in.
public bool RetentionEnabled;
public int RetentionDefaultDays = 30;
public Dictionary<ChatType, int> RetentionPerChannelDays = [];
public DateTimeOffset RetentionLastRunAt = DateTimeOffset.MinValue;
// Hellion Chat first-run wizard — opens once on a fresh install. Existing
// ChatTwo users skip it because the v6→v7 migration sets the flag.
public bool FirstRunCompleted;
// Hellion Chat global ImGui theme — applied to every plugin window in
// Plugin.Draw. Default ON; users who prefer the upstream Dalamud look
// can flip this off in the Privacy tab.
public bool HellionThemeEnabled = true;
// Window background opacity, 0.51.0. Lower values make the plugin
// panes more glass-like so the game shines through. Default 0.5
// matches the maintainer's daily-driver preference; users who want
// a less translucent look bump it up in Aussehen → Theme.
public float HellionThemeWindowOpacity = 0.5f;
// Use the bundled Exo 2 font (OFL-1.1) for the regular plugin font
// instead of whatever GlobalFontV2.FontId points at. Default ON so a
// fresh install gets the Hellion typography out-of-the-box; flip OFF
// to fall back to the user's chosen system or Dalamud font.
public bool UseHellionFont = true;
// Hellion Chat — Auto-Tell-Tabs. When enabled, an incoming or outgoing
// /tell spawns a session-only tab dedicated to that conversation
// partner. See spec: Hellion Chat Auto-Tell-Tabs Spec (Obsidian).
public bool EnableAutoTellTabs = true;
// Hard cap on simultaneously open auto tell tabs. Range enforced by the
// settings slider (150). LRU drop favors greeted tabs first.
public int AutoTellTabsLimit = 15;
// When true the sidebar shows only a thin separator before the temp
// tabs; when false a section header "Active Tells (n)" is rendered.
public bool AutoTellTabsCompactDisplay;
// Number of prior tells to preload from the message store when an
// auto tell tab is spawned. Range 0100; 0 disables preload.
public int AutoTellTabsHistoryPreload = 20;
// Show the greeter "marked-as-greeted" toggle button next to each
// temp tab and dim the tab name when set. Off by default because the
// workflow is specific to club-greeter use cases — most users just
// want the auto tabs themselves without the extra UI affordance.
public bool AutoTellTabsShowGreetedToggle;
// Hellion Chat — One-Time-Hint-Banner that introduces the v0.6.0 pop-out
// input feature. Set to true once the user dismisses the banner from a
// pop-out window; never reset after that.
public bool SeenPopOutInputHint;
// Hellion Chat — v0.6.0 master switch for the pop-out input bar.
// Global on purpose: per-tab makes no sense for Auto-Tell-Tabs which
// are session-only and would force the user to re-enable it for every
// new conversation. Default flipped to ON in v0.6.1 (was OFF in v0.6.0)
// because tester feedback called the manual toggle "umständlich, wirkt
// unfertig". v11 → v12 migration applies the same flip to existing users.
public bool PopOutInputEnabled = true;
// Hellion Chat — v0.6.1 One-Time-Hint-Banner that introduces the
// chat-header pop-out toolbar button and reminds about the pop-out
// input default flip. Set to true once the user dismisses the banner
// from the main chat window; never reset after that.
public bool SeenPopOutHeaderHint;
// Hellion Chat — v0.6.1 opt-in: when true, AutoTellTabsService.SpawnTempTab
// sets tab.PopOut = true on every new auto-tell tab so the conversation
// pops out as its own window directly. Closing the pop-out returns the
// tab to the sidebar via the standard Popout.OnClose() flow. Default OFF
// because the existing sidebar workflow is what most users (especially
// club greeters tracking many parallel tells) expect by default.
public bool AutoTellTabsOpenAsPopout;
public int GetRetentionDays(ChatType type)
{
if (RetentionPerChannelDays.TryGetValue(type, out var userOverride))
return userOverride;
if (Privacy.PrivacyDefaults.DefaultRetentionDays.TryGetValue(type, out var specDefault))
return specDefault;
return RetentionDefaultDays;
}
public bool HideChat = true;
public bool HideDuringCutscenes = true;
public bool HideWhenNotLoggedIn = true;
public bool HideWhenUiHidden = true;
public bool HideInLoadingScreens;
public bool HideInBattle;
public bool HideWhenInactive;
public int InactivityHideTimeout = 10;
public bool InactivityHideActiveDuringBattle = true;
[Obsolete("Use InactivityHideChannelsV2 instead")]
public Dictionary<ChatType, ChatSource> InactivityHideChannels = [];
public Dictionary<ChatType, (ChatSource, ChatSource)> InactivityHideChannelsV2 = [];
public bool InactivityHideExtraChatAll = true;
public HashSet<Guid> InactivityHideExtraChatChannels = [];
public bool ShowHideButton = true;
public bool NativeItemTooltips = true;
public bool PrettierTimestamps = true;
public bool MoreCompactPretty;
public bool HideSameTimestamps;
public bool ShowNoviceNetwork;
// Hellion Chat — vertical sidebar tab layout reads better than the
// horizontal tab strip in the company of Auto-Tell-Tabs (a club
// greeter typically tracks 515 simultaneous conversations). Bestand
// users keep their saved value untouched — only fresh installs pick
// up the new default.
public bool SidebarTabView = true;
public bool PrintChangelog = true;
public bool OnlyPreviewIf;
public int PreviewMinimum = 1;
public PreviewPosition PreviewPosition = PreviewPosition.Inside;
public CommandHelpSide CommandHelpSide = CommandHelpSide.None;
public KeybindMode KeybindMode = KeybindMode.Strict;
public LanguageOverride LanguageOverride = LanguageOverride.None;
public bool CanMove = true;
public bool CanResize = true;
public bool ShowTitleBar = true;
public bool ShowPopOutTitleBar = true;
public bool DatabaseBattleMessages;
public bool LoadPreviousSession;
public bool FilterIncludePreviousSessions;
public bool SortAutoTranslate;
public bool CollapseDuplicateMessages;
public bool CollapseKeepUniqueLinks;
public bool PlaySounds = true;
public bool KeepInputFocus = true;
public int MaxLinesToRender = 5_000; // 1-10000
// Default ON to match a German / European 24h locale. The
// ChatLogWindow.cs format-flip in v0.5.1 honours this strictly via
// CultureInfo.InvariantCulture so the result is consistent across
// host locales.
public bool Use24HourClock = true;
public bool ShowEmotes = true;
public HashSet<string> BlockedEmotes = [];
public bool FontsEnabled = true;
public ExtraGlyphRanges ExtraGlyphRanges = 0;
public float FontSizeV2 = 12.75f;
public float SymbolsFontSizeV2 = 12.75f;
public SingleFontSpec GlobalFontV2 = new()
{
// dalamud only ships KR as regular, which chat2 used previously for global fonts
FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular),
SizePt = 12.75f,
};
public SingleFontSpec JapaneseFontV2 = new()
{
FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkMedium),
SizePt = 12.75f,
};
public bool ItalicEnabled;
public SingleFontSpec ItalicFontV2 = new()
{
FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular),
SizePt = 12.75f,
};
public float TooltipOffset;
public float WindowAlpha = 100f;
public Dictionary<ChatType, uint> ChatColours = new();
public List<Tab> Tabs = [];
public bool OverrideStyle;
public string? ChosenStyle;
public ConfigKeyBind? ChatTabForward;
public ConfigKeyBind? ChatTabBackward;
public void UpdateFrom(Configuration other, bool backToOriginal)
{
if (backToOriginal)
foreach (var tab in Tabs.Where(t => t.PopOut))
tab.PopOut = false;
HideChat = other.HideChat;
HideDuringCutscenes = other.HideDuringCutscenes;
HideWhenNotLoggedIn = other.HideWhenNotLoggedIn;
HideWhenUiHidden = other.HideWhenUiHidden;
HideInLoadingScreens = other.HideInLoadingScreens;
HideInBattle = other.HideInBattle;
HideWhenInactive = other.HideWhenInactive;
InactivityHideTimeout = other.InactivityHideTimeout;
InactivityHideActiveDuringBattle = other.InactivityHideActiveDuringBattle;
InactivityHideChannelsV2 = other.InactivityHideChannelsV2.ToDictionary(pair => pair.Key, pair => pair.Value);
InactivityHideExtraChatAll = other.InactivityHideExtraChatAll;
InactivityHideExtraChatChannels = other.InactivityHideExtraChatChannels.ToHashSet();
ShowHideButton = other.ShowHideButton;
NativeItemTooltips = other.NativeItemTooltips;
PrettierTimestamps = other.PrettierTimestamps;
MoreCompactPretty = other.MoreCompactPretty;
HideSameTimestamps = other.HideSameTimestamps;
ShowNoviceNetwork = other.ShowNoviceNetwork;
SidebarTabView = other.SidebarTabView;
PrintChangelog = other.PrintChangelog;
OnlyPreviewIf = other.OnlyPreviewIf;
PreviewMinimum = other.PreviewMinimum;
PreviewPosition = other.PreviewPosition;
CommandHelpSide = other.CommandHelpSide;
KeybindMode = other.KeybindMode;
LanguageOverride = other.LanguageOverride;
CanMove = other.CanMove;
CanResize = other.CanResize;
ShowTitleBar = other.ShowTitleBar;
ShowPopOutTitleBar = other.ShowPopOutTitleBar;
DatabaseBattleMessages = other.DatabaseBattleMessages;
LoadPreviousSession = other.LoadPreviousSession;
FilterIncludePreviousSessions = other.FilterIncludePreviousSessions;
SortAutoTranslate = other.SortAutoTranslate;
CollapseDuplicateMessages = other.CollapseDuplicateMessages;
CollapseKeepUniqueLinks = other.CollapseKeepUniqueLinks;
PlaySounds = other.PlaySounds;
KeepInputFocus = other.KeepInputFocus;
MaxLinesToRender = other.MaxLinesToRender;
Use24HourClock = other.Use24HourClock;
ShowEmotes = other.ShowEmotes;
BlockedEmotes = other.BlockedEmotes;
FontsEnabled = other.FontsEnabled;
ItalicEnabled = other.ItalicEnabled;
ExtraGlyphRanges = other.ExtraGlyphRanges;
FontSizeV2 = other.FontSizeV2;
GlobalFontV2 = other.GlobalFontV2;
JapaneseFontV2 = other.JapaneseFontV2;
ItalicFontV2 = other.ItalicFontV2;
SymbolsFontSizeV2 = other.SymbolsFontSizeV2;
TooltipOffset = other.TooltipOffset;
WindowAlpha = other.WindowAlpha;
ChatColours = other.ChatColours.ToDictionary(entry => entry.Key, entry => entry.Value);
// Hellion Chat — Auto-Tell-Tabs are session-only and therefore
// never present in a disk-loaded copy. Keep the live temp tabs of
// *this* configuration alive across an UpdateFrom so a settings
// save (or sidebar-mode toggle) does not silently destroy the
// user's open tell conversations. Persistent tabs from `other`
// still get the regular clone-replace treatment.
var liveTempTabs = Tabs.Where(t => t.IsTempTab).ToList();
Tabs = other.Tabs.Where(t => !t.IsTempTab).Select(t => t.Clone()).ToList();
Tabs.AddRange(liveTempTabs);
OverrideStyle = other.OverrideStyle;
ChosenStyle = other.ChosenStyle;
ChatTabForward = other.ChatTabForward;
ChatTabBackward = other.ChatTabBackward;
PrivacyFilterEnabled = other.PrivacyFilterEnabled;
PrivacyPersistChannels = [..other.PrivacyPersistChannels];
PrivacyPersistUnknownChannels = other.PrivacyPersistUnknownChannels;
RetentionEnabled = other.RetentionEnabled;
RetentionDefaultDays = other.RetentionDefaultDays;
RetentionPerChannelDays = other.RetentionPerChannelDays.ToDictionary(p => p.Key, p => p.Value);
RetentionLastRunAt = other.RetentionLastRunAt;
FirstRunCompleted = other.FirstRunCompleted;
HellionThemeEnabled = other.HellionThemeEnabled;
HellionThemeWindowOpacity = other.HellionThemeWindowOpacity;
UseHellionFont = other.UseHellionFont;
EnableAutoTellTabs = other.EnableAutoTellTabs;
AutoTellTabsLimit = other.AutoTellTabsLimit;
AutoTellTabsCompactDisplay = other.AutoTellTabsCompactDisplay;
AutoTellTabsHistoryPreload = other.AutoTellTabsHistoryPreload;
AutoTellTabsShowGreetedToggle = other.AutoTellTabsShowGreetedToggle;
SeenPopOutInputHint = other.SeenPopOutInputHint;
PopOutInputEnabled = other.PopOutInputEnabled;
SeenPopOutHeaderHint = other.SeenPopOutHeaderHint;
AutoTellTabsOpenAsPopout = other.AutoTellTabsOpenAsPopout;
}
}
[Serializable]
public enum UnreadMode
{
All,
Unseen,
None,
}
public static class UnreadModeExt
{
internal static string Name(this UnreadMode mode) => mode switch
{
UnreadMode.All => Language.UnreadMode_All,
UnreadMode.Unseen => Language.UnreadMode_Unseen,
UnreadMode.None => Language.UnreadMode_None,
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
};
internal static string? Tooltip(this UnreadMode mode) => mode switch
{
UnreadMode.All => Language.UnreadMode_All_Tooltip,
UnreadMode.Unseen => Language.UnreadMode_Unseen_Tooltip,
UnreadMode.None => Language.UnreadMode_None_Tooltip,
_ => null,
};
}
[Serializable]
public class Tab
{
public string Name = Language.Tab_DefaultName;
[Obsolete("Removed in favor of SelectedChannels")]
public Dictionary<ChatType, ChatSource> ChatCodes = new();
public Dictionary<ChatType, (ChatSource, ChatSource)> SelectedChannels = new();
public bool ExtraChatAll;
public HashSet<Guid> ExtraChatChannels = [];
public UnreadMode UnreadMode = UnreadMode.Unseen;
public bool UnhideOnActivity;
public bool DisplayTimestamp = true;
public InputChannel? Channel;
public bool PopOut;
public bool IndependentOpacity;
public float Opacity = 100f;
public bool InputDisabled;
public bool CanMove = true;
public bool CanResize = true;
public bool IndependentHide;
public bool HideDuringCutscenes = true;
public bool HideWhenNotLoggedIn = true;
public bool HideWhenUiHidden = true;
public bool HideInLoadingScreens;
public bool HideInBattle;
public bool HideWhenInactive;
public bool IsTempTab;
public bool AllSenderMessages;
public TellTarget TellTarget = TellTarget.Empty();
[NonSerialized] public uint Unread;
[NonSerialized] public uint LastSendUnread;
[NonSerialized] public long LastActivity;
[NonSerialized] public MessageList Messages = new();
[NonSerialized] public UsedChannel CurrentChannel = new();
[NonSerialized] public Guid Identifier = Guid.NewGuid();
// Hellion Chat — Auto-Tell-Tabs greeted flag. Toggled manually from the
// sidebar to mark a tell partner as already greeted in the current
// session. NonSerialized because the temp tab itself is session-only.
[NonSerialized] public bool IsGreeted;
public bool Matches(Message message)
{
if (!message.Matches(SelectedChannels, ExtraChatAll, ExtraChatChannels))
{
return false;
}
// Auto-tell temp tabs are bound to a single conversation partner;
// every other tell that matches the channel filter must NOT land
// here, otherwise all temp tabs would mirror "Tell Exclusive".
if (IsTempTab && TellTarget?.IsSet() == true)
{
return ChunkUtil.MatchesSender(message, TellTarget.Name, TellTarget.World);
}
return true;
}
public void AddMessage(Message message, bool unread = true)
{
Messages.AddPrune(message, MessageManager.MessageDisplayLimit);
if (!unread)
return;
Unread += 1;
if (message.Matches(Plugin.Config.InactivityHideChannelsV2, Plugin.Config.InactivityHideExtraChatAll, Plugin.Config.InactivityHideExtraChatChannels))
LastActivity = Environment.TickCount64;
}
public void Clear()
=> Messages.Clear();
public Tab Clone()
{
return new Tab
{
Name = Name,
SelectedChannels = SelectedChannels.ToDictionary(pair => pair.Key, pair => pair.Value),
ExtraChatAll = ExtraChatAll,
ExtraChatChannels = ExtraChatChannels.ToHashSet(),
UnreadMode = UnreadMode,
UnhideOnActivity = UnhideOnActivity,
Unread = Unread,
LastActivity = LastActivity,
DisplayTimestamp = DisplayTimestamp,
Channel = Channel,
PopOut = PopOut,
IndependentOpacity = IndependentOpacity,
Opacity = Opacity,
Identifier = Identifier,
InputDisabled = InputDisabled,
CurrentChannel = CurrentChannel,
CanMove = CanMove,
CanResize = CanResize,
IndependentHide = IndependentHide,
HideDuringCutscenes = HideDuringCutscenes,
HideWhenNotLoggedIn = HideWhenNotLoggedIn,
HideWhenUiHidden = HideWhenUiHidden,
HideInLoadingScreens = HideInLoadingScreens,
HideInBattle = HideInBattle,
HideWhenInactive = HideWhenInactive,
IsTempTab = IsTempTab,
AllSenderMessages = AllSenderMessages,
TellTarget = TellTarget.From(TellTarget),
IsGreeted = IsGreeted,
};
}
/// <summary>
/// MessageList provides an ordered list of messages with duplicate ID
/// tracking, sorting and mutex protection.
/// </summary>
public class MessageList
{
private readonly SemaphoreSlim LockSlim = new(1, 1);
private readonly List<Message> Messages;
private readonly HashSet<Guid> TrackedMessageIds;
public MessageList()
{
Messages = [];
TrackedMessageIds = [];
}
public MessageList(int initialCapacity)
{
Messages = new List<Message>(initialCapacity);
TrackedMessageIds = new HashSet<Guid>(initialCapacity);
}
public void AddPrune(Message message, int max)
{
LockSlim.Wait(-1);
try
{
AddLocked(message);
PruneMaxLocked(max);
}
finally
{
LockSlim.Release();
}
}
public void AddSortPrune(IEnumerable<Message> messages, int max)
{
LockSlim.Wait(-1);
try
{
foreach (var message in messages)
AddLocked(message);
SortLocked();
PruneMaxLocked(max);
}
finally
{
LockSlim.Release();
}
}
private void AddLocked(Message message)
{
if (TrackedMessageIds.Contains(message.Id))
return;
Messages.Add(message);
TrackedMessageIds.Add(message.Id);
}
public void Clear()
{
LockSlim.Wait(-1);
try
{
Messages.Clear();
TrackedMessageIds.Clear();
}
finally
{
LockSlim.Release();
}
}
private void SortLocked()
{
Messages.Sort((a, b) => a.Date.CompareTo(b.Date));
}
private void PruneMaxLocked(int max)
{
while (Messages.Count > max)
{
TrackedMessageIds.Remove(Messages[0].Id);
Messages.RemoveAt(0);
}
}
/// <summary>
/// Returns an array copy of the message list for usage outside of main thread
/// </summary>
public async Task<Message[]> GetCopy(int millisecondsTimeout = -1)
{
await LockSlim.WaitAsync(millisecondsTimeout);
try
{
return Messages.ToArray();
}
finally
{
LockSlim.Release();
}
}
/// <summary>
/// GetReadOnly returns a read-only list of messages while holding a
/// reader lock. The list should be used with a using statement.
/// </summary>
public RLockedMessageList GetReadOnly(int millisecondsTimeout = -1)
{
LockSlim.Wait(millisecondsTimeout);
return new RLockedMessageList(LockSlim, Messages);
}
public class RLockedMessageList(SemaphoreSlim lockSlim, List<Message> messages) : IReadOnlyList<Message>, IDisposable
{
public IEnumerator<Message> GetEnumerator()
{
return messages.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
public int Count => messages.Count;
public Message this[int index] => messages[index];
public void Dispose()
{
lockSlim.Release();
}
}
}
}
public class UsedChannel
{
public InputChannel Channel = InputChannel.Invalid;
public List<Chunk> Name = [];
public TellTarget? TellTarget;
public bool UseTempChannel;
public InputChannel TempChannel = InputChannel.Invalid;
public TellTarget? TempTellTarget;
public void ResetTempChannel()
{
UseTempChannel = false;
TempTellTarget = null;
TempChannel = InputChannel.Invalid;
}
public void SetChannel(InputChannel channel)
{
Channel = channel;
}
}
[Serializable]
public enum PreviewPosition
{
None,
Inside,
Top,
Bottom,
Tooltip,
}
public static class PreviewPositionExt
{
public static string Name(this PreviewPosition position) => position switch
{
PreviewPosition.None => Language.Options_Preview_None,
PreviewPosition.Inside => Language.Options_Preview_Inside,
PreviewPosition.Top => Language.Options_Preview_Top,
PreviewPosition.Bottom => Language.Options_Preview_Bottom,
PreviewPosition.Tooltip => Language.Options_Preview_Tooltip,
_ => throw new ArgumentOutOfRangeException(nameof(position), position, null),
};
}
[Serializable]
public enum CommandHelpSide
{
None,
Left,
Right,
}
public static class CommandHelpSideExt
{
public static string Name(this CommandHelpSide side) => side switch
{
CommandHelpSide.None => Language.CommandHelpSide_None,
CommandHelpSide.Left => Language.CommandHelpSide_Left,
CommandHelpSide.Right => Language.CommandHelpSide_Right,
_ => throw new ArgumentOutOfRangeException(nameof(side), side, null),
};
}
[Serializable]
public enum KeybindMode
{
Flexible,
Strict,
}
public static class KeybindModeExt
{
public static string Name(this KeybindMode mode) => mode switch
{
KeybindMode.Flexible => Language.KeybindMode_Flexible_Name,
KeybindMode.Strict => Language.KeybindMode_Strict_Name,
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
};
public static string? Tooltip(this KeybindMode mode) => mode switch
{
KeybindMode.Flexible => Language.KeybindMode_Flexible_Tooltip,
KeybindMode.Strict => Language.KeybindMode_Strict_Tooltip,
_ => null,
};
}
[Serializable]
public enum LanguageOverride
{
None,
ChineseSimplified,
ChineseTraditional,
Dutch,
English,
French,
German,
Greek,
// Italian,
Japanese,
// Korean,
// Norwegian,
PortugueseBrazil,
Romanian,
Russian,
Spanish,
Swedish,
}
public static class LanguageOverrideExt
{
public static string Name(this LanguageOverride mode) => mode switch
{
LanguageOverride.None => Language.LanguageOverride_None,
LanguageOverride.ChineseSimplified => "简体中文",
LanguageOverride.ChineseTraditional => "繁體中文",
LanguageOverride.Dutch => "Nederlands",
LanguageOverride.English => "English",
LanguageOverride.French => "Français",
LanguageOverride.German => "Deutsch",
LanguageOverride.Greek => "Ελληνικά",
// LanguageOverride.Italian => "Italiano",
LanguageOverride.Japanese => "日本語",
// LanguageOverride.Korean => "한국어 (Korean)",
// LanguageOverride.Norwegian => "Norsk",
LanguageOverride.PortugueseBrazil => "Português do Brasil",
LanguageOverride.Romanian => "Română",
LanguageOverride.Russian => "Русский",
LanguageOverride.Spanish => "Español",
LanguageOverride.Swedish => "Svenska",
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
};
public static string Code(this LanguageOverride mode) => mode switch
{
LanguageOverride.None => "",
LanguageOverride.ChineseSimplified => "zh-hans",
LanguageOverride.ChineseTraditional => "zh-hant",
LanguageOverride.Dutch => "nl",
LanguageOverride.English => "en",
LanguageOverride.French => "fr",
LanguageOverride.German => "de",
LanguageOverride.Greek => "el",
// LanguageOverride.Italian => "it",
LanguageOverride.Japanese => "ja",
// LanguageOverride.Korean => "ko",
// LanguageOverride.Norwegian => "no",
LanguageOverride.PortugueseBrazil => "pt-br",
LanguageOverride.Romanian => "ro",
LanguageOverride.Russian => "ru",
LanguageOverride.Spanish => "es",
LanguageOverride.Swedish => "sv",
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
};
}
[Serializable]
[Flags]
public enum ExtraGlyphRanges
{
ChineseFull = 1 << 0,
ChineseSimplifiedCommon = 1 << 1,
Cyrillic = 1 << 2,
Japanese = 1 << 3,
Korean = 1 << 4,
Thai = 1 << 5,
Vietnamese = 1 << 6,
}
public static class ExtraGlyphRangesExt
{
public static string Name(this ExtraGlyphRanges ranges) => ranges switch
{
ExtraGlyphRanges.ChineseFull => Language.ExtraGlyphRanges_ChineseFull_Name,
ExtraGlyphRanges.ChineseSimplifiedCommon => Language.ExtraGlyphRanges_ChineseSimplifiedCommon_Name,
ExtraGlyphRanges.Cyrillic => Language.ExtraGlyphRanges_Cyrillic_Name,
ExtraGlyphRanges.Japanese => Language.ExtraGlyphRanges_Japanese_Name,
ExtraGlyphRanges.Korean => Language.ExtraGlyphRanges_Korean_Name,
ExtraGlyphRanges.Thai => Language.ExtraGlyphRanges_Thai_Name,
ExtraGlyphRanges.Vietnamese => Language.ExtraGlyphRanges_Vietnamese_Name,
_ => throw new ArgumentOutOfRangeException(nameof(ranges), ranges, null),
};
public static unsafe nint Range(this ExtraGlyphRanges ranges) => ranges switch
{
ExtraGlyphRanges.ChineseFull => (nint)ImGui.GetIO().Fonts.GetGlyphRangesChineseFull(),
ExtraGlyphRanges.ChineseSimplifiedCommon => (nint)ImGui.GetIO().Fonts.GetGlyphRangesChineseSimplifiedCommon(),
ExtraGlyphRanges.Cyrillic => (nint)ImGui.GetIO().Fonts.GetGlyphRangesCyrillic(),
ExtraGlyphRanges.Japanese => (nint)ImGui.GetIO().Fonts.GetGlyphRangesJapanese(),
ExtraGlyphRanges.Korean => (nint)ImGui.GetIO().Fonts.GetGlyphRangesKorean(),
ExtraGlyphRanges.Thai => (nint)ImGui.GetIO().Fonts.GetGlyphRangesThai(),
ExtraGlyphRanges.Vietnamese => (nint)ImGui.GetIO().Fonts.GetGlyphRangesVietnamese(),
_ => throw new ArgumentOutOfRangeException(nameof(ranges), ranges, null),
};
}
+324
View File
@@ -0,0 +1,324 @@
using System.Numerics;
using System.Text.Json;
using System.Text.Json.Serialization;
using Dalamud.Interface.Textures;
using Dalamud.Interface.Textures.TextureWraps;
using Dalamud.Utility;
using Dalamud.Bindings.ImGui;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace HellionChat;
public static class EmoteCache
{
private static readonly string[] NotWorking =
[
":tf:", "(ditto)", "c!", "h!", "l!", "M&Mjc", "LUL3D", "p!",
"POLICE2", "r!", "Pussy", "s!", "v!", "w!", "x0r6ztGiggle",
"z!", "xar2EDM", "iron95Pls", "Clap2", "AlienPls3", "Life",
"peepoPogClimbingTreeHard4House", "monkaGIGAftRobertDowneyJr",
"DogLookingSussyAndCold", "DICKS"
];
private static readonly HttpClient Client = new();
private const string BetterTTV = "https://api.betterttv.net/3";
private const string GlobalEmotes = $"{BetterTTV}/cached/emotes/global";
private const string Top100Emotes = "{0}/emotes/shared/top?before={1}&limit=100";
private const string EmotePath = "https://cdn.betterttv.net/emote/{0}/3x";
[Serializable]
private struct Top100()
{
[JsonPropertyName("emote")]
public Emote Emote { get; set; }
[JsonPropertyName("id")]
public required string Id { get; set; }
}
[Serializable]
public struct Emote()
{
[JsonPropertyName("id")]
public required string Id { get; set; }
[JsonPropertyName("code")]
public required string Code { get; set; }
[JsonPropertyName("imageType")]
public required string ImageType { get; set; }
}
public enum LoadingState
{
Unloaded,
Loading,
Done
}
// All of this data is uninitalized while State is not `LoadingState.Done`
public static LoadingState State = LoadingState.Unloaded;
private static readonly Dictionary<string, Emote> Cache = new();
private static readonly Dictionary<string, EmoteBase> EmoteImages = new();
public static string[] SortedCodeArray = [];
public static async Task LoadData()
{
if (State is not LoadingState.Unloaded)
return;
State = LoadingState.Loading;
try
{
var global = await Client.GetAsync(GlobalEmotes);
var globalList = await global.Content.ReadAsStringAsync();
foreach (var emote in JsonSerializer.Deserialize<Emote[]>(globalList)!)
if (!string.IsNullOrEmpty(emote.Code) && !NotWorking.Contains(emote.Code))
Cache.TryAdd(emote.Code, emote);
var lastId = string.Empty;
for (var i = 0; i < 15; i++)
{
var top = await Client.GetAsync(Top100Emotes.Format(BetterTTV, lastId));
var topList = await top.Content.ReadAsStringAsync();
var jsonList = JsonSerializer.Deserialize<List<Top100>>(topList)!;
// BetterTTV occasionally returns entries with a null Code; the
// upstream code passed those straight into Dictionary.TryAdd
// and tripped ArgumentNullException, killing the whole emote
// load. Skip them defensively so a single bad row no longer
// breaks the cache for everyone else.
foreach (var emote in jsonList)
if (!string.IsNullOrEmpty(emote.Emote.Code) && !NotWorking.Contains(emote.Emote.Code))
Cache.TryAdd(emote.Emote.Code, emote.Emote);
lastId = jsonList.Last().Id;
}
SortedCodeArray = Cache.Keys.Order().ToArray();
State = LoadingState.Done;
}
catch (Exception ex)
{
// Reset to Unloaded so a later trigger (e.g. the user reopening
// the Emotes tab after the network recovers) can retry. Without
// this the State stays on Loading and the early-out at the top
// of LoadData blocks every further attempt until plugin reload.
State = LoadingState.Unloaded;
Plugin.Log.Error(ex, "BetterTTV cache wasn't initialized");
}
}
public static void Dispose()
{
foreach (var emote in EmoteImages.Values)
emote.InnerDispose();
}
internal static bool Exists(string code)
{
return State is LoadingState.Done && SortedCodeArray.Contains(code);
}
internal static EmoteBase? GetEmote(string code)
{
if (State is not LoadingState.Done)
return null;
if (!Cache.TryGetValue(code, out var emoteDetail))
return null;
if (EmoteImages.TryGetValue(emoteDetail.Id, out var emote))
return emote;
try
{
if (emoteDetail.ImageType == "gif")
{
var animatedEmote = new ImGuiGif().Prepare(emoteDetail);
EmoteImages.Add(emoteDetail.Id, animatedEmote);
return animatedEmote;
}
var staticEmote = new ImGuiEmote().Prepare(emoteDetail);
EmoteImages.Add(emoteDetail.Id, staticEmote);
return staticEmote;
}
catch
{
Plugin.Log.Error("Failed to convert");
return null;
}
}
public abstract class EmoteBase
{
public bool Failed;
public bool IsLoaded;
public byte[] RawData = [];
protected IDalamudTextureWrap? Texture;
public virtual void Draw(Vector2 size)
{
ImGui.Image(Texture!.Handle, size);
}
internal async Task<byte[]> LoadAsync(Emote emote)
{
// BetterTTV-supplied Id and ImageType are interpolated straight
// into the filename. HTTPS protects the wire, but a compromised
// upstream could still hand us "../foo" and write into the
// pluginConfigs root (or worse). Resolve the candidate path and
// refuse anything that escapes the cache directory.
var dir = Path.GetFullPath(Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1"));
Directory.CreateDirectory(dir);
var dirPrefix = dir.EndsWith(Path.DirectorySeparatorChar) ? dir : dir + Path.DirectorySeparatorChar;
var filePath = Path.GetFullPath(Path.Join(dir, $"{emote.Id}.{emote.ImageType}"));
if (!filePath.StartsWith(dirPrefix, StringComparison.Ordinal))
throw new InvalidOperationException($"Emote path escapes cache directory: id={emote.Id}, type={emote.ImageType}");
if (File.Exists(filePath))
{
RawData = await File.ReadAllBytesAsync(filePath);
}
else
{
var content = await new HttpClient().GetAsync(EmotePath.Format(emote.Id));
RawData = await content.Content.ReadAsByteArrayAsync();
await using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read);
stream.Write(RawData, 0, RawData.Length);
}
return RawData;
}
public abstract void InnerDispose();
}
public sealed class ImGuiEmote : EmoteBase
{
public ImGuiEmote Prepare(Emote emote)
{
Task.Run(() => Load(emote));
return this;
}
private async void Load(Emote emote)
{
try
{
var image = await LoadAsync(emote);
if (image.Length <= 0)
return;
Texture = await Plugin.TextureProvider.CreateFromImageAsync(image);
IsLoaded = true;
}
catch (Exception ex)
{
Failed = true;
Plugin.Log.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
}
}
public override void InnerDispose()
{
Texture?.Dispose();
}
}
public sealed class ImGuiGif : EmoteBase
{
private List<(IDalamudTextureWrap Texture, float Delay)> Frames = [];
private float FrameTimer;
private int CurrentFrame;
private ulong GlobalFrameCount;
public override void Draw(Vector2 size)
{
if (Frames.Count == 0)
return;
if (CurrentFrame >= Frames.Count)
{
CurrentFrame = 0;
FrameTimer = -1f;
}
var frame = Frames[CurrentFrame];
if (FrameTimer <= 0.0f)
FrameTimer = frame.Delay;
ImGui.Image(frame.Texture.Handle, size);
if (GlobalFrameCount == Plugin.Interface.UiBuilder.FrameCount)
return;
GlobalFrameCount = Plugin.Interface.UiBuilder.FrameCount;
FrameTimer -= ImGui.GetIO().DeltaTime;
if (FrameTimer <= 0f)
CurrentFrame++;
}
public override void InnerDispose()
{
Frames.ForEach(f => f.Texture.Dispose());
Frames.Clear();
}
public ImGuiGif Prepare(Emote emote)
{
Task.Run(() => Load(emote));
return this;
}
private async void Load(Emote emote)
{
try
{
var image = await LoadAsync(emote);
if (image.Length <= 0)
return;
using var ms = new MemoryStream(image);
using var img = Image.Load<Rgba32>(ms);
if (img.Frames.Count == 0)
return;
var frames = new List<(IDalamudTextureWrap Tex, float Delay)>();
foreach (var frame in img.Frames)
{
var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f;
// Follows the same pattern as browsers, anything under 0.02s delay will be rounded up to 0.1s
if (delay < 0.02f)
delay = 0.1f;
var buffer = new byte[4 * frame.Width * frame.Height];
frame.CopyPixelDataTo(buffer);
var tex = await Plugin.TextureProvider.CreateFromRawAsync(RawImageSpecification.Rgba32(frame.Width, frame.Height), buffer);
frames.Add((tex, delay));
}
Frames = frames;
IsLoaded = true;
}
catch (Exception ex)
{
Failed = true;
Plugin.Log.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
}
}
}
}
+229
View File
@@ -0,0 +1,229 @@
using System.Globalization;
using System.Text;
using HellionChat.Code;
namespace HellionChat.Export;
internal enum ExportFormat
{
Markdown,
Json,
Csv,
}
internal static class ExportFormatExt
{
internal static string Extension(this ExportFormat fmt) => fmt switch
{
ExportFormat.Markdown => "md",
ExportFormat.Json => "json",
ExportFormat.Csv => "csv",
_ => "txt",
};
internal static string Filter(this ExportFormat fmt) => fmt switch
{
ExportFormat.Markdown => ".md",
ExportFormat.Json => ".json",
ExportFormat.Csv => ".csv",
_ => ".txt",
};
}
/// <summary>
/// Serializes message snapshots into Markdown, JSON, or CSV. The caller is
/// expected to filter the input enumerable; this class only handles
/// formatting and writes to the supplied path. Sender substring filtering
/// happens here because it requires deserialized SeString.TextValue.
/// </summary>
internal static class MessageExporter
{
internal record FilterDescription(
IReadOnlyCollection<int>? ChatTypes,
DateTimeOffset? From,
DateTimeOffset? To,
string? SenderSubstring);
internal static int ExportToFile(
string path,
ExportFormat format,
IEnumerable<Message> messages,
FilterDescription filter)
{
var matching = filter.SenderSubstring is { Length: > 0 } needle
? messages.Where(m => MatchesSender(m, needle))
: messages;
using var writer = new StreamWriter(path, append: false, encoding: Encoding.UTF8);
return format switch
{
ExportFormat.Markdown => WriteMarkdown(writer, matching, filter),
ExportFormat.Json => WriteJson(writer, matching, filter),
ExportFormat.Csv => WriteCsv(writer, matching, filter),
_ => throw new ArgumentOutOfRangeException(nameof(format), format, null),
};
}
private static bool MatchesSender(Message m, string needle)
=> m.SenderSource.TextValue.Contains(needle, StringComparison.OrdinalIgnoreCase);
private static int WriteMarkdown(StreamWriter w, IEnumerable<Message> messages, FilterDescription filter)
{
w.WriteLine("# Hellion Chat Export");
w.WriteLine();
w.WriteLine($"Generated: {DateTimeOffset.Now:yyyy-MM-dd HH:mm zzz}");
WriteFilterSummaryMarkdown(w, filter);
w.WriteLine();
DateTimeOffset? lastDate = null;
var count = 0;
foreach (var m in messages)
{
count++;
var localDate = m.Date.ToLocalTime();
if (lastDate is null || localDate.Date != lastDate.Value.Date)
{
w.WriteLine();
w.WriteLine($"## {localDate:yyyy-MM-dd}");
w.WriteLine();
lastDate = localDate;
}
var chatType = (ChatType)(ushort)m.Code.Type;
var sender = m.SenderSource.TextValue.Trim().Trim('<', '>', '[', ']', ':').Trim();
var content = m.ContentSource.TextValue;
if (string.IsNullOrEmpty(sender))
w.WriteLine($"**[{localDate:HH:mm}] {chatType}:** {content}");
else
w.WriteLine($"**[{localDate:HH:mm}] {chatType} {sender}:** {content}");
}
w.WriteLine();
w.WriteLine($"---");
w.WriteLine($"Total messages: {count}");
return count;
}
private static void WriteFilterSummaryMarkdown(StreamWriter w, FilterDescription filter)
{
if (filter.ChatTypes is { Count: > 0 })
w.WriteLine($"ChatTypes: {string.Join(", ", filter.ChatTypes.Select(t => $"{(ChatType)(ushort)t}({t})"))}");
if (filter.From is not null)
w.WriteLine($"From: {filter.From.Value.ToLocalTime():yyyy-MM-dd HH:mm}");
if (filter.To is not null)
w.WriteLine($"To: {filter.To.Value.ToLocalTime():yyyy-MM-dd HH:mm}");
if (filter.SenderSubstring is { Length: > 0 })
w.WriteLine($"Sender contains: \"{filter.SenderSubstring}\"");
}
private static int WriteJson(StreamWriter w, IEnumerable<Message> messages, FilterDescription filter)
{
// Manual JSON to avoid pulling in System.Text.Json policy choices.
// Output is a single object with metadata and an array of messages.
w.Write("{\n \"exported_at\": \"");
w.Write(DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
w.Write("\",\n \"plugin\": \"Hellion Chat\",\n");
w.Write(" \"filter\": {\n");
w.Write(" \"chat_types\": ");
if (filter.ChatTypes is { Count: > 0 })
w.Write("[" + string.Join(",", filter.ChatTypes) + "]");
else
w.Write("null");
w.Write(",\n \"from\": ");
w.Write(filter.From is null ? "null" : "\"" + filter.From.Value.ToString("O", CultureInfo.InvariantCulture) + "\"");
w.Write(",\n \"to\": ");
w.Write(filter.To is null ? "null" : "\"" + filter.To.Value.ToString("O", CultureInfo.InvariantCulture) + "\"");
w.Write(",\n \"sender_substring\": ");
w.Write(filter.SenderSubstring is null ? "null" : JsonString(filter.SenderSubstring));
w.Write("\n },\n \"messages\": [\n");
var first = true;
var count = 0;
foreach (var m in messages)
{
if (!first)
w.Write(",\n");
first = false;
count++;
var chatType = (ChatType)(ushort)m.Code.Type;
w.Write(" {");
w.Write($"\"id\":\"{m.Id}\"");
w.Write($",\"date\":\"{m.Date.ToString("O", CultureInfo.InvariantCulture)}\"");
w.Write($",\"chat_type\":{(int)m.Code.Type}");
w.Write($",\"chat_type_name\":\"{chatType}\"");
w.Write($",\"source_kind\":{m.Code.Source}");
w.Write($",\"target_kind\":{m.Code.Target}");
w.Write($",\"receiver\":{m.Receiver}");
w.Write($",\"content_id\":{m.ContentId}");
w.Write($",\"sender\":{JsonString(m.SenderSource.TextValue)}");
w.Write($",\"content\":{JsonString(m.ContentSource.TextValue)}");
w.Write("}");
}
w.Write("\n ],\n");
w.Write($" \"total\": {count}\n}}\n");
return count;
}
private static int WriteCsv(StreamWriter w, IEnumerable<Message> messages, FilterDescription filter)
{
// Header line always written so empty exports are still importable.
w.WriteLine("Date,ChatType,ChatTypeName,Sender,Content,Receiver,ContentId");
var count = 0;
foreach (var m in messages)
{
count++;
var chatType = (ChatType)(ushort)m.Code.Type;
w.Write(m.Date.ToString("O", CultureInfo.InvariantCulture));
w.Write(',');
w.Write((int)m.Code.Type);
w.Write(',');
w.Write(CsvString(chatType.ToString()));
w.Write(',');
w.Write(CsvString(m.SenderSource.TextValue));
w.Write(',');
w.Write(CsvString(m.ContentSource.TextValue));
w.Write(',');
w.Write(m.Receiver);
w.Write(',');
w.Write(m.ContentId);
w.WriteLine();
}
return count;
}
private static string JsonString(string s)
{
var sb = new StringBuilder(s.Length + 2);
sb.Append('"');
foreach (var c in s)
{
switch (c)
{
case '"': sb.Append("\\\""); break;
case '\\': sb.Append("\\\\"); break;
case '\b': sb.Append("\\b"); break;
case '\f': sb.Append("\\f"); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
default:
if (c < 0x20)
sb.Append($"\\u{(int)c:x4}");
else
sb.Append(c);
break;
}
}
sb.Append('"');
return sb.ToString();
}
private static string CsvString(string s)
{
if (s.IndexOfAny(['"', ',', '\n', '\r']) < 0)
return s;
return "\"" + s.Replace("\"", "\"\"") + "\"";
}
}
+210
View File
@@ -0,0 +1,210 @@
using Dalamud;
using Dalamud.Interface;
using Dalamud.Interface.FontIdentifier;
using Dalamud.Interface.GameFonts;
using Dalamud.Interface.ManagedFontAtlas;
using Dalamud.Interface.Utility;
using Dalamud.Bindings.ImGui;
namespace HellionChat;
public class FontManager
{
internal IFontHandle Axis = null!;
internal IFontHandle AxisItalic = null!;
internal IFontHandle RegularFont = null!;
internal IFontHandle? ItalicFont;
internal IFontHandle FontAwesome = null!;
internal readonly byte[] GameSymFont;
private ushort[] Ranges = [];
private ushort[] JpRange = [];
public static readonly HashSet<float> AxisFontSizeList =
[
9.6f, 10f, 12f, 14f, 16f,
18f, 18.4f, 20f, 23f, 34f,
36f, 40f, 45f, 46f, 68f, 90f,
];
public FontManager()
{
var filePath = Path.Combine(Plugin.Interface.ConfigDirectory.FullName, "FFXIV_Lodestone_SSF.ttf");
if (File.Exists(filePath))
{
GameSymFont = File.ReadAllBytes(filePath);
}
else
{
GameSymFont = new HttpClient().GetAsync("https://img.finalfantasyxiv.com/lds/pc/global/fonts/FFXIV_Lodestone_SSF.ttf")
.Result
.Content
.ReadAsByteArrayAsync()
.Result;
Dalamud.Utility.FilesystemUtil.WriteAllBytesSafe(filePath, GameSymFont);
}
}
/// <summary>
/// Backing bytes for the bundled Hellion font (Exo 2, OFL-1.1). Lazily
/// extracted from the assembly's manifest resources on first use; the
/// load happens inside the font atlas build callback so we keep the
/// allocation off the plugin constructor's hot path.
/// </summary>
private static byte[]? HellionFontBytes;
private static byte[] GetHellionFontBytes()
{
if (HellionFontBytes is not null)
return HellionFontBytes;
using var stream = typeof(FontManager).Assembly.GetManifestResourceStream("HellionFont.ttf")
?? throw new FileNotFoundException("Hellion font resource not embedded in the assembly");
using var ms = new MemoryStream();
stream.CopyTo(ms);
HellionFontBytes = ms.ToArray();
return HellionFontBytes;
}
private unsafe void SetUpRanges()
{
ushort[] BuildRange(IReadOnlyList<ushort>? chars, params nint[] ranges)
{
var builder = new ImFontGlyphRangesBuilderPtr(ImGuiNative.ImFontGlyphRangesBuilder());
// text
foreach (var range in ranges)
builder.AddRanges((ushort*)range);
// chars
if (chars != null)
{
for (var i = 0; i < chars.Count; i += 2)
{
if (chars[i] == 0)
break;
for (var j = (uint) chars[i]; j <= chars[i + 1]; j++)
builder.AddChar((ushort) j);
}
}
// Ingame supported ranges
var reader = new FdtReader(Plugin.DataManager.GetFile("common/font/axis_12.fdt")!.Data);
foreach (var c in reader.Glyphs)
builder.AddChar(c.Char);
// various symbols
// French
// Romanian
// builder.AddText("←→↑↓《》■※☀★★☆♥♡ヅツッシ☀☁☂℃℉°♀♂♠♣♦♣♧®©™€$£♯♭♪✓√◎◆◇♦■□〇●△▽▼▲‹›≤≥<«“”─\~");
builder.AddText("Œœ");
builder.AddText("ĂăÂâÎîȘșȚț");
// "Enclosed Alphanumerics" (partial) https://www.compart.com/en/unicode/block/U+2460
for (var i = 0x2460; i <= 0x24B5; i++)
builder.AddChar((char) i);
builder.AddChar('⓪');
return builder.BuildRangesToArray();
}
var ranges = new List<nint> { (nint)ImGui.GetIO().Fonts.GetGlyphRangesDefault() };
foreach (var extraRange in Enum.GetValues<ExtraGlyphRanges>())
if (Plugin.Config.ExtraGlyphRanges.HasFlag(extraRange))
ranges.Add(extraRange.Range());
Ranges = BuildRange(null, ranges.ToArray());
JpRange = BuildRange(GlyphRangesJapanese.GlyphRanges);
}
public void BuildFonts()
{
SetUpRanges();
Axis = Plugin.Interface.UiBuilder.FontAtlas.NewGameFontHandle(new GameFontStyle(GameFontFamily.Axis, SizeInPx(Plugin.Config.FontSizeV2)));
AxisItalic = Plugin.Interface.UiBuilder.FontAtlas.NewGameFontHandle(new GameFontStyle(GameFontFamily.Axis, SizeInPx(Plugin.Config.FontSizeV2))
{
SkewStrength = SizeInPx(Plugin.Config.FontSizeV2) / 6
});
FontAwesome = Plugin.Interface.UiBuilder.FontAtlas.NewDelegateFontHandle(e =>
{
e.OnPreBuild(tk => tk.AddFontAwesomeIconFont(new SafeFontConfig { SizePx = GetFontSize() }));
e.OnPostBuild(tk => tk.FitRatio(tk.Font));
});
RegularFont = Plugin.Interface.UiBuilder.FontAtlas.NewDelegateFontHandle(
e => e.OnPreBuild(
tk =>
{
var config = new SafeFontConfig {SizePt = Plugin.Config.GlobalFontV2.SizePt, GlyphRanges = Ranges};
config.MergeFont = Plugin.Config.UseHellionFont
? tk.AddFontFromMemory(GetHellionFontBytes(), config, "Hellion-Exo2")
: AddFontWithFallback(tk, Plugin.Config.GlobalFontV2.FontId, config, "global");
config.SizePt = Plugin.Config.JapaneseFontV2.SizePt;
config.GlyphRanges = JpRange;
AddFontWithFallback(tk, Plugin.Config.JapaneseFontV2.FontId, config, "japanese");
config.SizePt = Plugin.Config.SymbolsFontSizeV2;
tk.AddGameSymbol(config);
tk.Font = config.MergeFont;
}
));
if (Plugin.Config.ItalicEnabled)
{
ItalicFont = Plugin.Interface.UiBuilder.FontAtlas.NewDelegateFontHandle(
e => e.OnPreBuild(
tk =>
{
var config = new SafeFontConfig {SizePt = Plugin.Config.ItalicFontV2.SizePt, GlyphRanges = Ranges};
config.MergeFont = AddFontWithFallback(tk, Plugin.Config.ItalicFontV2.FontId, config, "italic");
config.SizePt = Plugin.Config.JapaneseFontV2.SizePt;
config.GlyphRanges = JpRange;
AddFontWithFallback(tk, Plugin.Config.JapaneseFontV2.FontId, config, "japanese");
config.SizePt = Plugin.Config.SymbolsFontSizeV2;
tk.AddGameSymbol(config);
tk.Font = config.MergeFont;
}
));
}
else
{
ItalicFont = null;
}
}
/// <summary>
/// Try to add a user-configured font to the build toolkit, falling back to
/// the bundled NotoSansCjkRegular asset if the configured font isn't
/// available on the system. Without this guard a stale SystemFontId
/// pointing at a font the user uninstalled or that never existed on
/// Linux (e.g. "Crimson Text") tears down the entire font atlas build.
/// </summary>
private static ImFontPtr AddFontWithFallback(IFontAtlasBuildToolkitPreBuild tk, IFontId fontId, SafeFontConfig config, string slot)
{
try
{
return fontId.AddToBuildToolkit(tk, config);
}
catch (Exception e) when (e is FileNotFoundException or DirectoryNotFoundException or IOException)
{
Plugin.Log.Warning(e, $"Configured {slot} font unavailable, falling back to NotoSansCjkRegular");
var fallback = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular);
return fallback.AddToBuildToolkit(tk, config);
}
}
public static float SizeInPt(float px) => (float) (px * 3.0 / 4.0);
public static float SizeInPx(float pt) => (float) (pt * 4.0 / 3.0);
public static float GetFontSize() => Plugin.Config.FontsEnabled ? Plugin.Config.GlobalFontV2.SizePx : SizeInPx(Plugin.Config.FontSizeV2);
}
+574
View File
@@ -0,0 +1,574 @@
using System.Text;
using HellionChat.Code;
using HellionChat.GameFunctions.Types;
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud.Game.Config;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Hooking;
using Dalamud.Memory;
using Dalamud.Utility.Signatures;
using FFXIVClientStructs.FFXIV.Application.Network;
using FFXIVClientStructs.FFXIV.Client.System.Framework;
using FFXIVClientStructs.FFXIV.Client.System.String;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Client.UI.Info;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using FFXIVClientStructs.FFXIV.Client.UI.Shell;
using FFXIVClientStructs.FFXIV.Component.GUI;
using InteropGenerator.Runtime;
using Lumina.Text.ReadOnly;
using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.AtkValueType;
namespace HellionChat.GameFunctions;
internal sealed unsafe class Chat : IDisposable
{
// Functions
[Signature("48 89 5C 24 ?? 48 89 74 24 ?? 57 48 83 EC ?? 48 8D B9 ?? ?? ?? ?? 33 C0")]
private readonly delegate* unmanaged<RaptureLogModule*, ushort, Utf8String*, Utf8String*, ulong, ulong, ushort, byte, int, byte, void> PrintTellNative = null!;
[Signature("E8 ?? ?? ?? ?? 48 8D 4C 24 ?? E8 ?? ?? ?? ?? 48 8D 8C 24 ?? ?? ?? ?? E8 ?? ?? ?? ?? B0 ?? 48 8B 8C 24")]
private readonly delegate* unmanaged<NetworkModule*, ulong, ushort, Utf8String*, Utf8String*, ushort, ushort, byte> SendTellNative = null!;
// Client::UI::AddonChatLog.OnRefresh
[Signature("40 53 57 41 57 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 4D 8B F8", DetourName = nameof(ChatLogRefreshDetour))]
private Hook<ChatLogRefreshDelegate>? ChatLogRefreshHook = null!;
private delegate byte ChatLogRefreshDelegate(nint log, ushort eventId, AtkValue* value);
// Replace with CS version later
[Signature("48 89 5C 24 ?? 55 56 57 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 83 B9", DetourName = nameof(ContextMenuTellInForayDetour))]
private Hook<ContextMenuTellInForayDelegate>? ContextMenuTellInForayHook = null!;
private delegate void ContextMenuTellInForayDelegate(RaptureShellModule* module, Utf8String* playerName, Utf8String* worldName, ushort worldId, ulong accountId, ulong contentId, ushort reason);
private readonly Hook<AgentChatLog.Delegates.ChangeChannelName>? ChangeChannelNameHook;
private readonly Hook<RaptureShellModule.Delegates.ReplyInSelectedChatMode>? ReplyInSelectedChatModeHook;
private readonly Hook<RaptureShellModule.Delegates.SetContextTellTarget>? SetChatLogTellTargetHook;
// Pointers
[Signature("48 8D 1D ?? ?? ?? ?? 8B 05", ScanType = ScanType.StaticAddress)]
private readonly char* LastTypedCharacter = null!;
private Plugin Plugin { get; }
private enum PlayerNameDisplayType : uint
{
FullName = 0,
SurnameAbbreviated = 1,
ForenameAbbreviated = 2,
Initials = 3
}
private long LastPlayerNameDisplayTypeRefresh;
private PlayerNameDisplayType CurrentPlayerNameDisplayType = PlayerNameDisplayType.FullName;
public Chat(Plugin plugin)
{
Plugin = plugin;
Plugin.GameInteropProvider.InitializeFromAttributes(this);
ChatLogRefreshHook?.Enable();
ContextMenuTellInForayHook?.Enable();
ChangeChannelNameHook = Plugin.GameInteropProvider.HookFromAddress<AgentChatLog.Delegates.ChangeChannelName>(AgentChatLog.MemberFunctionPointers.ChangeChannelName, ChangeChannelNameDetour);
ChangeChannelNameHook.Enable();
ReplyInSelectedChatModeHook = Plugin.GameInteropProvider.HookFromAddress<RaptureShellModule.Delegates.ReplyInSelectedChatMode>(RaptureShellModule.MemberFunctionPointers.ReplyInSelectedChatMode, ReplyInSelectedChatModeDetour);
ReplyInSelectedChatModeHook.Enable();
SetChatLogTellTargetHook = Plugin.GameInteropProvider.HookFromAddress<RaptureShellModule.Delegates.SetContextTellTarget>(RaptureShellModule.MemberFunctionPointers.SetContextTellTarget, SetContextTellTarget);
SetChatLogTellTargetHook.Enable();
Plugin.ClientState.Login += Login;
Login();
}
public void Dispose()
{
Plugin.ClientState.Login -= Login;
SetChatLogTellTargetHook?.Dispose();
ReplyInSelectedChatModeHook?.Dispose();
ChangeChannelNameHook?.Dispose();
ChatLogRefreshHook?.Dispose();
ContextMenuTellInForayHook?.Dispose();
}
internal string? GetLinkshellName(uint idx)
{
var utf = InfoProxyChat.Instance()->GetLinkShellName(idx);
return utf.HasValue ? utf.ToString() : null;
}
internal string? GetCrossLinkshellName(uint idx)
{
var utf = InfoProxyCrossWorldLinkshell.Instance()->GetCrossworldLinkshellName(idx);
return utf == null ? null : utf->ToString();
}
private static int GetRotateIdx(RotateMode mode) => mode switch
{
RotateMode.Forward => 1,
RotateMode.Reverse => -1,
_ => 0,
};
internal static void RotateLinkshellHistory(RotateMode mode)
{
var uiModule = UIModule.Instance();
if (mode == RotateMode.None)
uiModule->LinkshellCycle = -1;
uiModule->RotateLinkshellHistory(GetRotateIdx(mode));
}
internal static void RotateCrossLinkshellHistory(RotateMode mode) =>
UIModule.Instance()->RotateCrossLinkshellHistory(GetRotateIdx(mode));
// This function looks up a channel's user-defined color.
// If this function ever returns 0, it returns null instead.
internal uint? GetChannelColor(ChatType type)
{
var parent = type.Parent();
switch (parent)
{
case ChatType.Debug:
case ChatType.Urgent:
case ChatType.Notice:
return type.DefaultColor();
}
Plugin.GameConfig.TryGet(parent.ToConfigEntry(), out uint color);
var rgb = color & 0xFFFFFF;
if (rgb == 0)
return null;
return 0xFF | (rgb << 8);
}
private void Login()
{
var agent = AgentChatLog.Instance();
if (agent == null)
return;
ChangeChannelNameDetour(agent);
}
private byte ChatLogRefreshDetour(nint log, ushort eventId, AtkValue* value)
{
if (Plugin.CurrentTab.InputDisabled)
return ChatLogRefreshHook!.Original(log, eventId, value);
if (eventId != 0x31 || value == null || value->UInt is not (0x05 or 0x0C))
return ChatLogRefreshHook!.Original(log, eventId, value);
if (Plugin.Functions.KeybindManager.DirectChat && LastTypedCharacter != null)
{
// FIXME: this whole system sucks
// FIXME v2: I hate everything about this, but it works
Plugin.Framework.RunOnTick(() =>
{
string? input = null;
var utf8Bytes = MemoryHelper.ReadRaw((nint)LastTypedCharacter+0x4, 2);
var chars = Encoding.UTF8.GetString(utf8Bytes).ToCharArray();
if (chars.Length == 0)
return;
var c = chars[0];
if (c != '\0' && !char.IsControl(c))
input = c.ToString();
try
{
Plugin.ChatLogWindow.Activated(new ChatActivatedArgs(new ChannelSwitchInfo(null)) { Input = input, });
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Error in chat Activated event");
}
});
}
string? addIfNotPresent = null;
var str = value + 2;
if (str != null && ((int) str->Type & 0xF) == (int) ValueType.String && str->String.HasValue)
{
var add = str->String.ToString();
if (add.Length > 0)
addIfNotPresent = add;
}
try
{
// We already called this function once, so we skip the duplicated call
// Also return the original value here so that vanilla chat receives all information
if (Plugin.ChatLogWindow.TellSpecial)
{
Plugin.Log.Information("Return early to prevent duplicated call...");
return ChatLogRefreshHook!.Original(log, eventId, value);
}
Plugin.ChatLogWindow.Activated(new ChatActivatedArgs(new ChannelSwitchInfo(null)) { AddIfNotPresent = addIfNotPresent, });
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Error in chat Activated event");
}
// prevent the game from focusing the chat log
return 1;
}
private CStringPointer ChangeChannelNameDetour(AgentChatLog* agent)
{
var ret = ChangeChannelNameHook!.Original(agent);
if (agent == null)
return ret;
var channel = (uint) RaptureShellModule.Instance()->ChatType;
if (channel is 17 or 18)
channel = (uint) InputChannel.Tell;
var name = SeString.Parse(agent->ChannelLabel);
if (name.Payloads.Count == 0)
name = null;
if (name == null)
return ret;
var nameChunks = ChunkUtil.ToChunks(name, ChunkSource.None, null).ToList();
if (nameChunks.Count > 0 && nameChunks[0] is TextChunk text)
text.Content = text.Content.TrimStart('\uE01E').TrimStart();
string? playerName = null;
ushort worldId = 0;
if (channel == (uint) InputChannel.Tell)
{
playerName = SeString.Parse(agent->TellPlayerName).TextValue;
worldId = agent->TellWorldId;
Plugin.Log.Debug($"Detected tell target '{playerName}'@{worldId}");
}
Plugin.CurrentTab.CurrentChannel = new UsedChannel
{
Channel = (InputChannel) channel,
Name = nameChunks,
TellTarget = playerName != null ? new TellTarget(playerName, worldId, 0, 0) : null
};
return ret;
}
private void ReplyInSelectedChatModeDetour(RaptureShellModule* agent)
{
var replyMode = AgentChatLog.Instance()->ReplyChannel;
if (replyMode == -2)
{
ReplyInSelectedChatModeHook!.Original(agent);
return;
}
SetChannel((InputChannel) replyMode);
ReplyInSelectedChatModeHook!.Original(agent);
}
private bool SetContextTellTarget(RaptureShellModule* a1, Utf8String* playerName, Utf8String* worldName, ushort worldId, ulong accountId, ulong contentId, ushort reason, bool setChatType)
{
if (playerName != null)
{
try
{
var target = new TellTarget(playerName->ToString(), worldId, contentId, (TellReason) reason);
Plugin.ChatLogWindow.Activated(new ChatActivatedArgs(new ChannelSwitchInfo(InputChannel.Tell, permanent: setChatType))
{
TellReason = (TellReason) reason,
TellTarget = target,
});
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Error in chat Activated event");
}
}
return SetChatLogTellTargetHook!.Original(a1, playerName, worldName, worldId, accountId, contentId, reason, setChatType);
}
private void ContextMenuTellInForayDetour(RaptureShellModule* a1, Utf8String* playerName, Utf8String* worldName, ushort worldId, ulong accountId, ulong contentId, ushort reason)
{
if (!Plugin.CurrentTab.CurrentChannel.UseTempChannel)
Plugin.CurrentTab.CurrentChannel.UseTempChannel = true;
if (playerName != null)
{
try
{
var target = new TellTarget(playerName->ToString(), worldId, contentId, (TellReason) reason);
Plugin.ChatLogWindow.Activated(new ChatActivatedArgs(new ChannelSwitchInfo(InputChannel.Tell))
{
TellReason = (TellReason) reason,
TellTarget = target,
TellSpecial = Sheets.IsInForay(), // Handle Eureka/Bozja special
});
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Error in chat Activated event");
}
}
ContextMenuTellInForayHook!.Original(a1, playerName, worldName, worldId, accountId, contentId, reason);
}
/// <summary>
/// Returns true if the channel is any non-linkshell channel, or if the
/// linkshell actually exists.
/// </summary>
internal static bool ValidAnyLinkshell(InputChannel channel)
{
var idx = channel.LinkshellIndex();
if (idx == uint.MaxValue || channel.IsExtraChatLinkshell())
return true;
if (channel.IsLinkshell() && ValidLinkshell(idx))
return true;
if (channel.IsCrossLinkshell() && ValidCrossLinkshell(idx))
return true;
return false;
}
internal static bool ValidLinkshell(uint idx)
{
if (idx > 7)
return false;
return InfoProxyLinkshell.Instance()->LinkShells[(int) idx].Id != 0;
}
internal static bool ValidCrossLinkshell(uint idx)
{
if (idx > 7)
return false;
return InfoProxyCrossWorldLinkshell.Instance()->CrossWorldLinkshells[(int) idx].Name.Length > 0;
}
private static uint? RotateLinkshell(uint currentIndex, RotateMode rotate, Func<uint, bool> validFn)
{
if (rotate == RotateMode.None)
return null;
var delta = rotate switch
{
RotateMode.Forward => 1,
RotateMode.Reverse => -1,
_ => 1
};
// Iterate up to 8 times to find a valid linkshell.
for (var i = 0; i < 8; i++)
{
currentIndex = (uint) ((8 + currentIndex + delta) % 8);
if (validFn(currentIndex))
return currentIndex;
}
return null;
}
internal static InputChannel? ResolveTempInputChannel(InputChannel? currentTempChannel, InputChannel channel, RotateMode rotate)
{
switch (channel)
{
case InputChannel.Linkshell1 or InputChannel.CrossLinkshell1 when rotate != RotateMode.None:
{
var module = UIModule.Instance();
var currentIndex = channel is InputChannel.Linkshell1 ? (uint) module->LinkshellCycle : (uint) module->CrossWorldLinkshellCycle;
if (currentTempChannel != null)
{
switch (channel)
{
case InputChannel.Linkshell1 when currentTempChannel.Value.IsLinkshell():
case InputChannel.CrossLinkshell1 when currentTempChannel.Value.IsCrossLinkshell():
currentIndex = currentTempChannel.Value.LinkshellIndex();
break;
}
}
var idx = RotateLinkshell(currentIndex, rotate, channel == InputChannel.Linkshell1 ? ValidLinkshell : ValidCrossLinkshell);
return channel + idx;
}
default:
return channel;
}
}
internal void SetChannel(InputChannel channel, TellTarget? tellTarget = null)
{
// ExtraChat linkshells aren't supported in game so we never want to
// call the ChangeChatChannel function with them.
//
// Callers should call ChatLogWindow.SetChannel() which handles
// ExtraChat channels
if (channel.IsExtraChatLinkshell())
return;
var target = Utf8String.FromString(tellTarget?.ToTargetString() ?? "");
var idx = channel.LinkshellIndex();
if (idx == uint.MaxValue)
idx = 0;
if (!ValidAnyLinkshell(channel))
return;
RaptureShellModule.Instance()->ChangeChatChannel(tellTarget != null ? 17 : (int)channel, idx, target, true);
target->Dtor(true);
}
internal void SetEurekaTellChannel(string name, string worldName, ushort worldId, ulong accountId, ulong objectId, ushort reason, bool setChatType)
{
// param6 is 0 for contentId and 1 for objectId
// param7 is always 0 ?
if (!Plugin.CurrentTab.CurrentChannel.UseTempChannel)
Plugin.CurrentTab.CurrentChannel.UseTempChannel = true;
// Send tell via CommandInner later and let the game handle it
// Only works because we use the SetTellTargetInForay function to set all required information
Plugin.ChatLogWindow.TellSpecial = true;
var utfName = Utf8String.FromString(name);
var utfWorld = Utf8String.FromString(worldName);
RaptureShellModule.Instance()->SetTellTargetInForay(utfName, utfWorld, worldId, accountId, objectId, reason, setChatType);
utfName->Dtor(true);
utfWorld->Dtor(true);
}
internal TellHistoryInfo? GetTellHistoryInfo(int index)
{
var acquaintance = AcquaintanceModule.Instance()->GetTellHistory(index);
if (acquaintance == null || acquaintance->ContentId == 0)
return null;
var name = new ReadOnlySeStringSpan(acquaintance->Name.AsSpan()).ExtractText();
var world = acquaintance->WorldId;
var contentId = acquaintance->ContentId;
return new TellHistoryInfo(name, world, contentId);
}
internal void SendTellUsingCommandInner(byte[] message)
{
var mes = Utf8String.FromSequence(message.NullTerminate());
RaptureShellModule.Instance()->ExecuteCommandInner(mes, UIModule.Instance());
RaptureAtkModule.Instance()->ClearFocus(); // Clear the focus of vanilla chat that was still active
mes->Dtor(true);
}
internal void SendTell(TellReason reason, ulong contentId, string name, ushort homeWorld, byte[] message, string rawText)
{
if (contentId == 0)
{
Plugin.ChatGui.PrintError(Language.Chat_SendTell_Error);
Plugin.Log.Warning("Tried to send a tell with ContentId being 0, sorry this is an internal error.");
return;
}
var uName = Utf8String.FromString(name);
var uMessage = Utf8String.FromSequence(message.NullTerminate());
var encoded = Utf8String.FromUtf8String(PronounModule.Instance()->ProcessString(uMessage, true));
var decoded = EncodeMessage(rawText);
AutoTranslate.ReplaceWithPayload(ref decoded);
using var decodedUtf8String = new Utf8String(decoded.NullTerminate());
var logModule = RaptureLogModule.Instance();
var networkModule = Framework.Instance()->GetNetworkModuleProxy()->NetworkModule;
// // TODO: Remap TellReasons
if (reason == TellReason.Direct)
reason = TellReason.Friend;
var ok = SendTellNative(networkModule, contentId, homeWorld, uName, encoded, (ushort) reason, homeWorld);
if (ok == 1)
PrintTellNative(logModule, 33, uName, &decodedUtf8String, 0, contentId, homeWorld, 255, 0, 0);
else
Plugin.ChatGui.PrintError(Language.Chat_SendTell_Error);
encoded->Dtor(true);
uName->Dtor(true);
uMessage->Dtor(true);
}
private static byte[] EncodeMessage(string str) {
using var input = new Utf8String(str);
using var output = new Utf8String();
input.Copy(PronounModule.Instance()->ProcessString(&input, true));
output.Copy(PronounModule.Instance()->ProcessString(&input, false));
return output.AsSpan().ToArray();
}
internal bool IsCharValid(char c)
{
var uC = Utf8String.FromString(c.ToString());
uC->SanitizeString((AllowedEntities) 0x27F);
var wasValid = uC->ToString().Length > 0;
uC->Dtor(true);
return wasValid;
}
private PlayerNameDisplayType GetNameDisplayType()
{
var ok = Plugin.GameConfig.TryGet(UiConfigOption.LogNameType, out uint type);
if (!ok || !Enum.IsDefined(typeof(PlayerNameDisplayType), type))
return PlayerNameDisplayType.FullName;
return (PlayerNameDisplayType) type;
}
internal string AbbreviatePlayerName(string playerName)
{
if (LastPlayerNameDisplayTypeRefresh + 5_000 < Environment.TickCount64)
{
LastPlayerNameDisplayTypeRefresh = Environment.TickCount64;
CurrentPlayerNameDisplayType = GetNameDisplayType();
}
if (CurrentPlayerNameDisplayType == PlayerNameDisplayType.FullName)
return playerName;
var split = playerName.Split(' ');
if (split.Length != 2)
return playerName;
return CurrentPlayerNameDisplayType switch
{
PlayerNameDisplayType.SurnameAbbreviated => $"{split.First()} {split.Last().FirstOrDefault('A')}.",
PlayerNameDisplayType.ForenameAbbreviated => $"{split.First().FirstOrDefault('A')}. {split.Last()}",
PlayerNameDisplayType.Initials => $"{split.First().FirstOrDefault('A')}. {split.Last().FirstOrDefault('A')}.",
_ => playerName
};
}
internal bool CheckHideFlags()
{
// Only hide the chat in a cutscene when the vanilla chat would've
// also been hidden. This prevents Chat 2 from hiding for a split
// second before the cutscene actually starts, because the game sets
// the cutscene conditions before processing the skip.
var raptureAtkUnitManager = RaptureAtkUnitManager.Instance();
return raptureAtkUnitManager == null || raptureAtkUnitManager->UiFlags.HasFlag(UiFlags.Chat);
}
}
+43
View File
@@ -0,0 +1,43 @@
using System.Text;
using HellionChat.Resources;
using Dalamud.Memory;
using FFXIVClientStructs.FFXIV.Client.System.String;
using FFXIVClientStructs.FFXIV.Client.UI;
namespace HellionChat.GameFunctions;
public unsafe class ChatBox
{
public static void SendMessageUnsafe(byte[] message)
{
var mes = Utf8String.FromSequence(message.NullTerminate());
UIModule.Instance()->ProcessChatBoxEntry(mes);
mes->Dtor(true);
}
public static void SendMessage(string message)
{
var bytes = Encoding.UTF8.GetBytes(message);
if (bytes.Length == 0)
throw new ArgumentException(Language.ChatBox_Error_Empty, nameof(message));
if (bytes.Length > 500)
throw new ArgumentException(Language.ChatBox_Error_Too_Long, nameof(message));
if (message.Length != SanitiseText(message).Length)
throw new ArgumentException(Language.ChatBox_Error_Invalid, nameof(message));
SendMessageUnsafe(bytes);
}
private static string SanitiseText(string text)
{
var uText = Utf8String.FromString(text);
uText->SanitizeString((AllowedEntities) 0x27F);
var sanitised = uText->ToString();
uText->Dtor(true);
return sanitised;
}
}
+45
View File
@@ -0,0 +1,45 @@
using HellionChat.Util;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Client.UI.Info;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
namespace HellionChat.GameFunctions;
internal sealed unsafe class Context
{
internal static void InviteToNoviceNetwork(string name, ushort world)
{
// can specify content id if we have it, but there's no need
InfoProxyNoviceNetwork.Instance()->InviteToNoviceNetwork(0, 0, world, name.ToTerminatedBytes());
}
internal static void TryOn(uint itemId, byte stainId)
{
AgentTryon.TryOn(0xFF, itemId, stainId);
}
internal static void LinkItem(uint itemId)
{
AgentChatLog.Instance()->LinkItem(itemId);
}
internal static void LinkStatus(uint statusId)
{
AgentChatLog.Instance()->ContextStatusId = statusId;
}
internal static void OpenItemComparison(uint itemId)
{
AgentItemComp.Instance()->CompareItem(0x4D, itemId, 0, 0);
}
internal static void SearchForRecipesUsingItem(uint itemId)
{
AgentRecipeProductList.Instance()->SearchForRecipesUsingItem(itemId);
}
internal static void SearchForItem(uint itemId)
{
ItemFinderModule.Instance()->SearchForItem(itemId);
}
}
+261
View File
@@ -0,0 +1,261 @@
using System.Globalization;
using System.Runtime.InteropServices;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Hooking;
using Dalamud.Memory;
using Dalamud.Utility;
using Dalamud.Utility.Signatures;
using FFXIVClientStructs.FFXIV.Client.Enums;
using FFXIVClientStructs.FFXIV.Client.Game.UI;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Client.UI.Info;
using FFXIVClientStructs.FFXIV.Component.GUI;
using Lumina.Excel;
using Lumina.Excel.Sheets;
using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.AtkValueType;
namespace HellionChat.GameFunctions;
internal unsafe class GameFunctions : IDisposable
{
#region Hooks
[Signature("E8 ?? ?? ?? ?? 48 85 C0 0F 84 ?? ?? ?? ?? 48 8B D0 49 8D 4F", DetourName = nameof(ResolveTextCommandPlaceholderDetour))]
private Hook<ResolveTextCommandPlaceholderDelegate>? ResolveTextCommandPlaceholderHook = null!;
private delegate nint ResolveTextCommandPlaceholderDelegate(nint a1, byte* placeholderText, byte a3, byte a4);
#endregion
private Plugin Plugin { get; }
internal KeybindManager KeybindManager { get; }
internal Chat Chat { get; }
internal GameFunctions(Plugin plugin)
{
Plugin = plugin;
KeybindManager = new KeybindManager(plugin);
Chat = new Chat(Plugin);
Plugin.GameInteropProvider.InitializeFromAttributes(this);
ResolveTextCommandPlaceholderHook?.Enable();
}
public void Dispose()
{
Chat.Dispose();
KeybindManager.Dispose();
ResolveTextCommandPlaceholderHook?.Dispose();
Marshal.FreeHGlobal(PlaceholderNamePtr);
}
internal void SendFriendRequest(string name, ushort world)
{
ListCommand(name, world, "friendlist");
}
internal void AddToBlacklist(string name, ushort world)
{
ListCommand(name, world, "blist");
}
internal void AddToMuteList(ulong accountId, ulong contentId, string name, short worldId)
{
AgentMutelist.Instance()->Add(accountId, contentId, name, worldId);
}
internal void AddToTermsList(SeString content)
{
AgentTermFilter.Instance()->OpenNewFilterWindow(content.EncodeWithNullTerminator());
}
private void ListCommand(string name, ushort world, string commandName)
{
var worldRow = Sheets.WorldSheet.GetRow(world);
ReplacementName = $"{name}@{worldRow.Name.ToString()}";
ChatBox.SendMessage($"/{commandName} add {Placeholder}");
}
private static T* GetAddon<T>(string name) where T : unmanaged
{
var addon = RaptureAtkModule.Instance()->RaptureAtkUnitManager.GetAddonByName(name);
return addon != null && addon->IsReady ? (T*)addon : null;
}
internal static void SetAddonInteractable(string name, bool interactable)
{
var addon = GetAddon<AtkUnitBase>(name);
if (addon == null)
return;
addon->IsVisible = interactable;
}
internal static void SetChatInteractable(bool interactable)
{
for (var i = 0; i < 4; i++)
SetAddonInteractable($"ChatLogPanel_{i}", interactable);
SetAddonInteractable("ChatLog", interactable);
}
internal static bool IsAddonInteractable(string name)
{
var addon = GetAddon<AtkUnitBase>(name);
return addon != null && addon->IsVisible;
}
internal static void OpenItemTooltip(uint id, ItemKind itemKind)
{
var atkStage = AtkStage.Instance();
var agent = AgentItemDetail.Instance();
var addon = GetAddon<AtkUnitBase>("ItemDetail");
// atkStage ain't gonna be null or we have bigger problems
if (agent == null || addon == null)
return;
agent->DetailKind = itemKind == ItemKind.EventItem ? DetailKind.KeyItem : DetailKind.Item;
agent->TypeOrId = id;
agent->Index = 0;
agent->Flag1 &= 0xEF;
agent->ItemId = id;
// agent->Flag2 = 1;
// agent->Flag3 = 0;
// TODO: Revert whenever CS is merged
*(byte*)((nint)agent + 0x21A) = 1;
*(byte*)((nint)agent + 0x21E) = 0;
// This just probably needs to be set
agent->AddonId = addon->Id;
// Skips early return
atkStage->TooltipManager.TooltipType |= 2;
addon->Show(false, 15);
}
internal static void CloseItemTooltip()
{
// hide addon first to prevent the "addon close" sound
var addon = GetAddon<AtkUnitBase>("ItemDetail");
if (addon != null)
addon->Hide(true, false, 0);
var agent = AgentItemDetail.Instance();
if (agent != null)
{
var eventData = stackalloc AtkValue[1];
var atkValues = stackalloc AtkValue[1];
atkValues->Type = ValueType.Int;
atkValues->Int = -1;
agent->ReceiveEvent(eventData, atkValues, 1, 1);
}
}
internal static void OpenPartyFinder()
{
// this whole method: 6.05: 84433A (FF 97 ?? ?? ?? ?? 41 B4 01)
var lfg = AgentLookingForGroup.Instance();
if (lfg->IsAgentActive())
{
var addonId = lfg->GetAddonId();
var atkModule = RaptureAtkModule.Instance();
var atkModuleVtbl = (void**) atkModule->AtkModule.VirtualTable;
var vf27 = (delegate* unmanaged<RaptureAtkModule*, ulong, ulong, byte>) atkModuleVtbl[27];
vf27(atkModule, addonId, 1);
}
else
{
// 6.05: 8443DD
if (*(uint*) ((nint) lfg + 0x2C20) > 0)
lfg->Hide();
else
lfg->Show();
}
}
internal static bool IsMentor()
{
return PlayerState.Instance()->IsMentor();
}
internal static InfoProxyCommonList.CharacterData[] GetFriends()
{
return InfoProxyFriendList.Instance()->CharDataSpan.ToArray();
}
internal static void OpenQuestLog(RowRef<Quest> quest)
{
var splits = quest.Value.Id.ToString().Split("_");
if (splits.Length != 2)
{
Plugin.ChatGui.Print("QuestId is wrongly formatted");
return;
}
if (!uint.TryParse(splits[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var questId))
{
Plugin.ChatGui.Print("Unable to parse quest id");
return;
}
AgentQuestJournal.Instance()->OpenForQuest(questId, 1);
}
internal static void OpenPartyFinder(uint id)
{
AgentLookingForGroup.Instance()->OpenListing(id);
}
internal static void OpenAchievement(uint id)
{
AgentAchievement.Instance()->OpenById(id);
}
internal static bool IsInInstance()
{
return Plugin.Condition[ConditionFlag.BoundByDuty56];
}
internal static bool TryOpenAdventurerPlate(ulong playerId)
{
try
{
AgentCharaCard.Instance()->OpenCharaCard(playerId);
return true;
}
catch (Exception e)
{
Plugin.Log.Warning(e, "Unable to open adventurer plate");
return false;
}
}
internal static void ClickNoviceNetworkButton()
{
var agent = AgentChatLog.Instance();
// case 3
var value = new AtkValue { Type = ValueType.Int, Int = 3, };
var result = 0;
var vf0 = *(delegate* unmanaged<AgentChatLog*, int*, AtkValue*, ulong, ulong, int*>*) agent->VirtualTable;
vf0(agent, &result, &value, 0, 0);
}
private readonly nint PlaceholderNamePtr = Marshal.AllocHGlobal(128);
private readonly string Placeholder = $"<{Guid.NewGuid():N}>";
private string? ReplacementName;
private nint ResolveTextCommandPlaceholderDetour(nint a1, byte* placeholderText, byte a3, byte a4)
{
var placeholder = MemoryHelper.ReadStringNullTerminated((nint) placeholderText);
if (ReplacementName == null || placeholder != Placeholder)
return ResolveTextCommandPlaceholderHook!.Original(a1, placeholderText, a3, a4);
MemoryHelper.WriteString(PlaceholderNamePtr, ReplacementName);
ReplacementName = null;
return PlaceholderNamePtr;
}
}
+514
View File
@@ -0,0 +1,514 @@
using System.Numerics;
using HellionChat.Code;
using HellionChat.GameFunctions.Types;
using HellionChat.Util;
using Dalamud.Game.ClientState.Keys;
using Dalamud.Game.Config;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.System.String;
using FFXIVClientStructs.FFXIV.Client.UI;
using Dalamud.Bindings.ImGui;
using ModifierFlag = HellionChat.GameFunctions.Types.ModifierFlag;
namespace HellionChat.GameFunctions;
internal enum KeyboardSource {
Game,
ImGui
}
internal unsafe class KeybindManager : IDisposable {
private Plugin Plugin { get; }
internal bool DirectChat;
private long LastRefresh;
private bool VanillaTextInputHasFocus;
private readonly Dictionary<string, Keybind> Keybinds = new();
private static readonly IReadOnlyDictionary<string, ChannelSwitchInfo> KeybindsToIntercept = new Dictionary<string, ChannelSwitchInfo>
{
["CMD_CHAT"] = new(null),
["CMD_COMMAND"] = new(null, text: "/"),
["CMD_REPLY"] = new(InputChannel.Tell, rotate: RotateMode.Forward),
["CMD_REPLY_REV"] = new(InputChannel.Tell, rotate: RotateMode.Reverse),
["CMD_SAY"] = new(InputChannel.Say),
["CMD_YELL"] = new(InputChannel.Yell),
["CMD_SHOUT"] = new(InputChannel.Shout),
["CMD_PARTY"] = new(InputChannel.Party),
["CMD_ALLIANCE"] = new(InputChannel.Alliance),
["CMD_FREECOM"] = new(InputChannel.FreeCompany),
["PVPTEAM_CHAT"] = new(InputChannel.PvpTeam),
["CMD_CWLINKSHELL"] = new(InputChannel.CrossLinkshell1, rotate: RotateMode.Forward),
["CMD_CWLINKSHELL_REV"] = new(InputChannel.CrossLinkshell1, rotate: RotateMode.Reverse),
["CMD_CWLINKSHELL_1"] = new(InputChannel.CrossLinkshell1),
["CMD_CWLINKSHELL_2"] = new(InputChannel.CrossLinkshell2),
["CMD_CWLINKSHELL_3"] = new(InputChannel.CrossLinkshell3),
["CMD_CWLINKSHELL_4"] = new(InputChannel.CrossLinkshell4),
["CMD_CWLINKSHELL_5"] = new(InputChannel.CrossLinkshell5),
["CMD_CWLINKSHELL_6"] = new(InputChannel.CrossLinkshell6),
["CMD_CWLINKSHELL_7"] = new(InputChannel.CrossLinkshell7),
["CMD_CWLINKSHELL_8"] = new(InputChannel.CrossLinkshell8),
["CMD_LINKSHELL"] = new(InputChannel.Linkshell1, rotate: RotateMode.Forward),
["CMD_LINKSHELL_REV"] = new(InputChannel.Linkshell1, rotate: RotateMode.Reverse),
["CMD_LINKSHELL_1"] = new(InputChannel.Linkshell1),
["CMD_LINKSHELL_2"] = new(InputChannel.Linkshell2),
["CMD_LINKSHELL_3"] = new(InputChannel.Linkshell3),
["CMD_LINKSHELL_4"] = new(InputChannel.Linkshell4),
["CMD_LINKSHELL_5"] = new(InputChannel.Linkshell5),
["CMD_LINKSHELL_6"] = new(InputChannel.Linkshell6),
["CMD_LINKSHELL_7"] = new(InputChannel.Linkshell7),
["CMD_LINKSHELL_8"] = new(InputChannel.Linkshell8),
["CMD_BEGINNER"] = new(InputChannel.NoviceNetwork),
["CMD_REPLY_ALWAYS"] = new(InputChannel.Tell, true, RotateMode.Forward),
["CMD_REPLY_REV_ALWAYS"] = new(InputChannel.Tell, true, RotateMode.Reverse),
["CMD_SAY_ALWAYS"] = new(InputChannel.Say, true),
["CMD_YELL_ALWAYS"] = new(InputChannel.Yell, true),
["CMD_PARTY_ALWAYS"] = new(InputChannel.Party, true),
["CMD_ALLIANCE_ALWAYS"] = new(InputChannel.Alliance, true),
["CMD_FREECOM_ALWAYS"] = new(InputChannel.FreeCompany, true),
["PVPTEAM_CHAT_ALWAYS"] = new(InputChannel.PvpTeam, true),
["CMD_CWLINKSHELL_ALWAYS"] = new(InputChannel.CrossLinkshell1, true, RotateMode.Forward),
["CMD_CWLINKSHELL_ALWAYS_REV"] = new(InputChannel.CrossLinkshell1, true, RotateMode.Reverse),
["CMD_CWLINKSHELL_1_ALWAYS"] = new(InputChannel.CrossLinkshell1, true),
["CMD_CWLINKSHELL_2_ALWAYS"] = new(InputChannel.CrossLinkshell2, true),
["CMD_CWLINKSHELL_3_ALWAYS"] = new(InputChannel.CrossLinkshell3, true),
["CMD_CWLINKSHELL_4_ALWAYS"] = new(InputChannel.CrossLinkshell4, true),
["CMD_CWLINKSHELL_5_ALWAYS"] = new(InputChannel.CrossLinkshell5, true),
["CMD_CWLINKSHELL_6_ALWAYS"] = new(InputChannel.CrossLinkshell6, true),
["CMD_CWLINKSHELL_7_ALWAYS"] = new(InputChannel.CrossLinkshell7, true),
["CMD_CWLINKSHELL_8_ALWAYS"] = new(InputChannel.CrossLinkshell8, true),
["CMD_LINKSHELL_ALWAYS"] = new(InputChannel.Linkshell1, true, RotateMode.Forward),
["CMD_LINKSHELL_REV_ALWAYS"] = new(InputChannel.Linkshell1, true, RotateMode.Reverse),
["CMD_LINKSHELL_1_ALWAYS"] = new(InputChannel.Linkshell1, true),
["CMD_LINKSHELL_2_ALWAYS"] = new(InputChannel.Linkshell2, true),
["CMD_LINKSHELL_3_ALWAYS"] = new(InputChannel.Linkshell3, true),
["CMD_LINKSHELL_4_ALWAYS"] = new(InputChannel.Linkshell4, true),
["CMD_LINKSHELL_5_ALWAYS"] = new(InputChannel.Linkshell5, true),
["CMD_LINKSHELL_6_ALWAYS"] = new(InputChannel.Linkshell6, true),
["CMD_LINKSHELL_7_ALWAYS"] = new(InputChannel.Linkshell7, true),
["CMD_LINKSHELL_8_ALWAYS"] = new(InputChannel.Linkshell8, true),
["CMD_BEGINNER_ALWAYS"] = new(InputChannel.NoviceNetwork, true)
};
// List of keys that can be used as a part of keybinds while the chat is
// focused WITHOUT modifiers. All other keys can only be used if their
// configured keybind contains modifiers (except only SHIFT). This allows
// for using e.g. F11 to change chat channel while typing.
private static readonly IReadOnlyCollection<VirtualKey> ModifierlessChatKeys = new[]
{
// VirtualKey.NO_KEY,
// VirtualKey.LBUTTON,
// VirtualKey.RBUTTON,
// VirtualKey.CANCEL,
// VirtualKey.MBUTTON,
// VirtualKey.XBUTTON1,
// VirtualKey.XBUTTON2,
// VirtualKey.BACK,
// VirtualKey.TAB, // handled by ChatLogWindow
// VirtualKey.CLEAR,
// VirtualKey.RETURN, // handled by imgui
// VirtualKey.SHIFT,
// VirtualKey.CONTROL,
// VirtualKey.MENU,
VirtualKey.PAUSE,
// VirtualKey.CAPITAL,
// VirtualKey.KANA,
// VirtualKey.HANGUL,
// VirtualKey.JUNJA,
// VirtualKey.FINAL,
// VirtualKey.HANJA,
// VirtualKey.KANJI,
VirtualKey.ESCAPE,
// VirtualKey.CONVERT,
// VirtualKey.NONCONVERT,
// VirtualKey.ACCEPT,
// VirtualKey.MODECHANGE,
// VirtualKey.SPACE,
VirtualKey.PRIOR,
VirtualKey.NEXT,
// VirtualKey.END,
// VirtualKey.HOME,
// VirtualKey.LEFT, // handled by imgui
// VirtualKey.UP, // handled by ChatLogWindow
// VirtualKey.RIGHT, // handled by imgui
// VirtualKey.DOWN, // handled by ChatLogWindow
// VirtualKey.SELECT,
VirtualKey.PRINT,
VirtualKey.EXECUTE,
VirtualKey.SNAPSHOT,
// VirtualKey.INSERT,
// VirtualKey.DELETE,
VirtualKey.HELP,
// VirtualKey.KEY_0,
// VirtualKey.KEY_1,
// VirtualKey.KEY_2,
// VirtualKey.KEY_3,
// VirtualKey.KEY_4,
// VirtualKey.KEY_5,
// VirtualKey.KEY_6,
// VirtualKey.KEY_7,
// VirtualKey.KEY_8,
// VirtualKey.KEY_9,
// VirtualKey.A,
// VirtualKey.B,
// VirtualKey.C,
// VirtualKey.D,
// VirtualKey.E,
// VirtualKey.F,
// VirtualKey.G,
// VirtualKey.H,
// VirtualKey.I,
// VirtualKey.J,
// VirtualKey.K,
// VirtualKey.L,
// VirtualKey.M,
// VirtualKey.N,
// VirtualKey.O,
// VirtualKey.P,
// VirtualKey.Q,
// VirtualKey.R,
// VirtualKey.S,
// VirtualKey.T,
// VirtualKey.U,
// VirtualKey.V,
// VirtualKey.W,
// VirtualKey.X,
// VirtualKey.Y,
// VirtualKey.Z,
// VirtualKey.LWIN,
// VirtualKey.RWIN,
VirtualKey.APPS,
VirtualKey.SLEEP,
// VirtualKey.NUMPAD0,
// VirtualKey.NUMPAD1,
// VirtualKey.NUMPAD2,
// VirtualKey.NUMPAD3,
// VirtualKey.NUMPAD4,
// VirtualKey.NUMPAD5,
// VirtualKey.NUMPAD6,
// VirtualKey.NUMPAD7,
// VirtualKey.NUMPAD8,
// VirtualKey.NUMPAD9,
// VirtualKey.MULTIPLY,
// VirtualKey.ADD,
// VirtualKey.SEPARATOR,
// VirtualKey.SUBTRACT,
// VirtualKey.DECIMAL,
// VirtualKey.DIVIDE,
VirtualKey.F1,
VirtualKey.F2,
VirtualKey.F3,
VirtualKey.F4,
VirtualKey.F5,
VirtualKey.F6,
VirtualKey.F7,
VirtualKey.F8,
VirtualKey.F9,
VirtualKey.F10,
VirtualKey.F11,
VirtualKey.F12,
VirtualKey.F13,
VirtualKey.F14,
VirtualKey.F15,
VirtualKey.F16,
VirtualKey.F17,
VirtualKey.F18,
VirtualKey.F19,
VirtualKey.F20,
VirtualKey.F21,
VirtualKey.F22,
VirtualKey.F23,
VirtualKey.F24,
// VirtualKey.NUMLOCK,
// VirtualKey.SCROLL,
// VirtualKey.OEM_FJ_JISHO,
// VirtualKey.OEM_NEC_EQUAL,
// VirtualKey.OEM_FJ_MASSHOU,
// VirtualKey.OEM_FJ_TOUROKU,
// VirtualKey.OEM_FJ_LOYA,
// VirtualKey.OEM_FJ_ROYA,
// VirtualKey.LSHIFT,
// VirtualKey.RSHIFT,
// VirtualKey.LCONTROL,
// VirtualKey.RCONTROL,
// VirtualKey.LMENU,
// VirtualKey.RMENU,
VirtualKey.BROWSER_BACK,
VirtualKey.BROWSER_FORWARD,
VirtualKey.BROWSER_REFRESH,
VirtualKey.BROWSER_STOP,
VirtualKey.BROWSER_SEARCH,
VirtualKey.BROWSER_FAVORITES,
VirtualKey.BROWSER_HOME,
VirtualKey.VOLUME_MUTE,
VirtualKey.VOLUME_DOWN,
VirtualKey.VOLUME_UP,
VirtualKey.MEDIA_NEXT_TRACK,
VirtualKey.MEDIA_PREV_TRACK,
VirtualKey.MEDIA_STOP,
VirtualKey.MEDIA_PLAY_PAUSE,
VirtualKey.LAUNCH_MAIL,
VirtualKey.LAUNCH_MEDIA_SELECT,
VirtualKey.LAUNCH_APP1,
VirtualKey.LAUNCH_APP2,
// VirtualKey.OEM_1,
// VirtualKey.OEM_PLUS,
// VirtualKey.OEM_COMMA,
// VirtualKey.OEM_MINUS,
// VirtualKey.OEM_PERIOD,
// VirtualKey.OEM_2,
// VirtualKey.OEM_3,
// VirtualKey.OEM_4, // [{
// VirtualKey.OEM_5, // \"
// VirtualKey.OEM_6, // ]}
// VirtualKey.OEM_7, // '"
// VirtualKey.OEM_8,
// VirtualKey.OEM_AX,
// VirtualKey.OEM_102,
// VirtualKey.ICO_HELP,
// VirtualKey.ICO_00,
// VirtualKey.PROCESSKEY,
// VirtualKey.ICO_CLEAR,
// VirtualKey.PACKET,
// VirtualKey.OEM_RESET,
// VirtualKey.OEM_JUMP,
// VirtualKey.OEM_PA1,
// VirtualKey.OEM_PA2,
// VirtualKey.OEM_PA3,
// VirtualKey.OEM_WSCTRL,
// VirtualKey.OEM_CUSEL,
// VirtualKey.OEM_ATTN,
// VirtualKey.OEM_FINISH,
// VirtualKey.OEM_COPY,
// VirtualKey.OEM_AUTO,
// VirtualKey.OEM_ENLW,
// VirtualKey.OEM_BACKTAB,
// VirtualKey.ATTN,
// VirtualKey.CRSEL,
// VirtualKey.EXSEL,
// VirtualKey.EREOF,
// VirtualKey.PLAY,
// VirtualKey.ZOOM,
// VirtualKey.NONAME,
// VirtualKey.PA1,
// VirtualKey.OEM_CLEAR,
};
internal KeybindManager(Plugin plugin)
{
Plugin = plugin;
Plugin.GameInteropProvider.InitializeFromAttributes(this);
// Handle keybinds from the game on every tick.
Plugin.Framework.Update += HandleKeybinds;
}
public void Dispose()
{
Plugin.Framework.Update -= HandleKeybinds;
}
private void UpdateKeybinds()
{
foreach (var name in KeybindsToIntercept.Keys)
Keybinds[name] = GetKeybind(name);
}
private static ModifierFlag GetModifiers(KeyboardSource source)
{
var modifierState = ModifierFlag.None;
if (source == KeyboardSource.Game)
{
if (Plugin.KeyState[VirtualKey.MENU])
modifierState |= ModifierFlag.Alt;
if (Plugin.KeyState[VirtualKey.CONTROL])
modifierState |= ModifierFlag.Ctrl;
if (Plugin.KeyState[VirtualKey.SHIFT])
modifierState |= ModifierFlag.Shift;
return modifierState;
}
if (ImGui.GetIO().KeyAlt)
modifierState |= ModifierFlag.Alt;
if (ImGui.GetIO().KeyCtrl)
modifierState |= ModifierFlag.Ctrl;
if (ImGui.GetIO().KeyShift)
modifierState |= ModifierFlag.Shift;
return modifierState;
}
private static bool KeyPressed(KeyboardSource source, VirtualKey key)
{
if (key == VirtualKey.NO_KEY)
return false;
if (!Plugin.KeyState.IsVirtualKeyValid(key))
return false;
if (source == KeyboardSource.Game)
return Plugin.KeyState[key];
return key.TryToImGui(out var imguiKey) && ImGui.IsKeyPressed(imguiKey);
}
private static bool ComboPressed(KeyboardSource source, VirtualKey key, ModifierFlag modifier, ModifierFlag? modifierState = null, bool modifiersOnly = false)
{
// When we're in an input, we don't want to process any keybinds that
// don't have a modifier (or only use shift) and are not explicitly
// whitelisted.
if (modifiersOnly && !ModifierlessChatKeys.Contains(key) && modifier is ModifierFlag.None or ModifierFlag.Shift)
return false;
modifierState ??= GetModifiers(source);
var modifierPressed = Plugin.Config.KeybindMode switch
{
KeybindMode.Strict => modifier == modifierState.Value,
KeybindMode.Flexible => modifierState.Value.HasFlag(modifier),
_ => false
};
return KeyPressed(source, key) && modifierPressed;
}
private static bool ConfigKeybindPressed(KeyboardSource source, ConfigKeyBind? bind, ModifierFlag? modifierState = null, bool modifiersOnly = false)
{
return bind != null && ComboPressed(source, bind.Key, bind.Modifier, modifierState: modifierState, modifiersOnly: modifiersOnly);
}
private void HandleKeybinds(IFramework _ ) => HandleKeybinds(KeyboardSource.Game);
internal void HandleKeybinds(KeyboardSource source, bool ignoreChatOpen = false, bool modifiersOnly = false)
{
// Refresh current keybinds every 5s
if (LastRefresh + 5 * 1000 < Environment.TickCount64)
{
UpdateKeybinds();
DirectChat = Plugin.GameConfig.TryGet(UiControlOption.DirectChat, out bool option) && option;
LastRefresh = Environment.TickCount64;
}
// Vanilla text input has focus
if (RaptureAtkModule.Instance()->AtkModule.IsTextInputActive())
{
VanillaTextInputHasFocus = true;
return;
}
// If the vanilla text input has just lost focus, clear all non-modifier
// keys so we don't try to process them immediately on the next frame.
if (VanillaTextInputHasFocus)
{
foreach (var key in Plugin.KeyState.GetValidVirtualKeys())
if (key is not VirtualKey.CONTROL and not VirtualKey.SHIFT and not VirtualKey.MENU)
Plugin.KeyState[key] = false;
VanillaTextInputHasFocus = false;
return;
}
var modifierState = GetModifiers(source);
// Test for custom keybinds for changing chat tabs before checking
// vanilla keybinds.
if (ConfigKeybindPressed(source, Plugin.Config.ChatTabForward))
{
Plugin.KeyState[Plugin.Config.ChatTabForward!.Key] = false;
DispatchTabDelta(1);
return;
}
if (ConfigKeybindPressed(source, Plugin.Config.ChatTabBackward))
{
Plugin.KeyState[Plugin.Config.ChatTabBackward!.Key] = false;
DispatchTabDelta(-1);
return;
}
// Only process the active combo with the most modifiers.
var currentBest = (VirtualKey.NO_KEY, "", 0);
foreach (var (toIntercept, keybind) in Keybinds)
{
if (toIntercept is "CMD_CHAT" or "CMD_COMMAND" && (ignoreChatOpen || DirectChat))
continue;
void Intercept(VirtualKey vk, ModifierFlag modifier)
{
if (!ComboPressed(source, vk, modifier, modifierState: modifierState, modifiersOnly: modifiersOnly))
return;
var bits = BitOperations.PopCount((uint) modifier);
if (bits < currentBest.Item3)
return;
currentBest = (vk, toIntercept, bits);
}
Intercept(keybind.Key1, keybind.Modifier1);
Intercept(keybind.Key2, keybind.Modifier2);
}
if (currentBest.Item1 == VirtualKey.NO_KEY)
return;
Plugin.KeyState[currentBest.Item1] = false;
if (!KeybindsToIntercept.TryGetValue(currentBest.Item2, out var info))
return;
try
{
TellReason? reason = info.Channel == InputChannel.Tell ? TellReason.Reply : null;
Plugin.ChatLogWindow.Activated(new ChatActivatedArgs(info) { TellReason = reason, });
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Error in chat Activated event");
}
}
// v0.6.0 — central dispatch for ChatTabForward/Backward. If a pop-out
// window currently has its compact input focused, the keybind is
// forwarded into that pop-out's ChatInputBar so the user navigates
// tabs in the window they are typing in. Otherwise the main window
// handles it (= v0.5.x behavior).
private void DispatchTabDelta(int delta)
{
foreach (var popout in Plugin.ChatLogWindow.ActivePopouts)
{
if (popout.HasFocusedInputBar && popout.InputBar != null)
{
popout.InputBar.HandleKeybindForward(delta);
return;
}
}
Plugin.ChatLogWindow.ChangeTabDelta(delta);
}
private static Keybind GetKeybind(string id)
{
var outData = new FFXIVClientStructs.FFXIV.Client.System.Input.Keybind();
var idString = Utf8String.FromString(id);
UIInputData.Instance()->GetKeybindByName(idString, &outData);
idString->Dtor(true);
var key1 = outData.KeySettings[0];
var key2 = outData.KeySettings[1];
return new Keybind
{
Key1 = RemapInvalidVirtualKey((VirtualKey) key1.Key),
Modifier1 = (ModifierFlag) key1.KeyModifier,
Key2 = RemapInvalidVirtualKey((VirtualKey) key2.Key),
Modifier2 = (ModifierFlag) key2.KeyModifier,
};
}
private static VirtualKey RemapInvalidVirtualKey(VirtualKey key)
{
return key switch
{
VirtualKey.F23 => VirtualKey.OEM_2, // /?
(VirtualKey) 140 => VirtualKey.OEM_7, // '"
_ => key
};
}
}
+58
View File
@@ -0,0 +1,58 @@
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud.Interface.ImGuiNotification;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Client.UI.Info;
namespace HellionChat.GameFunctions;
internal static unsafe class Party
{
internal static void InviteSameWorld(string name, ushort world, ulong contentId)
{
// this only works if target is on the same world
fixed (byte* namePtr = name.ToTerminatedBytes()) {
InfoProxyPartyInvite.Instance()->InviteToParty(contentId, namePtr, world);
}
}
internal static void InviteOtherWorld(ulong contentId, ushort worldId = 0)
{
// third param is world, but it requires a specific world
// if they're not on that world, it will fail
// pass 0 and it will work on any world EXCEPT for the world the
// current player is on
if (contentId == 0)
{
WrapperUtil.AddNotification(Language.PartyInvite_NoId, NotificationType.Warning);
return;
}
InfoProxyPartyInvite.Instance()->InviteToPartyContentId(contentId, worldId);
}
internal static void InviteInInstance(ulong contentId)
{
if (contentId == 0)
{
WrapperUtil.AddNotification(Language.PartyInvite_NoId, NotificationType.Warning);
return;
}
InfoProxyPartyInvite.Instance()->InviteToPartyInInstanceByContentId(contentId);
}
internal static void Kick(string name, ulong contentId)
{
fixed (byte* namePtr = name.ToTerminatedBytes()) {
AgentPartyMember.Instance()->Kick(namePtr, 0, contentId);
}
}
internal static void Promote(string name, ulong contentId)
{
fixed (byte* namePtr = name.ToTerminatedBytes()) {
AgentPartyMember.Instance()->Promote(namePtr, 0, contentId);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
using HellionChat.Code;
namespace HellionChat.GameFunctions.Types;
internal class ChannelSwitchInfo {
internal InputChannel? Channel { get; }
internal bool Permanent { get; }
internal RotateMode Rotate { get; }
internal string? Text { get; }
internal ChannelSwitchInfo(InputChannel? channel, bool permanent = false, RotateMode rotate = RotateMode.None, string? text = null)
{
Channel = channel;
Permanent = permanent;
Rotate = rotate;
Text = text;
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace HellionChat.GameFunctions.Types;
internal sealed class ChatActivatedArgs
{
internal string? AddIfNotPresent { get; init; }
internal string? Input { get; init; }
internal ChannelSwitchInfo ChannelSwitchInfo { get; }
internal TellReason? TellReason { get; init; }
internal TellTarget? TellTarget { get; init; }
internal bool TellSpecial { get; init; } // specific to Eureka/Bozja/Zadnor
internal ChatActivatedArgs(ChannelSwitchInfo channelSwitchInfo)
{
ChannelSwitchInfo = channelSwitchInfo;
}
}
+12
View File
@@ -0,0 +1,12 @@
using Dalamud.Game.ClientState.Keys;
namespace HellionChat.GameFunctions.Types;
internal class Keybind
{
internal VirtualKey Key1 { get; init; }
internal ModifierFlag Modifier1 { get; init; }
internal VirtualKey Key2 { get; init; }
internal ModifierFlag Modifier2 { get; init; }
}
+10
View File
@@ -0,0 +1,10 @@
namespace HellionChat.GameFunctions.Types;
[Flags]
public enum ModifierFlag
{
None = 0,
Shift = 1 << 0,
Ctrl = 1 << 1,
Alt = 1 << 2,
}
+8
View File
@@ -0,0 +1,8 @@
namespace HellionChat.GameFunctions.Types;
internal enum RotateMode
{
None,
Forward,
Reverse,
}
+15
View File
@@ -0,0 +1,15 @@
namespace HellionChat.GameFunctions.Types;
internal sealed class TellHistoryInfo
{
internal string Name { get; }
internal uint World { get; }
internal ulong ContentId { get; }
internal TellHistoryInfo(string name, uint world, ulong contentId)
{
Name = name;
World = world;
ContentId = contentId;
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace HellionChat.GameFunctions.Types;
public enum TellReason
{
Direct = 0,
PartyFinder = 1,
Reply = 2,
Friend = 3,
}
+40
View File
@@ -0,0 +1,40 @@
using Dalamud.Game.ClientState.Objects.SubKinds;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
namespace HellionChat.GameFunctions.Types;
[Serializable]
public class TellTarget
{
public string Name { get; set; }
public uint World { get; set; }
public ulong ContentId { get; private set; }
public TellReason Reason { get; private set; }
public TellTarget(string name, uint world, ulong contentId, TellReason reason)
{
Name = name;
World = world;
ContentId = contentId;
Reason = reason;
}
public bool IsSet()
=> Name.Length > 0 && World > 0;
public string ToWorldString()
=> Sheets.WorldSheet.TryGetRow(World, out var worldRow) ? worldRow.Name.ToString() : string.Empty;
public string ToTargetString()
=> $"{Name}@{ToWorldString()}";
public unsafe void FromTarget(IPlayerCharacter target)
{
Name = target.Name.TextValue;
World = target.HomeWorld.RowId;
ContentId = ((Character*)target.Address)->ContentId;
}
public static TellTarget Empty() => new(string.Empty, 0, 0, TellReason.Direct);
public static TellTarget From(TellTarget t) => new(t.Name, t.World, t.ContentId, t.Reason);
}
+73
View File
@@ -0,0 +1,73 @@
<Project Sdk="Dalamud.NET.Sdk/15.0.0">
<PropertyGroup>
<!-- Hellion Chat versioning runs separately from upstream Chat 2.
0.1.0 is our bootstrap release; the underlying Chat 2 base is
called out in the yaml changelog so users can see what it
derives from. -->
<Version>0.6.1</Version>
<ImplicitUsings>enable</ImplicitUsings>
<!-- v1.0.0 standalone cut — both AssemblyName and RootNamespace
are HellionChat. The plugin no longer maintains source-level
cherry-pick compatibility with upstream Infiziert90/ChatTwo;
upstream changes are integrated manually if at all. -->
<AssemblyName>HellionChat</AssemblyName>
<RootNamespace>HellionChat</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
<PackageReference Include="morelinq" Version="4.4.0" />
<PackageReference Include="Pidgin" Version="3.3.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<ItemGroup>
<Compile Update="Resources\Language.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Language.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Resources\Language.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Language.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<!-- HellionChat — Hellion-specific resource bundle (HellionStrings.resx
+ HellionStrings.<lang>.resx) is picked up automatically by the SDK
default include. Designer.cs is hand-maintained, no auto-gen needed. -->
<!-- Bundled Hellion font (Exo 2, OFL-1.1). Embedded as a manifest
resource with a fixed LogicalName so FontManager can pull the
bytes back at runtime via AddFontFromMemory. The OFL license
text travels with it inside the assembly to satisfy the
"license must be distributed with the font" clause. -->
<ItemGroup>
<EmbeddedResource Include="Resources\HellionFont.ttf">
<LogicalName>HellionFont.ttf</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Resources\HellionFont-OFL.txt">
<LogicalName>HellionFont-OFL.txt</LogicalName>
</EmbeddedResource>
</ItemGroup>
<!-- Plugin icon. Copy images/* into the build output so Dalamud
finds the icon next to the DLL, and let the SDK default
DalamudPackager pipeline include the same path in the
release ZIP. Earlier we shipped a custom DalamudPackager
targets override that explicitly set HandleImages and
ImagesPath; that override conflicted with the SDK 15
default and the resulting manifest carried no IconUrl.
Removed in v0.5.2. -->
<ItemGroup>
<None Include="images\**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+144
View File
@@ -0,0 +1,144 @@
name: Hellion Chat
author: JonKazama-Hellion
punchline: Chat replacement with privacy controls aligned to EU, US and JP rules — based on Chat 2 (EUPL-1.2)
description: |-
Hellion Chat is a privacy-focused chat replacement for FINAL FANTASY XIV
based on the Chat 2 codebase (EUPL-1.2). One feature is intentionally
removed (the optional webinterface) and a stack of privacy controls is
added on top. Tabs, channel filters, RGB colours, emotes, screenshot
mode, IPC integration and the chat replacement window itself work the
same. The webinterface is intentionally not part of Hellion Chat because
it serves a different use case from the smaller default footprint this
plugin is built around.
On top of that, Hellion Chat adds privacy and data-handling controls
designed to align with the modern data protection rules that apply
across the EU, the United States and Japan. By default only your own
conversations are stored; messages from strangers, NPCs and system
spam stay out of the database. Retention windows are configurable per
channel, history can be wiped retroactively, and stored data can be
exported on demand.
Key privacy and data-handling features:
- Channel whitelist with a Privacy-First default
- Per-channel retention with a daily background sweep
- Retroactive cleanup with a Ctrl+Shift confirm
- Export to Markdown, JSON or CSV
- First-run wizard with three preset profiles (Privacy-First, Casual,
Full History)
- Bilingual UI (English and German) with live language switching
- Independent plugin state — own config file and database directory,
so Hellion Chat does not share state with upstream Chat 2
Based on Chat 2 by Infi and Anna, licensed under EUPL-1.2.
Modding & support: join the Hellion Forge Discord at
https://discord.gg/X9V7Kcv5gR — community for Hellion Chat and
other Hellion Online Media plugins/tools.
repo_url: https://github.com/JonKazama-Hellion/HellionChat
accepts_feedback: true
icon_url: https://raw.githubusercontent.com/JonKazama-Hellion/HellionChat/main/HellionChat/images/icon.png
image_urls:
- https://raw.githubusercontent.com/JonKazama-Hellion/HellionChat/main/HellionChat/images/chatWindow.png
- https://raw.githubusercontent.com/JonKazama-Hellion/HellionChat/main/HellionChat/images/withSimpleTweaks.png
tags:
- Social
- UI
- Chat
- Replacement
- Privacy
changelog: |-
**Hellion Chat 0.6.1 — Pop-Out Discoverability & /tell Auto-Pop-Out**
- Pop-out button now visible in the chat header (no more hunting
through the right-click menu)
- One-time hint banner explains pop-out tabs and the right-click
shortcut
- New setting: open new /tell tabs directly as pop-out windows
(Settings → Chat → Auto-Tell-Tabs)
- Pop-out input is now enabled by default — closing a pop-out still
returns the tab to the sidebar
- Bugfix: dropping or logging out with an LRU/popped auto-tell tab
now also closes its pop-out window (no more ghost windows)
- Bugfix: dead zone below the chat input bar when the v0.6.0 pop-out
hint banner was visible (also fixed retroactively for the v0.6.0
banner inside pop-outs)
Modding & support: join Hellion Forge — https://discord.gg/X9V7Kcv5gR
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
**Hellion Chat 0.6.0 — UX Polish: Pop-Out Input + Colour Presets**
Two opt-in UX features land in the same release. Existing users see
no change unless they enable the new toggles.
Pop-out input bar:
- New global master switch in Settings → Window → Frame: "Enable input
in pop-outs". Default OFF so existing behaviour is preserved
- When enabled, every pop-out window grows a compact input bar at the
bottom (channel-coloured icon button left, text input right). The
auto-translate picker is intentionally not part of the compact bar
in v0.6.0 — typical pop-out workflows (FC greeter, club hostess)
rarely need it there
- Each pop-out keeps an independent text buffer and history cursor;
channel changes still apply globally because that is how the FFXIV
channel API works
- Up/Down navigates a shared input history singleton across the main
window and every open pop-out
- First pop-out opening after the upgrade shows a one-time hint
banner pointing users to the new toggle
Chat colour presets:
- Seven built-in presets above the per-channel colour list in
Settings → Appearance → Colours: ChatTwo Default, High-Contrast,
Pastell, Dark-Mode-Tuned, Hellion (brand-coloured, blue/orange
Arctic Cyan + Ember Glow palette from the Hellion Online Media
branding spec), plus two bonus mood presets — Night Blue (royal
blue, classic-cool) and Indigo Violet (royal violet, glitter-mystic)
- Apply is immediate and overwrites the channels covered by the
preset; battle-channel colours are left alone so combat tuning
stays intact
Configuration migrates from v10 to v11 with a diagnostic log entry;
no data is reset. Bilingual (English/German) for both new sections.
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
**Hellion Chat 0.5.4 — WrapText hardening**
Replaces the unsafe pointer-arithmetic in ImGuiUtil.WrapText with
Span- and index-based control flow. Closes the persistent CodeQL
Critical alert "unvalidated local pointer arithmetic" that kept
re-firing on every shape of the previous fix.
Hardening:
- WrapText now allocates a buffer sized by Encoding.UTF8.GetMaxByteCount
via ArrayPool, validates the actual encoded length against that
ceiling, and threads the rest of the algorithm through int offsets
instead of raw byte pointers
- Pointer arithmetic only happens inside two small private helpers
(CalcWordWrap and DrawText) that take the pinned base pointer plus
int offsets sourced from the plugin's own logic, not from any
virtual-method return
- Added a 16 KiB upper bound on the buffer rent to prevent a
pathological input from triggering an unbounded ArrayPool allocation
No user-visible behaviour change. Word-wrap output is byte-identical
to v0.5.3.
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
**Hellion Chat 0.5.3 — Pointer arithmetic hardening**
Closed CodeQL Critical alert in ImGuiUtil.WrapText by validating the
encoded byte buffer length via GetByteCount before pointer
arithmetic. Single-fix patch on top of v0.5.2.
---
Earlier history: https://github.com/JonKazama-Hellion/HellionChat/releases
+51
View File
@@ -0,0 +1,51 @@
using System.Collections.Generic;
namespace HellionChat;
// Hellion Chat — v0.6.0 shared input history. Replaces the embedded
// ChatLogWindow.InputBacklog so that pop-out windows with their own
// ChatInputBar can navigate the same Up/Down history as the main window.
// Index semantics are kept identical to the v0.5.x InputBacklog:
// index 0 = oldest entry
// index Count - 1 = newest entry
// Push performs move-to-newest deduplication: existing entries are
// removed before the new one is appended at the end.
public static class InputHistoryService
{
private const int MaxSize = 30;
private static readonly List<string> _entries = new();
public static IReadOnlyList<string> Entries => _entries;
public static int Count => _entries.Count;
public static void Push(string entry)
{
if (string.IsNullOrWhiteSpace(entry))
return;
var trimmed = entry.Trim();
// Move-to-newest: existing entries are removed before the append
// so the same line typed twice does not occupy two history slots.
for (var i = 0; i < _entries.Count; i++)
{
if (_entries[i] == trimmed)
{
_entries.RemoveAt(i);
break;
}
}
_entries.Add(trimmed);
if (_entries.Count > MaxSize)
_entries.RemoveAt(0);
}
public static string? GetByCursor(int cursor)
{
if (cursor < 0 || cursor >= _entries.Count)
return null;
return _entries[cursor];
}
}
+74
View File
@@ -0,0 +1,74 @@
using Dalamud.Plugin.Ipc;
namespace HellionChat.Ipc;
public sealed class ExtraChat : IDisposable
{
#pragma warning disable CS0649 // Assigned through IPC
[Serializable]
private struct OverrideInfo
{
public string? Channel;
public ushort UiColour;
public uint Rgba;
}
#pragma warning restore CS0649
private ICallGateSubscriber<OverrideInfo, object> OverrideChannelGate { get; }
private ICallGateSubscriber<Dictionary<string, uint>, Dictionary<string, uint>> ChannelCommandColoursGate { get; }
private ICallGateSubscriber<Dictionary<Guid, string>, Dictionary<Guid, string>> ChannelNamesGate { get; }
internal (string, uint)? ChannelOverride { get; set; }
private Dictionary<string, uint> ChannelCommandColoursInternal { get; set; } = new();
internal IReadOnlyDictionary<string, uint> ChannelCommandColours => ChannelCommandColoursInternal;
private Dictionary<Guid, string> ChannelNamesInternal { get; set; } = new();
internal IReadOnlyDictionary<Guid, string> ChannelNames => ChannelNamesInternal;
internal ExtraChat()
{
OverrideChannelGate = Plugin.Interface.GetIpcSubscriber<OverrideInfo, object>("ExtraChat.OverrideChannelColour");
ChannelCommandColoursGate = Plugin.Interface.GetIpcSubscriber<Dictionary<string, uint>, Dictionary<string, uint>>("ExtraChat.ChannelCommandColours");
ChannelNamesGate = Plugin.Interface.GetIpcSubscriber<Dictionary<Guid, string>, Dictionary<Guid, string>>("ExtraChat.ChannelNames");
OverrideChannelGate.Subscribe(OnOverrideChannel);
ChannelCommandColoursGate.Subscribe(OnChannelCommandColours);
ChannelNamesGate.Subscribe(OnChannelNames);
try
{
ChannelCommandColoursInternal = ChannelCommandColoursGate.InvokeFunc(null!);
ChannelNamesInternal = ChannelNamesGate.InvokeFunc(null!);
}
catch (Exception)
{
// no-op
}
}
public void Dispose()
{
OverrideChannelGate.Unsubscribe(OnOverrideChannel);
}
private void OnOverrideChannel(OverrideInfo info)
{
if (info.Channel == null)
{
ChannelOverride = null;
return;
}
ChannelOverride = (info.Channel, info.Rgba);
}
private void OnChannelCommandColours(Dictionary<string, uint> obj)
{
ChannelCommandColoursInternal = obj;
}
private void OnChannelNames(Dictionary<Guid, string> obj)
{
ChannelNamesInternal = obj;
}
}
+62
View File
@@ -0,0 +1,62 @@
using HellionChat.Code;
using Dalamud.Plugin.Ipc;
namespace HellionChat.Ipc;
using ChatInputState = (bool InputVisible, bool InputFocused, bool HasText, bool IsTyping, int TextLength, ChatType ChannelType);
internal sealed class TypingIpc : IDisposable
{
private Plugin Plugin { get; }
private ICallGateProvider<ChatInputState> StateQueryGate { get; }
private ICallGateProvider<ChatInputState, object?> StateChangedGate { get; }
private ChatInputState LastState;
private bool HasState;
internal TypingIpc(Plugin plugin)
{
Plugin = plugin;
StateQueryGate = Plugin.Interface.GetIpcProvider<ChatInputState>("HellionChat.GetChatInputState");
StateChangedGate = Plugin.Interface.GetIpcProvider<ChatInputState, object?>("HellionChat.ChatInputStateChanged");
StateQueryGate.RegisterFunc(GetState);
}
private ChatInputState BuildState()
{
var log = Plugin.ChatLogWindow;
var usedChannel = Plugin.CurrentTab.CurrentChannel;
var inputChannel = usedChannel.UseTempChannel ? usedChannel.TempChannel : usedChannel.Channel;
var channelType = inputChannel.ToChatType();
return (InputVisible: !log.IsHidden,
log.InputFocused,
HasText: log.Chat.Length > 0,
IsTyping: log is { InputFocused: true, Chat.Length: > 0 },
TextLength: log.Chat.Length,
ChannelType: channelType);
}
private ChatInputState GetState()
=> BuildState();
internal void Update()
{
var state = BuildState();
if (HasState && state.Equals(LastState))
return;
HasState = true;
LastState = state;
StateChangedGate.SendMessage(state);
}
public void Dispose()
{
StateQueryGate.UnregisterFunc();
}
}
+54
View File
@@ -0,0 +1,54 @@
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Plugin.Ipc;
namespace HellionChat;
internal sealed class IpcManager : IDisposable
{
private ICallGateProvider<string> RegisterGate { get; }
private ICallGateProvider<string, object?> UnregisterGate { get; }
private ICallGateProvider<object?> AvailableGate { get; }
private ICallGateProvider<string, PlayerPayload?, ulong, Payload?, SeString?, SeString?, object?> InvokeGate { get; }
internal List<string> Registered { get; } = [];
public IpcManager()
{
RegisterGate = Plugin.Interface.GetIpcProvider<string>("HellionChat.Register");
RegisterGate.RegisterFunc(Register);
AvailableGate = Plugin.Interface.GetIpcProvider<object?>("HellionChat.Available");
UnregisterGate = Plugin.Interface.GetIpcProvider<string, object?>("HellionChat.Unregister");
UnregisterGate.RegisterAction(Unregister);
InvokeGate = Plugin.Interface.GetIpcProvider<string, PlayerPayload?, ulong, Payload?, SeString?, SeString?, object?>("HellionChat.Invoke");
AvailableGate.SendMessage();
}
internal void Invoke(string id, PlayerPayload? sender, ulong contentId, Payload? payload, SeString? senderString, SeString? content)
{
InvokeGate.SendMessage(id, sender, contentId, payload, senderString, content);
}
private string Register()
{
var id = Guid.NewGuid().ToString();
Registered.Add(id);
return id;
}
private void Unregister(string id)
{
Registered.Remove(id);
}
public void Dispose()
{
UnregisterGate.UnregisterFunc();
RegisterGate.UnregisterFunc();
Registered.Clear();
}
}
+342
View File
@@ -0,0 +1,342 @@
using System.Text;
using HellionChat.Code;
using HellionChat.Util;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using System.Text.RegularExpressions;
using Dalamud.Game.Text;
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
namespace HellionChat;
public partial class Message
{
public Guid Id { get; } = Guid.NewGuid();
public ulong Receiver { get; }
public ulong ContentId { get; set; }
public ulong AccountId { get; set; } // 0 if not set
public DateTimeOffset Date { get; }
public ChatCode Code { get; }
public List<Chunk> Sender { get; }
public List<Chunk> Content { get; private set; }
public SeString SenderSource { get; }
public SeString ContentSource { get; }
public int SortCodeV2 { get; }
public Guid ExtraChatChannel { get; }
// Not stored in the database:
public int Hash { get; }
public Dictionary<Guid, float?> Height { get; } = new();
public Dictionary<Guid, bool> IsVisible { get; } = new();
public Message(ulong receiver, ulong contentId, ulong accountId, ChatCode code, List<Chunk> sender, List<Chunk> content, SeString senderSource, SeString contentSource)
{
var extraChatChannel = ExtractExtraChatChannel(contentSource);
Receiver = receiver;
ContentId = contentId;
AccountId = accountId;
Date = DateTimeOffset.UtcNow;
Code = code;
Sender = sender;
Content = CheckMessageContent(content, extraChatChannel);
SenderSource = senderSource;
ContentSource = contentSource;
SortCodeV2 = Code.ToSortCodeV2();
ExtraChatChannel = extraChatChannel;
Hash = GenerateHash();
foreach (var chunk in sender.Concat(content))
chunk.Message = this;
}
public Message(Guid id, ulong receiver, ulong contentId, DateTimeOffset date, ChatCode code, List<Chunk> sender, List<Chunk> content, SeString senderSource, SeString contentSource, Guid extraChatChannel)
{
Id = id;
Receiver = receiver;
ContentId = contentId;
Date = date;
Code = code;
Sender = sender;
// Don't call ReplaceContentURLs here since we're loading the message
// from the database, and it should already have parsed URL data.
Content = content;
SenderSource = senderSource;
ContentSource = contentSource;
SortCodeV2 = code.ToSortCodeV2();
ExtraChatChannel = extraChatChannel;
Hash = GenerateHash();
foreach (var chunk in sender.Concat(content))
chunk.Message = this;
}
public static Message FakeMessage(List<Chunk> content, ChatCode code)
{
return new Message(0, 0, 0, code, [], content, new SeString(), new SeString());
}
public bool Matches(Dictionary<ChatType, (ChatSource Source, ChatSource Target)> channels, bool allExtraChatChannels, HashSet<Guid> extraChatChannels)
{
if (ExtraChatChannel != Guid.Empty)
return allExtraChatChannels || extraChatChannels.Contains(ExtraChatChannel);
var source = (ChatSource)(1 << (int)Code.Source);
var target = (ChatSource)(1 << (int)Code.Target);
return Code.Type.IsGm()
|| channels.TryGetValue(Code.Type, out var sources)
&& (Code.Source is 0 || sources.Source.HasFlag(source) || sources.Target.HasFlag(target));
}
private int GenerateHash()
{
var hash = SortCodeV2.GetHashCode()
^ ExtraChatChannel.GetHashCode()
^ string.Join("", Sender.Select(c => c.StringValue())).GetHashCode()
^ string.Join("", Content.Select(c => c.StringValue())).GetHashCode();
if (Plugin.Config.CollapseKeepUniqueLinks)
{
// Hash the link too for something like DeathRecap where the message is the same
// but the link is different
hash ^= string.Join("", Content.Select(c => c.Link?.GetHashCode())).GetHashCode();
}
return hash;
}
private static Guid ExtractExtraChatChannel(SeString contentSource)
{
if (contentSource.Payloads.Count > 0 && contentSource.Payloads[0] is RawPayload raw)
{
// this does an encode and clone every time it's accessed, so cache
var data = raw.Data;
try
{
if (data[1] == 0x27 && data[2] == 18 && data[3] == 0x20)
return new Guid(data[4..^1]);
}
catch (ArgumentException ex)
{
Plugin.Log.Error(ex, "Failed to parse extra chat channel GUID");
Plugin.Log.Error($"Byte Array: ${string.Join(", ", data[4..^1])}");
return Guid.Empty;
}
}
return Guid.Empty;
}
private List<Chunk> CheckMessageContent(List<Chunk> oldChunks, Guid extraChatChannel)
{
var newChunks = new List<Chunk>();
void AddChunkWithMessage(TextChunk chunk)
{
if (string.IsNullOrEmpty(chunk.Content))
return;
chunk.Message = this;
newChunks.Add(chunk);
}
var nextIsAutoTranslate = false;
var checkForEmotes = (Code.IsPlayerMessage() || extraChatChannel != Guid.Empty) && Plugin.Config.ShowEmotes;
foreach (var chunk in oldChunks)
{
// Use as is if it's not a text chunk, it already has a payload, or is auto translate
if (chunk is not TextChunk text || chunk.Link != null || nextIsAutoTranslate)
{
nextIsAutoTranslate = chunk is IconChunk { Icon: BitmapFontIcon.AutoTranslateBegin };
// No need to call AddChunkWithMessage here since the chunk
// already has the Message field set.
newChunks.Add(chunk);
continue;
}
var wordBuilder = new StringBuilder();
var sentenceBuilder = new StringBuilder();
foreach (var token in Tokenizer.PrecedenceBasedRegexTokenizer.Tokenize(text.Content))
{
if (token.TokenType == Tokenizer.TokenType.StringValue)
{
wordBuilder.Append(token.Value);
continue;
}
var word = wordBuilder.ToString();
wordBuilder.Clear();
var wordUsed = false;
var tokenUsed = false;
if (checkForEmotes && EmoteCache.Exists(word) && !Plugin.Config.BlockedEmotes.Contains(word))
{
// Add the previous sentence before adding the emote
AddChunkWithMessage(text.NewWithStyle(chunk, sentenceBuilder.ToString()));
AddChunkWithMessage(new TextChunk(chunk.Source, EmotePayload.ResolveEmote(word), word) { FallbackColour = text.FallbackColour });
wordUsed = true;
sentenceBuilder.Clear();
}
if (token.TokenType == Tokenizer.TokenType.UrlString)
{
// Add the previous sentence before adding the url
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, sentenceBuilder.Append(!wordUsed ? word : "").ToString()));
try
{
AddChunkWithMessage(text.NewWithStyle(chunk.Source, UriPayload.ResolveUri(token.Value), token.Value));
}
catch (UriFormatException)
{
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, token.Value));
Plugin.Log.Debug($"Invalid URL accepted by Regex but failed URI parsing: '{token.Value}'");
}
wordUsed = true;
tokenUsed = true;
sentenceBuilder.Clear();
}
// Append match if we haven't reached end of string yet
if (token.TokenType != Tokenizer.TokenType.SequenceTerminator)
{
sentenceBuilder.Append(!wordUsed ? word : "");
sentenceBuilder.Append(!tokenUsed ? token.Value : "");
continue;
}
// End of string reached, we add our leftover
AddChunkWithMessage(text.NewWithStyle(chunk, sentenceBuilder.Append(!wordUsed ? word : "").ToString()));
}
}
return newChunks;
}
public unsafe void DecodeTextParam()
{
var newChunks = new List<Chunk>();
void AddChunkWithMessage(TextChunk chunk)
{
if (string.IsNullOrEmpty(chunk.Content))
return;
chunk.Message = this;
newChunks.Add(chunk);
}
foreach (var chunk in Content)
{
// Use as is if it's not a text chunk or it already has a payload.
if (chunk is not TextChunk text || chunk.Link != null)
{
// No need to call AddChunkWithMessage here since the chunk
// already has the Message field set.
newChunks.Add(chunk);
continue;
}
var splits = TextParamRegex().Split(text.Content);
if (splits.Length == 1)
{
newChunks.Add(chunk);
continue;
}
var nextIsMatch = false;
foreach (var split in splits)
{
if (split == "" || !nextIsMatch)
{
nextIsMatch = true;
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, split));
continue;
}
nextIsMatch = false;
try
{
if (split == "<item>")
{
var agentChat = AgentChatLog.Instance();
var item = agentChat->LinkedItem;
if (item.ItemId == 0)
{
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, split));
continue;
}
var kind = item.ItemId switch
{
< 500_000 => ItemKind.Normal,
< 1_000_000 => ItemKind.Collectible,
< 2_000_000 => ItemKind.Hq,
_ => ItemKind.EventItem
};
var name = kind != ItemKind.EventItem
? Sheets.ItemSheet.GetRow(item.ItemId).Name.ToString()
: Sheets.EventItemSheet.GetRow(item.ItemId).Name.ToString();
var link = new ItemPayload(item.ItemId, kind, $"{SeIconChar.LinkMarker.ToIconChar()}{name}");
AddChunkWithMessage(text.NewWithStyle(chunk.Source, link, link.DisplayName ?? "Unknown"));
}
else if (split == "<status>")
{
var statusId = AgentChatLog.Instance()->ContextStatusId;
if (statusId == 0 || !Sheets.StatusSheet.TryGetRow(statusId, out var statusRow))
{
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, split));
continue;
}
var nameValue = statusRow.Name.ToString();
var content = statusRow.StatusCategory switch
{
1 => $"{SeIconChar.Buff.ToIconString()}{nameValue}",
2 => $"{SeIconChar.Debuff.ToIconString()}{nameValue}",
_ => nameValue
};
var link = new StatusPayload(statusId);
AddChunkWithMessage(text.NewWithStyle(chunk.Source, link, content));
}
else if (split == "<flag>")
{
var agentMap = AgentMap.Instance();
if (agentMap->FlagMarkerCount == 0)
{
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, split));
continue;
}
var mapCoords = agentMap->FlagMapMarkers[0];
var rawX = (int)(MathF.Round(mapCoords.XFloat, 3, MidpointRounding.AwayFromZero) * 1000);
var rawY = (int)(MathF.Round(mapCoords.YFloat, 3, MidpointRounding.AwayFromZero) * 1000);
var link = new MapLinkPayload(mapCoords.TerritoryId, mapCoords.MapId, rawX, rawY);
AddChunkWithMessage(text.NewWithStyle(chunk.Source, link, $"{SeIconChar.LinkMarker.ToIconChar()}{link.PlaceName} {link.CoordinateString}"));
}
}
catch (Exception)
{
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, split));
Plugin.Log.Debug($"Failed to parse the text param: '{split}'");
}
}
}
Content = newChunks;
}
[GeneratedRegex("(<item>|<flag>|<status>)")]
private static partial Regex TextParamRegex();
}
+350
View File
@@ -0,0 +1,350 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text;
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud.Game.Chat;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Hooking;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using Lumina.Text.Expressions;
using Lumina.Text.Payloads;
using Lumina.Text.ReadOnly;
namespace HellionChat;
internal class MessageManager : IAsyncDisposable
{
internal const int MessageDisplayLimit = 10_000;
private Plugin Plugin { get; }
internal MessageStore Store { get; }
private Dictionary<ChatType, NameFormatting> Formats { get; } = [];
private ulong LastContentId { get; set; }
// Messages go into the PendingSync queue first, which will be consumed one
// at a time in the main thread. This is to delay the async processing until
// after we've received the content ID from the ContentIdResolver hook.
//
// After that, the message is enqueued in the PendingAsync queue, which will
// be consumed in a separate thread and perform more processing (emotes,
// URLs) as well as inserting the message into the database.
private Queue<PendingMessage> PendingSync { get; } = [];
private ConcurrentQueue<PendingMessage> PendingAsync { get; } = [];
private readonly Thread PendingMessageThread;
private readonly CancellationTokenSource PendingThreadCancellationToken = new();
private Hook<RaptureLogModule.Delegates.AddMsgSourceEntry>? ContentIdResolverHook { get; init; }
internal ulong CurrentContentId
{
get
{
var contentId = Plugin.PlayerState.ContentId;
return contentId == 0 ? LastContentId : contentId;
}
}
// Hellion Chat — Auto-Tell-Tabs hook. Fires after a fully processed
// message has been routed to all matching persistent tabs and stored
// in the database. The AutoTellTabsService subscribes to spawn or
// refresh temp tabs without having to wedge itself into ProcessMessage
// directly.
public event Action<Message>? MessageProcessed;
internal unsafe MessageManager(Plugin plugin)
{
Plugin = plugin;
Store = new MessageStore(DatabasePath());
PendingMessageThread = new Thread(() => ProcessPendingMessages(PendingThreadCancellationToken.Token));
PendingMessageThread.Start();
ContentIdResolverHook = Plugin.GameInteropProvider.HookFromAddress<RaptureLogModule.Delegates.AddMsgSourceEntry>(RaptureLogModule.MemberFunctionPointers.AddMsgSourceEntry, ContentIdResolver);
ContentIdResolverHook.Enable();
Plugin.ChatGui.ChatMessageUnhandled += ChatMessage;
Plugin.Framework.Update += OnFrameworkUpdate;
Plugin.ClientState.Logout += Logout;
}
public async ValueTask DisposeAsync()
{
ContentIdResolverHook?.Dispose();
Plugin.ClientState.Logout -= Logout;
Plugin.Framework.Update -= OnFrameworkUpdate;
Plugin.ChatGui.ChatMessageUnhandled -= ChatMessage;
await PendingThreadCancellationToken.CancelAsync();
var timeout = 10_000; // 10s
while (timeout > 0)
{
if (!PendingMessageThread.IsAlive)
break;
timeout -= 100;
await Task.Delay(100);
Plugin.Log.Debug("Sleeping because PendingMessageThread thread still alive");
}
Store.Dispose();
}
internal static string DatabasePath()
{
return Path.Join(Plugin.Interface.ConfigDirectory.FullName, "chat-sqlite.db");
}
private void Logout(int _, int __)
{
LastContentId = 0;
}
private void OnFrameworkUpdate(IFramework framework)
{
var contentId = Plugin.PlayerState.ContentId;
if (contentId != 0)
LastContentId = contentId;
// Drain the PendingSync queue into the PendingAsync queue.
while (PendingSync.TryDequeue(out var pending))
PendingAsync.Enqueue(pending);
}
private void ProcessPendingMessages(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
if (PendingAsync.TryDequeue(out var pendingMessage))
{
try
{
ProcessMessage(pendingMessage);
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Error processing pending message");
}
}
else
{
Thread.Sleep(1);
}
}
}
internal void ClearAllTabs()
{
foreach (var tab in Plugin.Config.Tabs)
tab.Clear();
}
internal void FilterAllTabs()
{
DateTimeOffset? since = null;
if (!Plugin.Config.FilterIncludePreviousSessions)
since = Plugin.GameStarted;
using var messages = Store.GetMostRecentMessages(CurrentContentId, since);
// We store the pending messages to be added to the chat log in a
// temporary list, and apply them all at once after filtering.
var pendingTabs = Plugin.Config.Tabs.Select(tab => (tab, new List<Message>())).ToList();
foreach (var message in messages)
foreach (var (_, pendingMessages) in pendingTabs.Where(ptab => ptab.Item1.Matches(message)))
pendingMessages.Add(message);
// Apply the messages to the chat log in one go.
foreach (var (tab, pendingMessages) in pendingTabs)
tab.Messages.AddSortPrune(pendingMessages, MessageDisplayLimit);
if (!messages.DidError) return;
WrapperUtil.AddNotification(Language.LoadMessages_Error, NotificationType.Error);
// Mark the failed messages as deleted so we don't try to load them
// again.
var failedIds = messages.FailedMessageIds();
Plugin.Log.Info($"Marking {failedIds.Count} messages as deleted due to parse failures");
foreach (var msgId in messages.FailedMessageIds())
{
Plugin.Log.Debug($"Marking message '{msgId}' as deleted due to parse failure");
Store.DeleteMessage(msgId);
}
}
internal void FilterAllTabsAsync()
{
Task.Run(() =>
{
var stopwatch = Stopwatch.StartNew();
try
{
FilterAllTabs();
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Error in FilterAllTabs");
}
Plugin.Log.Debug($"FilterAllTabs took {stopwatch.ElapsedMilliseconds}ms");
});
}
public (SeString? Sender, SeString? Message) LastMessage = (null, null);
private void ChatMessage(IChatMessage message)
{
LastMessage = (message.Sender, message.Message);
var pendingMessage = new PendingMessage
{
ContentId = 0,
AccountId = 0,
LogKind = message.LogKind,
SourceKind = message.SourceKind,
TargetKind = message.TargetKind,
Sender = message.Sender,
Content = message.Message,
};
// Update colour codes.
GlobalParametersCache.Refresh();
// We delay messages to be handed off to the async processing thread
// in the next tick, otherwise we can't get the content ID from the hook
// below.
PendingSync.Enqueue(pendingMessage);
}
// This hook is called immediately after receiving a message with the
// message's content ID. If multiple messages are received in the same tick,
// this will be called for each message immediately after ChatMessage is
// called for each message.
private unsafe void ContentIdResolver(RaptureLogModule* agent, ulong contentId, ulong accountId, int messageIndex, ushort worldId, ushort chatType)
{
try
{
ContentIdResolverHook?.Original(agent, contentId, accountId, messageIndex, worldId, chatType);
if (PendingSync.Count == 0)
return;
PendingSync.Last().ContentId = contentId;
PendingSync.Last().AccountId = accountId;
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Error in ContentIdResolver");
}
}
private void ProcessMessage(PendingMessage pendingMessage)
{
var chatCode = new ChatCode(pendingMessage.LogKind, pendingMessage.SourceKind, pendingMessage.TargetKind);
NameFormatting? formatting = null;
if (pendingMessage.Sender.Payloads.Count > 0)
formatting = FormatFor(chatCode.Type);
var senderChunks = new List<Chunk>();
if (formatting is { IsPresent: true })
{
senderChunks.Add(new TextChunk(ChunkSource.None, null, formatting.Before) { FallbackColour = chatCode.Type });
senderChunks.AddRange(ChunkUtil.ToChunks(pendingMessage.Sender, ChunkSource.Sender, chatCode.Type));
senderChunks.Add(new TextChunk(ChunkSource.None, null, formatting.After) { FallbackColour = chatCode.Type });
}
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.Config.DatabaseBattleMessages || !message.Code.IsBattle())
Store.UpsertMessage(message);
var currentMatches = Plugin.CurrentTab.Matches(message);
foreach (var tab in Plugin.Config.Tabs)
{
var unread = !(tab.UnreadMode == UnreadMode.Unseen && Plugin.CurrentTab != tab && currentMatches);
if (tab.Matches(message))
tab.AddMessage(message, unread);
}
MessageProcessed?.Invoke(message);
}
internal class NameFormatting
{
internal string Before { get; private set; } = string.Empty;
internal string After { get; private set; } = string.Empty;
internal bool IsPresent { get; private set; } = true;
internal static NameFormatting Empty()
{
return new NameFormatting { IsPresent = false, };
}
internal static NameFormatting Of(string before, string after)
{
return new NameFormatting
{
Before = before,
After = after,
};
}
}
private NameFormatting FormatFor(ChatType type)
{
if (Formats.TryGetValue(type, out var cached))
return cached;
var formats = Sheets.LogKindSheet.GetRow((uint)type).Format.ToList();
static bool IsStringParam(ReadOnlySePayload payload, byte num)
{
if (payload.MacroCode != MacroCode.String)
return false;
return payload.TryGetExpression(out var expr1)
&& expr1.TryGetParameterExpression(out var expressionType, out var operand)
&& expressionType == (byte)ExpressionType.LocalString
&& operand.TryGetInt(out var lstrIndex)
&& lstrIndex == num;
}
var firstStringParam = formats.FindIndex(payload => IsStringParam(payload, 1));
var secondStringParam = formats.FindIndex(payload => IsStringParam(payload, 2));
if (firstStringParam == -1 || secondStringParam == -1)
return NameFormatting.Empty();
var before = formats
.GetRange(0, firstStringParam)
.Where(payload => payload.Type == ReadOnlySePayloadType.Text)
.Select(text => Encoding.UTF8.GetString(text.Body.Span));
var after = formats
.GetRange(firstStringParam + 1, secondStringParam - firstStringParam)
.Where(payload => payload.Type == ReadOnlySePayloadType.Text)
.Select(text => Encoding.UTF8.GetString(text.Body.Span)); // Can't use `ToString()` as it defaults to macro
var nameFormatting = NameFormatting.Of(string.Join("", before), string.Join("", after));
Formats[type] = nameFormatting;
return nameFormatting;
}
private class PendingMessage
{
public ulong ContentId; // 0 if unknown
public ulong AccountId; // 0 if unknown
public XivChatType LogKind;
public XivChatRelationKind SourceKind;
public XivChatRelationKind TargetKind;
public required SeString Sender;
public required SeString Content;
}
}
+918
View File
@@ -0,0 +1,918 @@
using System.Buffers;
using System.Collections;
using System.Data.Common;
using HellionChat.Code;
using HellionChat.Ui;
using HellionChat.Util;
using Dalamud.Game.Text.SeStringHandling;
using MessagePack;
using MessagePack.Formatters;
using MessagePack.Resolvers;
using Microsoft.Data.Sqlite;
using DalamudUtil = Dalamud.Utility.Util;
using Encoding = System.Text.Encoding;
namespace HellionChat;
internal static class DbExtensions
{
internal static void Execute(this DbConnection conn, string sql)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
}
internal enum PayloadMessagePackType : byte
{
Achievement,
PartyFinder,
Uri,
Emote,
Other = 255,
}
public class PayloadMessagePackFormatter : IMessagePackFormatter<Payload?>
{
public void Serialize(ref MessagePackWriter writer, Payload? value, MessagePackSerializerOptions options)
{
if (value == null)
{
writer.WriteNil();
return;
}
writer.WriteArrayHeader(2);
switch (value)
{
case AchievementPayload achievementPayload:
writer.WriteUInt8((byte)PayloadMessagePackType.Achievement);
writer.WriteUInt32(achievementPayload.Id);
break;
case PartyFinderPayload partyFinderPayload:
writer.WriteUInt8((byte)PayloadMessagePackType.PartyFinder);
writer.WriteUInt32(partyFinderPayload.Id);
break;
case UriPayload uriPayload:
writer.WriteUInt8((byte)PayloadMessagePackType.Uri);
writer.WriteString(Encoding.UTF8.GetBytes(uriPayload.Uri.ToString()));
break;
case EmotePayload emotePayload:
writer.WriteUInt8((byte)PayloadMessagePackType.Emote);
writer.WriteString(Encoding.UTF8.GetBytes(emotePayload.Code));
break;
default:
writer.WriteUInt8((byte)PayloadMessagePackType.Other);
writer.Write(value.Encode());
break;
}
}
public Payload? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
if (reader.TryReadNil())
return null;
if (reader.ReadArrayHeader() != 2)
throw new InvalidOperationException("Invalid array count for Payload object");
var type = (PayloadMessagePackType)reader.ReadByte();
switch (type)
{
case PayloadMessagePackType.Achievement:
return new AchievementPayload(reader.ReadUInt32());
case PayloadMessagePackType.PartyFinder:
return new PartyFinderPayload(reader.ReadUInt32());
case PayloadMessagePackType.Uri:
return new UriPayload(new Uri(reader.ReadString() ?? ""));
case PayloadMessagePackType.Emote:
return EmotePayload.ResolveEmote(reader.ReadString() ?? "");
case PayloadMessagePackType.Other:
default:
var bytes = reader.ReadBytes() ?? new ReadOnlySequence<byte>();
var binReader = new BinaryReader(new MemoryStream(bytes.ToArray()));
return Payload.Decode(binReader);
}
}
}
public class SeStringMessagePackFormatter : IMessagePackFormatter<SeString?>
{
public void Serialize(ref MessagePackWriter writer, SeString? value, MessagePackSerializerOptions options)
{
options.Resolver.GetFormatter<List<Payload>>()!.Serialize(ref writer, value?.Payloads ?? [], options);
}
public SeString Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
return new SeString(options.Resolver.GetFormatter<List<Payload>>()!.Deserialize(ref reader, options));
}
}
internal class MessageStore : IDisposable
{
private const int MessageQueryLimit = 10_000;
private string DbPath { get; }
private SqliteConnection Connection { get; set; }
internal static readonly MessagePackSerializerOptions MsgPackOptions = MessagePackSerializerOptions.Standard
.WithResolver(CompositeResolver.Create([new PayloadMessagePackFormatter(), new SeStringMessagePackFormatter()], [StandardResolver.Instance]));
internal MessageStore(string dbPath)
{
DbPath = dbPath;
Connection = Connect();
Migrate();
}
public void Dispose()
{
Connection.Close();
Connection.Dispose();
// Closing the connection doesn't immediately release the file.
GC.Collect();
GC.WaitForPendingFinalizers();
}
private SqliteConnection Connect()
{
var uriBuilder = new SqliteConnectionStringBuilder
{
DataSource = DbPath,
DefaultTimeout = 5,
Pooling = false,
Mode = SqliteOpenMode.ReadWriteCreate,
};
var conn = new SqliteConnection(uriBuilder.ToString());
conn.Open();
conn.Execute(@"PRAGMA journal_mode=WAL;");
conn.Execute(@"PRAGMA synchronous=NORMAL;");
if (DalamudUtil.IsWine())
conn.Execute(@"PRAGMA cache_size = 32768;");
return conn;
}
private void Migrate()
{
// Get current user_version.
using var cmd = Connection.CreateCommand();
cmd.CommandText = "PRAGMA user_version;";
var userVersion = Convert.ToInt32(cmd.ExecuteScalar());
var migrationsToDo = new List<Action>();
switch (userVersion)
{
case <= 0:
migrationsToDo.Add(Migrate0);
// Migration support was only added in version 1. Migrate 0 is
// idempotent.
migrationsToDo.Add(Migrate1);
migrationsToDo.Add(Migrate2);
migrationsToDo.Add(Migrate3);
break;
case 1:
migrationsToDo.Add(Migrate2);
migrationsToDo.Add(Migrate3);
break;
case 2:
migrationsToDo.Add(Migrate3);
break;
}
foreach (var migration in migrationsToDo)
migration();
}
private void Migrate0()
{
Plugin.Log.Information("Running migration 0: Creating tables");
Connection.Execute(@"
CREATE TABLE IF NOT EXISTS messages (
Id BLOB PRIMARY KEY NOT NULL, -- Guid
Receiver INTEGER NOT NULL, -- uint64 (first bits are always 0)
ContentId INTEGER NOT NULL, -- uint64 (first bits are always 0)
Date INTEGER NOT NULL, -- unix timestamp with millisecond precision
Code INTEGER NOT NULL, -- ChatCode encoding
Sender BLOB NOT NULL, -- Chunk[] msgpack
Content BLOB NOT NULL, -- Chunk[] msgpack
SenderSource BLOB NOT NULL, -- SeString
ContentSource BLOB NOT NULL, -- SeString
SortCode INTEGER NOT NULL, -- SortCode encoding
ExtraChatChannel BLOB NOT NULL -- Guid
);
CREATE INDEX IF NOT EXISTS idx_messages_receiver ON messages (Receiver);
CREATE INDEX IF NOT EXISTS idx_messages_date ON messages (Date);
");
SetMigrationVersion(0);
}
private void Migrate1()
{
Plugin.Log.Information("Running migration 1: Adding Deleted column");
Connection.Execute(@"
-- Migration 1: Add Deleted column
ALTER TABLE messages ADD COLUMN Deleted BOOLEAN NOT NULL DEFAULT false;
");
SetMigrationVersion(1);
}
private void Migrate2()
{
Plugin.Log.Information("Running migration 2: Adding Channel generated column");
Connection.Execute(@"
-- Migration 2: Add Channel generated column
ALTER TABLE messages ADD COLUMN Channel INTEGER GENERATED ALWAYS AS (Code & 0x7f) VIRTUAL;
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages (Channel);
");
SetMigrationVersion(2);
}
private bool ColumnExists(string table, string column)
{
// PRAGMA does not accept SQLite parameter bindings. The table name is
// a compile-time constant fed in from internal call sites, so the
// interpolation cannot be reached from any user-controlled path.
using var cmd = Connection.CreateCommand();
cmd.CommandText = $"PRAGMA table_info({table});";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
if (reader.GetString(1) == column)
return true;
}
return false;
}
private void Migrate3()
{
Plugin.Log.Information("Running migration 3: Fix log kinds to fit the new format");
// Recovery for partially-applied Migrate3: if the schema is already
// in its target shape (new columns exist, old Code column gone) but
// user_version was never bumped, just record the version and exit.
if (ColumnExists("messages", "ChatType") && !ColumnExists("messages", "Code"))
{
Plugin.Log.Information("Migration 3: schema already migrated, only bumping user_version");
SetMigrationVersion(3);
return;
}
Connection.Execute(@"
-- Migration 3: Fix log kinds to fit the new format
-- Add new ChatType, SourceKind, TargetKind (byte), SortCodeV2
-- Migrate OldChatColumn
-- ChatType = OldChatColumn & 0x7f
-- SourceKind = log2(1 << ((OldChatColumn >> 11) & 0xF))
-- TargetKind = trunc(log2(1 << ((OldChatColumn >> 7) & 0xF)))
-- Virtual SortCodeV2 = ChatType << 16 | SourceKind << 8 | TargetKind
-- Delete OldChatColumn, Virtual Channel
ALTER TABLE messages ADD COLUMN ChatType INTEGER;
CREATE INDEX IF NOT EXISTS idx_messages_chat_type ON messages (ChatType);
ALTER TABLE messages ADD COLUMN SourceKind INTEGER;
ALTER TABLE messages ADD COLUMN TargetKind INTEGER;
UPDATE messages SET
ChatType = Code & 0x7f,
SourceKind = trunc(log2(1 << ((Code >> 11) & 0xF))),
TargetKind = trunc(log2(1 << ((Code >> 7) & 0xF)))
WHERE true;
DROP INDEX idx_messages_channel;
ALTER TABLE messages DROP COLUMN Channel;
ALTER TABLE messages DROP COLUMN Code;
ALTER TABLE messages DROP COLUMN SortCode;
");
SetMigrationVersion(3);
}
private void SetMigrationVersion(int version)
{
Plugin.Log.Information($"Setting version {version}");
using var cmd = Connection.CreateCommand();
// PRAGMA does not accept SQLite parameter bindings, and there is no
// pragma_ function variant that can set the version either. The
// version is a compile-time int from the migration sequence, never
// user input.
cmd.CommandText = $"PRAGMA user_version = {version};";
cmd.ExecuteNonQuery();
}
internal void ClearMessages()
{
Connection.Execute("DELETE FROM messages;");
PerformMaintenance();
}
/// <summary>
/// Returns a (ChatType, count) snapshot over non-deleted messages.
/// Used by the Privacy tab to preview the impact of a retroactive
/// cleanup before the user confirms.
/// </summary>
internal Dictionary<int, long> GetMessageCountsByChatType()
{
var result = new Dictionary<int, long>();
using var cmd = Connection.CreateCommand();
cmd.CommandText = "SELECT ChatType, COUNT(*) FROM messages WHERE deleted = false GROUP BY ChatType;";
cmd.CommandTimeout = 120;
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var chatType = reader.GetInt32(0);
var count = reader.GetInt64(1);
result[chatType] = count;
}
return result;
}
/// <summary>
/// Deletes messages older than the per-channel retention window, with a
/// global default for channels not listed explicitly. Cutoffs are
/// computed from "now" at call time. Runs VACUUM only if anything was
/// removed. Returns the number of rows deleted.
/// </summary>
internal long DeleteByRetentionPolicy(IReadOnlyDictionary<int, int> chatTypeDaysMap, int defaultDays)
{
if (defaultDays < 0)
throw new ArgumentOutOfRangeException(nameof(defaultDays), "Negative retention is not allowed.");
foreach (var (_, days) in chatTypeDaysMap)
if (days < 0)
throw new ArgumentOutOfRangeException(nameof(chatTypeDaysMap), "Negative retention is not allowed.");
var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (chatTypeDaysMap.Count == 0 && defaultDays <= 0)
return 0;
long deleted;
using (var cmd = Connection.CreateCommand())
{
var clauses = new List<string>();
var index = 0;
foreach (var (type, days) in chatTypeDaysMap)
{
var cutoff = nowMs - days * 86400000L;
var typeParam = $"$type{index}";
var cutoffParam = $"$cutoff{index}";
cmd.Parameters.AddWithValue(typeParam, type);
cmd.Parameters.AddWithValue(cutoffParam, cutoff);
clauses.Add($"(ChatType = {typeParam} AND Date < {cutoffParam})");
index++;
}
// Catch-all for channels without an explicit override. "0" is
// treated as "do not delete by default" — without an explicit
// user override, unmapped channels stay forever instead of
// getting wiped immediately.
if (defaultDays > 0)
{
var defaultCutoff = nowMs - defaultDays * 86400000L;
cmd.Parameters.AddWithValue("$defaultCutoff", defaultCutoff);
var explicitPlaceholders = chatTypeDaysMap.Count > 0
? BindIntList(cmd, "explicit", chatTypeDaysMap.Keys)
: "-1"; // empty list would produce invalid SQL
clauses.Add($"(ChatType NOT IN ({explicitPlaceholders}) AND Date < $defaultCutoff)");
}
if (clauses.Count == 0)
return 0;
cmd.CommandText = $"DELETE FROM messages WHERE {string.Join(" OR ", clauses)};";
cmd.CommandTimeout = 600;
deleted = cmd.ExecuteNonQuery();
}
if (deleted > 0)
PerformMaintenance();
return deleted;
}
/// <summary>
/// Hard-deletes every message whose ChatType is not in the supplied
/// allowlist, then VACUUMs the database to reclaim disk space.
/// Returns the number of rows deleted.
/// </summary>
internal long CleanupRetainOnly(IReadOnlyCollection<int> allowedTypes)
{
if (allowedTypes.Count == 0)
{
// Defensive: refuse a "delete everything" disguised as a filter.
// Use ClearMessages() if a full wipe is actually intended.
throw new InvalidOperationException("CleanupRetainOnly requires at least one allowed ChatType. Use ClearMessages for a full wipe.");
}
long deleted;
using (var cmd = Connection.CreateCommand())
{
var placeholders = BindIntList(cmd, "ct", allowedTypes);
cmd.CommandText = $"DELETE FROM messages WHERE ChatType NOT IN ({placeholders});";
cmd.CommandTimeout = 600;
deleted = cmd.ExecuteNonQuery();
}
PerformMaintenance();
return deleted;
}
internal void PerformMaintenance()
{
Connection.Execute(@"
VACUUM;
REINDEX messages;
ANALYZE;
");
}
private string LogPath => DbPath + "-wal";
internal long DatabaseSize() => !File.Exists(DbPath) ? 0 : new FileInfo(DbPath).Length;
internal long DatabaseLogSize() => !File.Exists(LogPath) ? 0 : new FileInfo(LogPath).Length;
internal int MessageCount()
{
using var cmd = Connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM messages;";
return Convert.ToInt32(cmd.ExecuteScalar());
}
internal void UpsertMessage(Message message)
{
// Hellion Chat privacy filter — drop disallowed ChatTypes before
// they reach the storage layer (single source of truth, also
// covers any future write paths e.g. webinterface backfill).
if (!Plugin.Config.IsAllowedForStorage(message.Code.Type))
{
Plugin.Log.Debug($"Privacy filter dropped message: ChatType={message.Code.Type}");
return;
}
using var cmd = Connection.CreateCommand();
cmd.CommandText = @"
INSERT INTO messages (
Id,
Receiver,
ContentId,
Date,
ChatType,
SourceKind,
TargetKind,
Sender,
Content,
SenderSource,
ContentSource,
ExtraChatChannel,
Deleted
) VALUES (
$Id,
$Receiver,
$ContentId,
$Date,
$ChatType,
$SourceKind,
$TargetKind,
$Sender,
$Content,
$SenderSource,
$ContentSource,
$ExtraChatChannel,
false
)
ON CONFLICT (id) DO UPDATE SET
Receiver = excluded.Receiver,
ContentId = excluded.ContentId,
Date = excluded.Date,
ChatType = excluded.ChatType,
SourceKind = excluded.SourceKind,
TargetKind = excluded.TargetKind,
Sender = excluded.Sender,
Content = excluded.Content,
SenderSource = excluded.SenderSource,
ContentSource = excluded.ContentSource,
ExtraChatChannel = excluded.ExtraChatChannel,
Deleted = false;
";
cmd.Parameters.AddWithValue("$Id", message.Id);
cmd.Parameters.AddWithValue("$Receiver", message.Receiver);
cmd.Parameters.AddWithValue("$ContentId", message.ContentId);
cmd.Parameters.AddWithValue("$Date", message.Date.ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$ChatType", message.Code.Type);
cmd.Parameters.AddWithValue("$SourceKind", message.Code.Source);
cmd.Parameters.AddWithValue("$TargetKind", message.Code.Target);
cmd.Parameters.AddWithValue("$Sender", MessagePackSerializer.Serialize(message.Sender, MsgPackOptions));
cmd.Parameters.AddWithValue("$Content", MessagePackSerializer.Serialize(message.Content, MsgPackOptions));
cmd.Parameters.AddWithValue("$SenderSource", MessagePackSerializer.Serialize(message.SenderSource, MsgPackOptions));
cmd.Parameters.AddWithValue("$ContentSource", MessagePackSerializer.Serialize(message.ContentSource, MsgPackOptions));
cmd.Parameters.AddWithValue("$ExtraChatChannel", message.ExtraChatChannel);
cmd.ExecuteNonQuery();
}
/// <summary>
/// Streams messages for export. Optional filters:
/// - <paramref name="chatTypes"/>: limit to these ChatTypes
/// - <paramref name="from"/> / <paramref name="to"/>: inclusive date range
/// Result is sorted ascending by Date and excludes soft-deleted rows.
/// Caller is responsible for disposing the enumerator.
/// </summary>
internal MessageEnumerator StreamForExport(
IReadOnlyCollection<int>? chatTypes,
DateTimeOffset? from,
DateTimeOffset? to)
{
var cmd = Connection.CreateCommand();
var clauses = new List<string> { "deleted = false" };
if (chatTypes is { Count: > 0 })
clauses.Add($"ChatType IN ({BindIntList(cmd, "exct", chatTypes)})");
if (from is not null)
clauses.Add("Date >= $From");
if (to is not null)
clauses.Add("Date <= $To");
cmd.CommandText = @"
SELECT
Id,
Receiver,
ContentId,
Date,
ChatType,
SourceKind,
TargetKind,
Sender,
Content,
SenderSource,
ContentSource,
ExtraChatChannel
FROM messages
WHERE " + string.Join(" AND ", clauses) + @"
ORDER BY Date ASC;";
cmd.CommandTimeout = 600;
if (from is not null)
cmd.Parameters.AddWithValue("$From", from.Value.ToUnixTimeMilliseconds());
if (to is not null)
cmd.Parameters.AddWithValue("$To", to.Value.ToUnixTimeMilliseconds());
return new MessageEnumerator(cmd.ExecuteReader());
}
/// <summary>
/// Get the most recent messages.
/// </summary>
/// <param name="receiver">The receiver content ID to filter by. If null, no filtering is performed.</param>
/// <param name="since">Only show messages since this date. If null, no filtering is performed.</param>
/// <param name="count">The amount to return. Defaults to 10,000.</param>
internal MessageEnumerator GetMostRecentMessages(ulong? receiver = null, DateTimeOffset? since = null, int count = MessageQueryLimit)
{
List<string> whereClauses = ["deleted = false"];
if (receiver != null)
whereClauses.Add("Receiver = $Receiver");
if (since != null)
whereClauses.Add("Date >= $Since");
var whereClause = "WHERE " + string.Join(" AND ", whereClauses);
var cmd = Connection.CreateCommand();
// Select last N messages by date DESC, but reverse the order to get
// them in ascending order.
cmd.CommandText = @"
SELECT *
FROM (
SELECT
Id,
Receiver,
ContentId,
Date,
ChatType,
SourceKind,
TargetKind,
Sender,
Content,
SenderSource,
ContentSource,
ExtraChatChannel
FROM messages
" + whereClause + @"
ORDER BY Date DESC
LIMIT $Count
)
ORDER BY Date ASC;
";
cmd.CommandTimeout = 120; // this could take a while on slow computers
if (receiver != null)
cmd.Parameters.AddWithValue("$Receiver", receiver);
if (since != null)
cmd.Parameters.AddWithValue("$Since", since.Value.ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$Count", count);
return new MessageEnumerator(cmd.ExecuteReader());
}
/// <summary>
/// Hellion Chat — Auto-Tell-Tabs history preload.
///
/// Returns up to <paramref name="limit"/> tells exchanged with the named
/// player, oldest-first, ready to be added to a freshly spawned auto
/// tell tab. The Sender column is a serialized chunk blob, so SQL on its
/// own cannot filter by player identity; we narrow with SQL on Receiver
/// + ChatType (cheap, indexed) and let the client do the final
/// PlayerPayload comparison on the result set.
///
/// <paramref name="sqlScanLimit"/> caps how many recent tells we scan
/// before giving up. 500 covers around 10 days for an active greeter
/// and stays well under the 20 ms budget required to keep the spawn on
/// the message-processing worker thread.
/// </summary>
internal IReadOnlyList<Message> GetTellHistoryWithSender(
ulong receiver,
string senderName,
uint senderWorld,
int limit,
int sqlScanLimit = 500)
{
if (limit <= 0)
{
return [];
}
using var cmd = Connection.CreateCommand();
cmd.CommandText = @"
SELECT
Id,
Receiver,
ContentId,
Date,
ChatType,
SourceKind,
TargetKind,
Sender,
Content,
SenderSource,
ContentSource,
ExtraChatChannel
FROM messages
WHERE deleted = false
AND Receiver = $Receiver
AND ChatType IN ($TellIncoming, $TellOutgoing)
ORDER BY Date DESC
LIMIT $ScanLimit;
";
cmd.CommandTimeout = 60;
cmd.Parameters.AddWithValue("$Receiver", receiver);
cmd.Parameters.AddWithValue("$TellIncoming", (int)ChatType.TellIncoming);
cmd.Parameters.AddWithValue("$TellOutgoing", (int)ChatType.TellOutgoing);
cmd.Parameters.AddWithValue("$ScanLimit", sqlScanLimit);
var collected = new List<Message>();
using var enumerator = new MessageEnumerator(cmd.ExecuteReader());
foreach (var message in enumerator)
{
if (!ChunkUtil.MatchesSender(message, senderName, senderWorld))
{
continue;
}
collected.Add(message);
if (collected.Count >= limit)
{
break;
}
}
// SQL was DESC (newest-first) so we hit the limit on the most
// recent matching tells. Reverse to oldest-first for chronological
// display in the tab.
collected.Reverse();
return collected;
}
/// <summary>
/// Marks a message as deleted so it won't get returned in queries.
/// </summary>
internal void DeleteMessage(Guid id)
{
using var cmd = Connection.CreateCommand();
cmd.CommandText = "UPDATE messages SET Deleted = true WHERE Id = $Id;";
cmd.Parameters.AddWithValue("$Id", id);
cmd.ExecuteNonQuery();
}
internal long CountDateRange(DateTime after, DateTime before, IEnumerable<byte> channels, ulong? receiver = null)
{
using var cmd = Connection.CreateCommand();
List<string> whereClauses = ["deleted = false"];
if (receiver != null)
whereClauses.Add("Receiver = $Receiver");
whereClauses.Add("Date BETWEEN $After AND $Before");
whereClauses.Add($"ChatType IN ({BindIntList(cmd, "cdr", channels.Select(c => (int)c))})");
var whereClause = "WHERE " + string.Join(" AND ", whereClauses);
// Select last N messages by date DESC, but reverse the order to get
// them in ascending order.
cmd.CommandText = @"
SELECT COUNT(*)
FROM messages
" + whereClause;
if (receiver != null)
cmd.Parameters.AddWithValue("$Receiver", receiver);
cmd.Parameters.AddWithValue("$After", ((DateTimeOffset) after).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset) before).ToUnixTimeMilliseconds());
cmd.CommandTimeout = 120; // this could take a while on slow computers
return (long) cmd.ExecuteScalar()!;
}
internal MessageEnumerator GetDateRange(DateTime after, DateTime before, IEnumerable<byte> channels, ulong? receiver = null)
{
var cmd = Connection.CreateCommand();
List<string> whereClauses = ["deleted = false"];
if (receiver != null)
whereClauses.Add("Receiver = $Receiver");
whereClauses.Add("Date BETWEEN $After AND $Before");
whereClauses.Add($"ChatType IN ({BindIntList(cmd, "gdr", channels.Select(c => (int)c))})");
var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}";
// Select last N messages by date DESC, but reverse the order to get
// them in ascending order.
cmd.CommandText = @"
SELECT
Id,
Receiver,
ContentId,
Date,
ChatType,
SourceKind,
TargetKind,
Sender,
Content,
SenderSource,
ContentSource,
ExtraChatChannel
FROM messages
" + whereClause;
cmd.CommandTimeout = 120; // this could take a while on slow computers
if (receiver != null)
cmd.Parameters.AddWithValue("$Receiver", receiver);
cmd.Parameters.AddWithValue("$After", ((DateTimeOffset) after).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset) before).ToUnixTimeMilliseconds());
return new MessageEnumerator(cmd.ExecuteReader());
}
internal MessageEnumerator GetPagedDateRange(DateTime after, DateTime before, IEnumerable<byte> channels, ulong? receiver = null, int page = 0)
{
var cmd = Connection.CreateCommand();
List<string> whereClauses = ["deleted = false"];
if (receiver != null)
whereClauses.Add("Receiver = $Receiver");
whereClauses.Add("Date BETWEEN $After AND $Before");
whereClauses.Add($"ChatType IN ({BindIntList(cmd, "pdr", channels.Select(c => (int)c))})");
var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}";
// Select last N messages by date DESC, but reverse the order to get
// them in ascending order.
cmd.CommandText = @"
SELECT
Id,
Receiver,
ContentId,
Date,
ChatType,
SourceKind,
TargetKind,
Sender,
Content,
SenderSource,
ContentSource,
ExtraChatChannel
FROM messages
" + whereClause + @"
ORDER BY Date
LIMIT $Offset, $OffsetCount;
";
cmd.CommandTimeout = 120; // this could take a while on slow computers
if (receiver != null)
cmd.Parameters.AddWithValue("$Receiver", receiver);
cmd.Parameters.AddWithValue("$After", ((DateTimeOffset) after).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset) before).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$Offset", DbViewer.RowPerPage * page);
cmd.Parameters.AddWithValue("$OffsetCount", DbViewer.RowPerPage);
return new MessageEnumerator(cmd.ExecuteReader());
}
// Build "$prefix0,$prefix1,..." placeholder list and bind values to
// the command. SQLite has no native array parameter, so we generate
// the list at runtime and bind each entry under its own name. Used
// for IN-clauses and similar dynamic-arity SQL fragments.
private static string BindIntList(SqliteCommand cmd, string prefix, IEnumerable<int> values)
{
var names = new List<string>();
var index = 0;
foreach (var value in values)
{
var name = $"${prefix}{index}";
cmd.Parameters.AddWithValue(name, value);
names.Add(name);
index++;
}
return string.Join(",", names);
}
}
internal class MessageEnumerator(DbDataReader reader) : IEnumerable<Message>, IDisposable, IAsyncDisposable
{
private const int MaxErrorLogs = 10;
// FailedIds and FailedCount are separate, because messages might fail to
// even parse the ID field.
private readonly List<Guid> FailedIds = [];
private int FailedCount;
public bool DidError => FailedCount > 0;
public IEnumerator<Message> GetEnumerator()
{
while (reader.Read())
{
var id = Guid.Empty;
Message msg;
try
{
id = reader.GetGuid(0);
msg = new Message(
id,
(ulong)reader.GetInt64(1),
(ulong)reader.GetInt64(2),
DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(3)),
new ChatCode((byte)reader.GetInt32(4), (byte)reader.GetInt32(5), (byte)reader.GetInt32(6)),
MessagePackSerializer.Deserialize<List<Chunk>>(reader.GetFieldValue<byte[]>(7), MessageStore.MsgPackOptions),
MessagePackSerializer.Deserialize<List<Chunk>>(reader.GetFieldValue<byte[]>(8), MessageStore.MsgPackOptions),
MessagePackSerializer.Deserialize<SeString>(reader.GetFieldValue<byte[]>(9), MessageStore.MsgPackOptions),
MessagePackSerializer.Deserialize<SeString>(reader.GetFieldValue<byte[]>(10), MessageStore.MsgPackOptions),
reader.GetGuid(11)
);
}
catch (Exception e)
{
if (FailedCount < MaxErrorLogs)
Plugin.Log.Error($"Exception while reading message '{id}' from database: {e}");
FailedCount++;
if (FailedCount == MaxErrorLogs)
Plugin.Log.Error("Further parsing errors will not be logged");
if (id != Guid.Empty)
FailedIds.Add(id);
continue;
}
yield return msg;
}
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
public IReadOnlyList<Guid> FailedMessageIds()
{
return FailedIds;
}
public void Dispose()
{
reader.Dispose();
}
public async ValueTask DisposeAsync()
{
await reader.DisposeAsync();
}
}
+755
View File
@@ -0,0 +1,755 @@
using System.Numerics;
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Ui;
using HellionChat.Util;
using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.Config;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.Textures;
using Dalamud.Interface.Textures.TextureWraps;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
using Dalamud.Bindings.ImGui;
using Lumina.Excel.Sheets;
using Action = System.Action;
using DalamudPartyFinderPayload = Dalamud.Game.Text.SeStringHandling.Payloads.PartyFinderPayload;
using ChatTwoPartyFinderPayload = HellionChat.Util.PartyFinderPayload;
namespace HellionChat;
public sealed class PayloadHandler
{
private const string PopupId = "hellionchat-context-popup";
private ChatLogWindow LogWindow { get; }
private (Chunk, Payload?)? Popup { get; set; }
public bool HandleTooltips;
public uint HoveredItem;
public uint HoverCounter;
public uint LastHoverCounter;
private const uint PopupSfx = 1;
internal PayloadHandler(ChatLogWindow logWindow)
{
LogWindow = logWindow;
}
internal void Draw()
{
DrawPopups();
if (HandleTooltips && ++HoverCounter - LastHoverCounter > 1)
{
GameFunctions.GameFunctions.CloseItemTooltip();
HoveredItem = 0;
HoverCounter = LastHoverCounter = 0;
HandleTooltips = false;
}
}
private void DrawPopups()
{
if (Popup == null)
return;
var (chunk, payload) = Popup.Value;
using var popup = ImRaii.Popup(PopupId);
if (!popup.Success)
{
Popup = null;
return;
}
using var id = ImRaii.PushId(PopupId);
var drawn = false;
switch (payload)
{
case PlayerPayload player:
DrawPlayerPopup(chunk, player);
drawn = true;
break;
case ItemPayload item:
DrawItemPopup(item);
drawn = true;
break;
case UriPayload uri:
DrawUriPopup(uri);
drawn = true;
break;
case StatusPayload status:
DrawStatusPopup(status);
drawn = true;
break;
}
ContextFooter(drawn, chunk);
Integrations(chunk, payload);
}
private void Integrations(Chunk chunk, Payload? payload)
{
var registered = LogWindow.Plugin.Ipc.Registered;
if (registered.Count == 0)
return;
ImGui.Separator();
var contentId = chunk.Message?.ContentId ?? 0;
var sender = chunk.Message?.Sender.Select(c => c.Link).FirstOrDefault(p => p is PlayerPayload) as PlayerPayload;
using var menu = ImRaii.Menu(Language.Context_Integrations);
if (!menu.Success)
return;
var cursor = ImGui.GetCursorPos();
foreach (var id in registered)
{
try
{
LogWindow.Plugin.Ipc.Invoke(id, sender, contentId, payload, chunk.Message?.SenderSource, chunk.Message?.ContentSource);
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Error executing integration");
}
}
if (cursor == ImGui.GetCursorPos())
{
using var pushedColor = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int)ImGuiCol.TextDisabled]);
ImGui.Text("No integrations available");
}
}
private void ContextFooter(bool didCustomContext, Chunk chunk)
{
ImRaii.MenuDisposable menu = default;
if (didCustomContext)
{
ImGui.Separator();
// Only place these menu items in a submenu if we've already drawn
// custom context menu items based on the payload.
//
// It makes it much more convenient in the majority of cases to
// copy the message content without having to open a submenu.
menu = ImRaii.Menu(Plugin.PluginName);
if (!menu.Success)
return;
}
ImGui.Checkbox(Language.Context_ScreenshotMode, ref LogWindow.ScreenshotMode);
if (ImGui.Selectable(Language.Context_HideChat))
LogWindow.UserHide();
if (chunk.Message is { } message)
{
if (ImGui.Selectable(Language.Context_Copy))
{
ImGui.SetClipboardText(StringifyMessage(message, true));
WrapperUtil.AddNotification(Language.Context_CopySuccess, NotificationType.Info);
}
// Only show a separate "Copy content" option if the message has
// Sender chunks, so it doesn't show for system messages.
if (message.Sender.Count > 0 && ImGui.Selectable(Language.Context_CopyContent))
{
ImGui.SetClipboardText(StringifyMessage(message));
WrapperUtil.AddNotification(Language.Context_CopyContentSuccess, NotificationType.Info);
}
using var pushedColor = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int) ImGuiCol.TextDisabled]);
ImGui.TextUnformatted(message.Code.Type.Name());
}
menu.Dispose();
}
private static string StringifyMessage(Message? message, bool withSender = false)
{
if (message == null)
return string.Empty;
var chunks = withSender ? message.Sender.Concat(message.Content) : message.Content;
return chunks.Where(chunk => chunk is TextChunk)
.Cast<TextChunk>()
.Select(text => text.Content)
.Aggregate(string.Concat);
}
internal unsafe void Click(Chunk chunk, Payload? payload, ImGuiMouseButton button)
{
if (Plugin.Config.PlaySounds)
UIGlobals.PlaySoundEffect(PopupSfx);
switch (button)
{
case ImGuiMouseButton.Left:
LeftClickPayload(chunk, payload);
break;
case ImGuiMouseButton.Right:
RightClickPayload(chunk, payload);
break;
}
}
internal void Hover(Payload payload)
{
var hoverSize = 350f * ImGuiHelpers.GlobalScale;
switch (payload)
{
case StatusPayload status:
DoHover(() => HoverStatus(status), hoverSize);
break;
case ItemPayload item:
if (Plugin.Config.NativeItemTooltips)
{
if (!HandleTooltips || HoveredItem != item.RawItemId)
{
HandleTooltips = true;
HoveredItem = item.RawItemId;
HoverCounter = LastHoverCounter = 0;
GameFunctions.GameFunctions.OpenItemTooltip(item.RawItemId, item.Kind);
}
else
{
LastHoverCounter = HoverCounter;
}
return;
}
DoHover(() => HoverItem(item), hoverSize);
break;
case UriPayload uri:
DoHover(() => HoverUri(uri), hoverSize);
break;
}
}
private void DoHover(Action inside, float width)
{
ImGui.SetNextWindowSize(new Vector2(width, -1f));
using (ImRaii.Tooltip())
using (ImRaii.TextWrapPos(0.0f))
using (ImRaii.PushColor(ImGuiCol.Text, LogWindow.DefaultText))
inside();
}
public unsafe void MoveTooltip(AddonEvent type, AddonArgs args)
{
// Only move if the user has the "Next to Cursor" option selected
if (!Plugin.GameConfig.TryGet(UiControlOption.DetailTrackingType, out uint selected) || selected != 0)
return;
if (LogWindow.LastViewport != ImGuiHelpers.MainViewport.Handle)
return;
var atk = args.Addon;
if (atk.IsNull)
return;
var atkBase = (AtkUnitBase*)atk.Address;
if (atkBase->WindowNode == null)
return;
if (!atkBase->IsVisible)
return;
var component = atkBase->WindowNode->AtkResNode;
var atkPos = new Vector2(component.ScreenX, component.ScreenY);
var atkSize = new Vector2(component.GetWidth() * component.ScaleX, component.GetHeight() * component.GetScaleY());
var chatRect = new MathUtil.Rectangle(LogWindow.LastWindowPos, LogWindow.LastWindowSize);
var addonRect = new MathUtil.Rectangle(atkPos, atkSize);
if (!chatRect.HasOverlap(addonRect))
return;
var viewportSize = ImGuiHelpers.MainViewport.Size;
var isLeft = chatRect.SizeX < viewportSize.X / 2;
var isTop = chatRect.SizeY < viewportSize.Y / 2;
var mousePos = ImGui.GetMousePos();
// addon spawned left of mouse cursor
if (addonRect.X < mousePos.X)
{
if (isLeft)
addonRect.X = (short)mousePos.X + 5;
}
else
{
if (!isLeft)
addonRect.X = Math.Max(0, (short)mousePos.X - 5 - addonRect.Width);
}
if (!chatRect.HasOverlap(addonRect))
{
atkBase->SetPosition((short) addonRect.X, (short) addonRect.Y);
return;
}
// addon spawned above mouse cursor
if (addonRect.Y < mousePos.Y)
{
if (isTop)
addonRect.Y = (short)mousePos.Y + 5;
}
else
{
if (!isTop)
addonRect.Y = Math.Max(0, (short)mousePos.Y - 5 - addonRect.Height); // prevent it going below 0
}
if (!chatRect.HasOverlap(addonRect))
{
atkBase->SetPosition((short) addonRect.X, (short) addonRect.Y);
return;
}
// Spawning right/bottom of mouse cursor didn't solve the overlap, so we spawn it next to the chat
var x = isLeft ? chatRect.SizeX : LogWindow.LastWindowPos.X - atkSize.X;
var y = Math.Clamp(chatRect.SizeY - atkSize.Y, 0, float.MaxValue);
y -= isTop ? 0 : Plugin.Config.TooltipOffset; // offset to prevent cut-off on the bottom
atkBase->SetPosition((short) x, (short) y);
}
private static void InlineIcon(IDalamudTextureWrap icon)
{
var cursor = ImGui.GetCursorPos();
var size = ImGuiHelpers.ScaledVector2(32, 32);
ImGui.Image(icon.Handle, size);
ImGui.SameLine();
ImGui.SetCursorPos(cursor + new Vector2(size.X + 4, size.Y - ImGui.GetTextLineHeightWithSpacing()));
}
private void HoverStatus(StatusPayload status)
{
if (Plugin.TextureProvider.GetFromGameIcon(status.Status.Value.Icon).GetWrapOrDefault() is { } icon)
InlineIcon(icon);
var builder = new SeStringBuilder();
var nameValue = status.Status.Value.Name.ToString();
switch (status.Status.Value.StatusCategory)
{
case 1:
builder.AddUiForeground($"{SeIconChar.Buff.ToIconString()}{nameValue}", 517);
break;
case 2:
builder.AddUiForeground($"{SeIconChar.Debuff.ToIconString()}{nameValue}", 518);
break;
default:
builder.AddUiForeground(nameValue, 1);
break;
}
var name = ChunkUtil.ToChunks(builder.BuiltString, ChunkSource.None, null);
LogWindow.DrawChunks(name.ToList());
ImGui.Separator();
var desc = ChunkUtil.ToChunks(status.Status.Value.Description.ToDalamudString(), ChunkSource.None, null);
LogWindow.DrawChunks(desc.ToList());
}
private void HoverItem(ItemPayload item)
{
if (item.Kind == ItemKind.EventItem)
{
HoverEventItem(item);
return;
}
if (!item.Item.TryGetValue(out Item resolvedItem))
return;
if (Plugin.TextureProvider.GetFromGameIcon(new GameIconLookup(resolvedItem.Icon, item.IsHQ)).GetWrapOrDefault() is { } icon)
InlineIcon(icon);
var name = ChunkUtil.ToChunks(resolvedItem.Name.ToDalamudString(), ChunkSource.None, null);
LogWindow.DrawChunks(name.ToList());
ImGui.Separator();
var desc = ChunkUtil.ToChunks(resolvedItem.Description.ToDalamudString(), ChunkSource.None, null);
LogWindow.DrawChunks(desc.ToList());
}
private void HoverEventItem(ItemPayload payload)
{
if (!Sheets.EventItemSheet.TryGetRow(payload.RawItemId, out var itemRow))
return;
if (Plugin.TextureProvider.GetFromGameIcon(new GameIconLookup(itemRow.Icon)).GetWrapOrDefault() is { } icon)
InlineIcon(icon);
var name = ChunkUtil.ToChunks(itemRow.Name.ToDalamudString(), ChunkSource.None, null);
LogWindow.DrawChunks(name.ToList());
ImGui.Separator();
if (!Sheets.EventItemHelpSheet.TryGetRow(payload.RawItemId, out var itemHelpRow))
return;
LogWindow.DrawChunks(ChunkUtil.ToChunks(itemHelpRow.Description.ToDalamudString(), ChunkSource.None, null).ToList());
}
private void HoverUri(UriPayload uri)
{
ImGui.TextUnformatted(string.Format(Language.Context_URLDomain, uri.Uri.Authority));
ImGuiUtil.WarningText(Language.Context_URLWarning);
}
private void LeftClickPayload(Chunk chunk, Payload? payload)
{
switch (payload)
{
case MapLinkPayload map:
Plugin.GameGui.OpenMapWithMapLink(map);
break;
case QuestPayload quest:
GameFunctions.GameFunctions.OpenQuestLog(quest.Quest);
break;
case DalamudLinkPayload link:
ClickLinkPayload(chunk, payload, link);
break;
case DalamudPartyFinderPayload pf:
if (pf.LinkType == DalamudPartyFinderPayload.PartyFinderLinkType.PartyFinderNotification)
GameFunctions.GameFunctions.OpenPartyFinder();
else
GameFunctions.GameFunctions.OpenPartyFinder(pf.ListingId);
break;
case ChatTwoPartyFinderPayload pf:
GameFunctions.GameFunctions.OpenPartyFinder(pf.Id);
break;
case AchievementPayload achievement:
GameFunctions.GameFunctions.OpenAchievement(achievement.Id);
break;
case RawPayload raw:
if (Equals(raw, ChunkUtil.PeriodicRecruitmentLink))
GameFunctions.GameFunctions.OpenPartyFinder();
break;
case UriPayload uri:
WrapperUtil.TryOpenUri(uri.Uri);
break;
default:
RightClickPayload(chunk, payload);
break;
}
}
private void ClickLinkPayload(Chunk chunk, Payload payload, DalamudLinkPayload link)
{
if (chunk.GetSeString() is not { } source)
return;
var start = source.Payloads.IndexOf(payload);
var end = source.Payloads.IndexOf(RawPayload.LinkTerminator, start == -1 ? 0 : start);
if (start == -1 || end == -1)
return;
var payloads = source.Payloads.Skip(start).Take(end - start + 1).ToList();
if (!Plugin.ChatGui.RegisteredLinkHandlers.TryGetValue((link.Plugin, link.CommandId), out var value))
{
Plugin.Log.Warning("Could not find DalamudLinkHandlers");
return;
}
try
{
// Running XivCommon SendChat instantly, without RunOnTick, leads to a game freeze, for whatever reason
Plugin.Framework.RunOnTick(() => value.Invoke(link.CommandId, new SeString(payloads)));
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Error executing DalamudLinkPayload handler");
}
}
private void RightClickPayload(Chunk chunk, Payload? payload)
{
Popup = (chunk, payload);
ImGui.OpenPopup(PopupId);
}
private void DrawItemPopup(ItemPayload payload)
{
if (payload.Kind == ItemKind.EventItem)
{
DrawEventItemPopup(payload);
return;
}
if (!Sheets.ItemSheet.TryGetRow(payload.ItemId, out var itemRow))
return;
var hq = payload.Kind == ItemKind.Hq;
if (Plugin.TextureProvider.GetFromGameIcon(new GameIconLookup(itemRow.Icon, hq)).GetWrapOrDefault() is { } icon)
InlineIcon(icon);
var name = itemRow.Name.ToDalamudString();
// hq symbol
if (hq)
name.Payloads.Add(new TextPayload(" "));
else if (payload.Kind == ItemKind.Collectible)
name.Payloads.Add(new TextPayload(" "));
LogWindow.DrawChunks(ChunkUtil.ToChunks(name, ChunkSource.None, null).ToList(), false);
ImGui.Separator();
var realItemId = payload.RawItemId;
if (itemRow.EquipSlotCategory.RowId != 0)
{
if (ImGui.Selectable(Language.Context_TryOn))
GameFunctions.Context.TryOn(realItemId, 0);
if (ImGui.Selectable(Language.Context_ItemComparison))
GameFunctions.Context.OpenItemComparison(realItemId);
}
if (itemRow.ItemSearchCategory.Value.Category == 3)
if (ImGui.Selectable(Language.Context_SearchRecipes))
GameFunctions.Context.SearchForRecipesUsingItem(payload.ItemId);
if (ImGui.Selectable(Language.Context_SearchForItem))
GameFunctions.Context.SearchForItem(realItemId);
if (ImGui.Selectable(Language.Context_Link))
GameFunctions.Context.LinkItem(realItemId);
if (ImGui.Selectable(Language.Context_CopyItemName))
ImGui.SetClipboardText(name.TextValue);
}
private void DrawEventItemPopup(ItemPayload payload)
{
if (payload.Kind != ItemKind.EventItem)
return;
if (!Sheets.EventItemSheet.HasRow(payload.ItemId))
return;
var item = Sheets.EventItemSheet.GetRow(payload.ItemId);
if (Plugin.TextureProvider.GetFromGameIcon(new GameIconLookup(item.Icon)).GetWrapOrDefault() is { } icon)
InlineIcon(icon);
LogWindow.DrawChunks(ChunkUtil.ToChunks(item.Name.ToDalamudString(), ChunkSource.None, null).ToList(), false);
ImGui.Separator();
var realItemId = payload.RawItemId;
if (ImGui.Selectable(Language.Context_Link))
GameFunctions.Context.LinkItem(realItemId);
if (ImGui.Selectable(Language.Context_CopyItemName))
ImGui.SetClipboardText(item.Name.ToString());
}
private void DrawPlayerPopup(Chunk chunk, PlayerPayload player)
{
// Possible that GMs return a null payload
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
if (player == null)
return;
var world = player.World;
if (chunk.Message?.Code.Type == ChatType.FreeCompanyLoginLogout)
if (Plugin.PlayerState.HomeWorld.IsValid)
world = Plugin.PlayerState.HomeWorld;
var name = new List<Chunk> { new TextChunk(ChunkSource.None, null, player.PlayerName) };
if (world.Value.IsPublic)
{
name.AddRange([
new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld),
new TextChunk(ChunkSource.None, null, world.Value.Name.ExtractText())
]);
}
LogWindow.DrawChunks(name, false);
ImGui.Separator();
var validContentId = chunk.Message?.ContentId is not (null or 0);
if (ImGui.Selectable(Language.Context_SendTell))
{
// Eureka, Bozja and Occult need special handling as tells work different
if (!Sheets.IsInForay())
{
LogWindow.Chat = $"/tell {player.PlayerName}";
if (world.Value.IsPublic)
LogWindow.Chat += $"@{world.Value.Name}";
LogWindow.Chat += " ";
}
else if (validContentId)
{
LogWindow.Plugin.Functions.Chat.SetEurekaTellChannel(player.PlayerName, world.Value.Name.ToString(), (ushort) world.RowId, 0, chunk.Message!.ContentId, 0, false);
}
LogWindow.Activate = true;
}
if (world.Value.IsPublic)
{
var party = Plugin.PartyList;
var leader = party[(int) party.PartyLeaderIndex]?.ContentId;
var isLeader = party.Length == 0 || Plugin.PlayerState.ContentId == leader;
var member = party.FirstOrDefault(member => member.Name.TextValue == player.PlayerName && member.World.RowId == world.RowId);
var isInParty = member != null;
var inInstance = GameFunctions.GameFunctions.IsInInstance();
var inPartyInstance = Sheets.TerritorySheet.GetRow(Plugin.ClientState.TerritoryType).TerritoryIntendedUse.RowId is (41 or 47 or 48 or 52 or 53 or 61);
if (isLeader)
{
if (!isInParty)
{
if (inInstance && inPartyInstance)
{
if (validContentId && ImGui.Selectable(Language.Context_InviteToParty))
GameFunctions.Party.InviteInInstance(chunk.Message!.ContentId);
}
else if (!inInstance)
{
using var menu = ImRaii.Menu(Language.Context_InviteToParty);
if (menu.Success)
{
if (ImGui.Selectable(Language.Context_InviteToParty_SameWorld))
GameFunctions.Party.InviteSameWorld(player.PlayerName, (ushort)world.RowId, chunk.Message?.ContentId ?? 0);
if (validContentId && ImGui.Selectable(Language.Context_InviteToParty_DifferentWorld))
GameFunctions.Party.InviteOtherWorld(chunk.Message!.ContentId, (ushort)world.RowId);
}
}
}
if (isInParty && member != null && (!inInstance || (inInstance && inPartyInstance)))
{
if (ImGui.Selectable(Language.Context_Promote))
GameFunctions.Party.Promote(player.PlayerName, member.ContentId);
if (ImGui.Selectable(Language.Context_KickFromParty))
GameFunctions.Party.Kick(player.PlayerName, member.ContentId);
}
}
var isFriend = GameFunctions.GameFunctions.GetFriends().Any(friend => friend.NameString == player.PlayerName && friend.HomeWorld == world.RowId);
if (!isFriend && ImGui.Selectable(Language.Context_SendFriendRequest))
LogWindow.Plugin.Functions.SendFriendRequest(player.PlayerName, (ushort) world.RowId);
using (var menuBlockFunctions = ImRaii.Menu(Language.Context_BlockFunctions))
{
if (menuBlockFunctions.Success)
{
if (ImGui.Selectable(Language.Context_AddToBlacklist))
LogWindow.Plugin.Functions.AddToBlacklist(player.PlayerName, (ushort)world.RowId);
if (chunk.Message != null)
{
var message = chunk.Message;
if (message.AccountId != 0 && ImGui.Selectable(Language.Context_AddToMuteList))
LogWindow.Plugin.Functions.AddToMuteList(message.AccountId, message.ContentId, player.PlayerName, (short) world.RowId);
if (ImGui.Selectable(Language.Context_AddToTermsFilter))
LogWindow.Plugin.Functions.AddToTermsList(message.ContentSource);
}
}
}
if (GameFunctions.GameFunctions.IsMentor() && ImGui.Selectable(Language.Context_InviteToNoviceNetwork))
GameFunctions.Context.InviteToNoviceNetwork(player.PlayerName, (ushort) world.RowId);
}
var inputChannel = chunk.Message?.Code.Type.ToInputChannel();
if (inputChannel != null && ImGui.Selectable(Language.Context_ReplyInSelectedChatMode))
{
LogWindow.SetChannel(inputChannel.Value);
LogWindow.Activate = true;
}
if (ImGui.Selectable(Language.Context_Target) && FindCharacterForPayload(player) is { } obj)
Plugin.TargetManager.Target = obj;
if (validContentId && ImGui.Selectable(Language.Context_AdventurerPlate))
if (!GameFunctions.GameFunctions.TryOpenAdventurerPlate(chunk.Message!.ContentId))
WrapperUtil.AddNotification(Language.Context_AdventurerPlateError, NotificationType.Warning);
}
private IPlayerCharacter? FindCharacterForPayload(PlayerPayload payload)
{
foreach (var obj in Plugin.ObjectTable)
{
if (obj is not IPlayerCharacter character)
continue;
if (character.Name.TextValue != payload.PlayerName)
continue;
if (payload.World.Value.IsPublic && character.HomeWorld.RowId != payload.World.RowId)
continue;
return character;
}
return null;
}
private void DrawUriPopup(UriPayload uri)
{
ImGui.TextUnformatted(string.Format(Language.Context_URLDomain, uri.Uri.Authority));
ImGuiUtil.WarningText(Language.Context_URLWarning, false);
ImGui.Separator();
if (ImGui.Selectable(Language.Context_OpenInBrowser))
WrapperUtil.TryOpenUri(uri.Uri);
if (ImGui.Selectable(Language.Context_CopyLink))
{
ImGui.SetClipboardText(uri.Uri.ToString());
WrapperUtil.AddNotification(Language.Context_CopyLinkNotification, NotificationType.Info);
}
}
private void DrawStatusPopup(StatusPayload status)
{
if (Plugin.TextureProvider.GetFromGameIcon(new GameIconLookup(status.Status.Value.Icon)).GetWrapOrDefault() is { } icon)
InlineIcon(icon);
var builder = new SeStringBuilder();
var nameValue = status.Status.Value.Name.ToString();
switch (status.Status.Value.StatusCategory)
{
case 1:
builder.AddUiForeground($"{SeIconChar.Buff.ToIconString()}{nameValue}", 517);
break;
case 2:
builder.AddUiForeground($"{SeIconChar.Debuff.ToIconString()}{nameValue}", 518);
break;
default:
builder.AddUiForeground(nameValue, 1);
break;
}
LogWindow.DrawChunks(ChunkUtil.ToChunks(builder.BuiltString, ChunkSource.None, null).ToList(), false);
ImGui.Separator();
if (ImGui.Selectable(Language.Context_Link))
{
GameFunctions.Context.LinkStatus(status.Status.RowId);
LogWindow.Chat += " <status>";
}
}
}
+592
View File
@@ -0,0 +1,592 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using HellionChat.Ipc;
using HellionChat.Resources;
using HellionChat.Ui;
using HellionChat.Util;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Interface.Windowing;
using Dalamud.IoC;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.ImGuiFileDialog;
namespace HellionChat;
// ReSharper disable once ClassNeverInstantiated.Global
public sealed class Plugin : IDalamudPlugin
{
public const string PluginName = "Hellion Chat";
[PluginService] public static IPluginLog Log { get; private set; } = null!;
[PluginService] public static IDalamudPluginInterface Interface { get; private set; } = null!;
[PluginService] public static IChatGui ChatGui { get; private set; } = null!;
[PluginService] public static IClientState ClientState { get; private set; } = null!;
[PluginService] public static ICommandManager CommandManager { get; private set; } = null!;
[PluginService] public static ICondition Condition { get; private set; } = null!;
[PluginService] public static IDataManager DataManager { get; private set; } = null!;
[PluginService] public static IFramework Framework { get; private set; } = null!;
[PluginService] public static IGameGui GameGui { get; private set; } = null!;
[PluginService] public static IKeyState KeyState { get; private set; } = null!;
[PluginService] public static IObjectTable ObjectTable { get; private set; } = null!;
[PluginService] public static IPartyList PartyList { get; private set; } = null!;
[PluginService] public static ITargetManager TargetManager { get; private set; } = null!;
[PluginService] public static ITextureProvider TextureProvider { get; private set; } = null!;
[PluginService] public static IGameInteropProvider GameInteropProvider { get; private set; } = null!;
[PluginService] public static IGameConfig GameConfig { get; private set; } = null!;
[PluginService] public static INotificationManager Notification { get; private set; } = null!;
[PluginService] public static IAddonLifecycle AddonLifecycle { get; private set; } = null!;
[PluginService] public static IPlayerState PlayerState { get; private set; } = null!;
[PluginService] public static ISeStringEvaluator Evaluator { get; private set; } = null!;
public static Configuration Config = null!;
public static FileDialogManager FileDialogManager { get; private set; } = null!;
public readonly WindowSystem WindowSystem = new(PluginName);
public SettingsWindow SettingsWindow { get; }
public ChatLogWindow ChatLogWindow { get; }
public DbViewer DbViewer { get; }
public InputPreview InputPreview { get; }
public CommandHelpWindow CommandHelpWindow { get; }
public SeStringDebugger SeStringDebugger { get; }
public FirstRunWizard FirstRunWizard { get; }
public DebuggerWindow DebuggerWindow { get; }
internal Commands Commands { get; }
internal GameFunctions.GameFunctions Functions { get; }
internal MessageManager MessageManager { get; }
internal AutoTellTabsService AutoTellTabsService { get; }
internal IpcManager Ipc { get; }
internal ExtraChat ExtraChat { get; }
internal TypingIpc TypingIpc { get; }
internal FontManager FontManager { get; }
internal int DeferredSaveFrames = -1;
// Serialises retention sweeps. The 24h auto-sweep on plugin load and
// the manual button in the Privacy tab both run on background threads;
// without this gate, hitting the manual button moments after a fresh
// plugin start would launch two sweeps in parallel and the second one
// would just re-do work the first one already finished. The lock guards
// the flag — the flag check itself bails before we touch the database.
// Volatile because the ImGui thread reads the flag outside the lock to
// gate the manual button; without it the JIT may cache the value in a
// register and miss the background-thread update.
internal readonly object RetentionSweepLock = new();
internal volatile bool RetentionSweepRunning;
internal DateTime GameStarted { get; }
// Tab management needs to happen outside the chatlog window class for access reasons
internal int LastTab { get; set; }
internal int? WantedTab { get; set; }
internal Tab CurrentTab
{
get
{
var i = LastTab;
return i > -1 && i < Config.Tabs.Count ? Config.Tabs[i] : new Tab();
}
}
public Plugin()
{
// Refuse to start if upstream Chat 2 is loaded — prevents IPC
// channel collisions and double-replacement of the in-game chat
// window. Throwing here makes Dalamud abort the load cleanly with
// our localized message instead of crashing FFXIV mid-frame.
ChatTwoConflictDetector.ThrowIfChatTwoIsLoaded(Interface);
try
{
GameStarted = Process.GetCurrentProcess().StartTime.ToUniversalTime();
// Hellion Chat: take over config + database from upstream ChatTwo
// before Dalamud loads our plugin config. Idempotent: only acts on
// the first start where the legacy paths exist and ours don't.
MigrateFromChatTwoLayout();
Config = Interface.GetPluginConfig() as Configuration ?? new Configuration();
// Hellion Chat — Auto-Tell-Tabs Defense-in-Depth. SaveConfig
// already strips temp tabs before persistence, but a previous
// crash or external write could have left them in the JSON.
// Drop them on load to guarantee the session-only invariant.
Config.Tabs.RemoveAll(t => t.IsTempTab);
// Hellion Chat v9 → v10 — wipes the configuration so the new 8-tab
// layout starts from defaults instead of mapping every previous setting
// to its new position. Backup-Failure ist non-fatal, der Wipe läuft
// trotzdem; dem User fehlt dann nur das manuelle Restore-Sicherheitsnetz.
if (Config.Version < 10)
{
var pluginConfigsDir = Interface.ConfigDirectory.Parent?.FullName;
if (pluginConfigsDir is not null)
{
var liveConfigPath = Path.Combine(pluginConfigsDir, $"{Interface.InternalName}.json");
var backupPath = Path.Combine(pluginConfigsDir, $"{Interface.InternalName}.json.pre-v10-backup");
try
{
if (File.Exists(liveConfigPath))
{
File.Copy(liveConfigPath, backupPath, overwrite: true);
}
}
catch (Exception ex)
{
Log.Warning(ex, "HellionChat: pre-v10 config backup failed");
}
}
Config = new Configuration
{
Version = 10,
FirstRunCompleted = true,
};
SaveConfig();
Notification.AddNotification(new Dalamud.Interface.ImGuiNotification.Notification
{
Title = HellionStrings.SettingsRefactor_Migration_Title,
Content = HellionStrings.SettingsRefactor_Migration_Content,
Type = Dalamud.Interface.ImGuiNotification.NotificationType.Info,
InitialDuration = TimeSpan.FromSeconds(25),
});
}
// Hellion Chat v10 → v11 — adds the global Configuration.PopOutInputEnabled
// master switch and SeenPopOutInputHint flag for the v0.6.0 pop-out
// input feature. Lightweight migration: defaults both fields,
// no user-facing notification because the change is opt-in only.
if (Config.Version < 11)
{
Config.PopOutInputEnabled = false;
Config.SeenPopOutInputHint = false;
Config.Version = 11;
SaveConfig();
Log.Information(
"Migrated config v10 → v11: PopOutInputEnabled added (global, default off), " +
"SeenPopOutInputHint added (default false)");
}
// Hellion Chat v11 → v12 — flips Configuration.PopOutInputEnabled from
// the v0.6.0 opt-in default (false) to opt-out (true) per v0.6.1 UX
// polish. Hard-flip is a deliberate design call (see Spec section 5.7);
// users are notified via the v0.6.1 hint banner (SeenPopOutHeaderHint
// reset). Re-toggle after migration is preserved because this block
// only fires for Version < 12.
if (Config.Version < 12)
{
Config.PopOutInputEnabled = true;
Config.SeenPopOutHeaderHint = false;
Config.Version = 12;
SaveConfig();
Log.Information(
"Migrated config v11 → v12: PopOutInputEnabled hard-flipped to true (v0.6.1 default), " +
"SeenPopOutHeaderHint reset to false (v0.6.1 banner re-armed)");
}
// Hellion default tab layout for first-run and v10-wipe.
// General catches player chat plus active gameplay events; the
// System tab takes the technical noise so it does not bury real
// conversation. Beginner tab only appears when the Novice
// Network is enabled in Audio and Notifications, otherwise it
// would just sit empty.
if (Config.Tabs.Count == 0)
{
Config.Tabs.Add(TabsUtil.VanillaGeneral);
Config.Tabs.Add(TabsUtil.HellionSystem);
Config.Tabs.Add(TabsUtil.HellionFreeCompany);
Config.Tabs.Add(TabsUtil.HellionParty);
if (Config.ShowNoviceNetwork)
Config.Tabs.Add(TabsUtil.HellionBeginner);
Config.Tabs.Add(TabsUtil.HellionLinkshell);
Config.Tabs.Add(TabsUtil.VanillaTellExclusive);
}
LanguageChanged(Interface.UiLanguage);
ImGuiUtil.Initialize(this);
FileDialogManager = new FileDialogManager();
Commands = new Commands();
Functions = new GameFunctions.GameFunctions(this);
Ipc = new IpcManager();
TypingIpc = new TypingIpc(this);
ExtraChat = new ExtraChat();
FontManager = new FontManager();
MessageManager = new MessageManager(this); // Does it require UI?
// Hellion Chat — Auto-Tell-Tabs service. Subscribes to the
// MessageManager's MessageProcessed event for live tells and
// to ClientState.Logout for the cleanup pass. Created after
// MessageManager so the constructor can hand off the live
// store and event source.
AutoTellTabsService = new AutoTellTabsService(this, MessageManager, MessageManager.Store);
AutoTellTabsService.Initialize();
// Hellion Chat — daily retention sweep, off-thread so it never
// blocks plugin load. Skips itself when disabled or already ran
// within the past 24 hours.
RunRetentionSweepIfDue();
ChatLogWindow = new ChatLogWindow(this);
SettingsWindow = new SettingsWindow(this);
DbViewer = new DbViewer(this);
InputPreview = new InputPreview(ChatLogWindow);
CommandHelpWindow = new CommandHelpWindow(ChatLogWindow);
SeStringDebugger = new SeStringDebugger(this);
DebuggerWindow = new DebuggerWindow(this);
FirstRunWizard = new FirstRunWizard(this);
WindowSystem.AddWindow(ChatLogWindow);
WindowSystem.AddWindow(SettingsWindow);
WindowSystem.AddWindow(DbViewer);
WindowSystem.AddWindow(InputPreview);
WindowSystem.AddWindow(CommandHelpWindow);
WindowSystem.AddWindow(SeStringDebugger);
WindowSystem.AddWindow(DebuggerWindow);
WindowSystem.AddWindow(FirstRunWizard);
// Open the wizard on a fresh install. Existing ChatTwo users have
// FirstRunCompleted set to true by the v6→v7 migration above.
if (!Config.FirstRunCompleted)
FirstRunWizard.IsOpen = true;
FontManager.BuildFonts();
Interface.UiBuilder.DisableCutsceneUiHide = true;
Interface.UiBuilder.DisableGposeUiHide = true;
// let all the other components register, then initialize commands
Commands.Initialise();
if (Interface.Reason is not PluginLoadReason.Boot)
MessageManager.FilterAllTabsAsync();
Framework.Update += FrameworkUpdate;
Interface.UiBuilder.Draw += Draw;
Interface.LanguageChanged += LanguageChanged;
// Hellion Chat — surface a "main UI" entry point so Dalamud's
// plugin list shows the Open-Plugin button. Settings is the
// most useful landing place; OpenConfigUi is already wired to
// the same toggle inside SettingsWindow.
Interface.UiBuilder.OpenMainUi += OpenMainUi;
if (Config.ShowEmotes)
_ = EmoteCache.LoadData(); // Fire-and-forget intentional, exceptions are caught inside
#if !DEBUG
// Avoid 300ms hitch when sending first message by preloading the
// auto-translate cache. Don't do this in debug because it makes
// profiling difficult.
AutoTranslate.PreloadCache();
#endif
}
catch (Exception ex)
{
Log.Error(ex, "Plugin load threw an error, turning off plugin");
Dispose();
// Re-throw the exception to fail the plugin load.
throw;
}
}
// Suppressing this warning because Dispose() is called in Plugin() if the
// load fails, so some values may not be initialized.
[SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract")]
public void Dispose()
{
Interface.UiBuilder.OpenMainUi -= OpenMainUi;
Interface.LanguageChanged -= LanguageChanged;
Interface.UiBuilder.Draw -= Draw;
Framework.Update -= FrameworkUpdate;
GameFunctions.GameFunctions.SetChatInteractable(true);
WindowSystem?.RemoveAllWindows();
ChatLogWindow?.Dispose();
DbViewer?.Dispose();
InputPreview?.Dispose();
SettingsWindow?.Dispose();
DebuggerWindow?.Dispose();
SeStringDebugger?.Dispose();
TypingIpc?.Dispose();
ExtraChat?.Dispose();
Ipc?.Dispose();
// Dispose the Auto-Tell-Tabs service before MessageManager so it
// can cleanly unsubscribe from the MessageProcessed event before
// its source goes away.
AutoTellTabsService?.Dispose();
MessageManager?.DisposeAsync().AsTask().Wait();
Functions?.Dispose();
Commands?.Dispose();
EmoteCache.Dispose();
}
private static void MigrateFromChatTwoLayout()
{
var pluginConfigsDir = Interface.ConfigDirectory.Parent?.FullName;
if (pluginConfigsDir is null)
return;
var legacyConfigFile = Path.Combine(pluginConfigsDir, "ChatTwo.json");
var legacyConfigDir = Path.Combine(pluginConfigsDir, "ChatTwo");
var ourConfigFile = Path.Combine(pluginConfigsDir, "HellionChat.json");
var ourConfigDir = Interface.ConfigDirectory.FullName;
// Track whether anything legitimately blocked us. The most common
// cause is upstream Chat 2 still being loaded — its SQLite handle
// keeps chat-sqlite.db locked and File.Move throws IOException.
var lockedBlocker = false;
try
{
if (!File.Exists(ourConfigFile) && File.Exists(legacyConfigFile))
{
File.Move(legacyConfigFile, ourConfigFile);
Log.Information($"HellionChat: migrated config file {legacyConfigFile} → {ourConfigFile}");
}
}
catch (IOException e)
{
Log.Warning(e, $"HellionChat: config file move blocked, leaving {legacyConfigFile} in place");
lockedBlocker = true;
}
// The plugin's ConfigDirectory may already exist on first load
// (Dalamud creates it), so check at the file level instead of
// skipping when the directory is present. Move every legacy
// entry whose target name is not occupied yet, then remove the
// source dir if it ends up empty. Each move is wrapped on its
// own so a single locked file (the SQLite db while ChatTwo still
// runs) does not abandon the rest of the migration.
if (!Directory.Exists(legacyConfigDir))
return;
try
{
Directory.CreateDirectory(ourConfigDir);
foreach (var file in Directory.EnumerateFiles(legacyConfigDir))
{
var target = Path.Combine(ourConfigDir, Path.GetFileName(file));
if (File.Exists(target))
continue;
try
{
File.Move(file, target);
Log.Information($"HellionChat: migrated file {file} → {target}");
}
catch (IOException e)
{
Log.Warning(e, $"HellionChat: file move blocked for {file}, will retry on next load");
lockedBlocker = true;
}
}
foreach (var dir in Directory.EnumerateDirectories(legacyConfigDir))
{
var target = Path.Combine(ourConfigDir, Path.GetFileName(dir));
if (Directory.Exists(target))
continue;
try
{
Directory.Move(dir, target);
Log.Information($"HellionChat: migrated subdir {dir} → {target}");
}
catch (IOException e)
{
Log.Warning(e, $"HellionChat: subdir move blocked for {dir}, will retry on next load");
lockedBlocker = true;
}
}
if (!Directory.EnumerateFileSystemEntries(legacyConfigDir).Any())
{
Directory.Delete(legacyConfigDir);
Log.Information($"HellionChat: removed empty legacy dir {legacyConfigDir}");
}
}
catch (Exception e)
{
Log.Error(e, "HellionChat: layout migration failed, continuing with whatever exists");
}
if (lockedBlocker)
{
// Surface the most common cause to the user as a notification
// so they don't think Hellion Chat lost their history when in
// fact upstream Chat 2 was still holding the database file.
Notification.AddNotification(new Dalamud.Interface.ImGuiNotification.Notification
{
Title = "Hellion Chat",
Content = "Could not migrate the Chat 2 database — the file appears to be in use. " +
"Disable Chat 2, fully close the game, then start it again. " +
"See the README troubleshooting section if the issue persists.",
Type = Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
InitialDuration = TimeSpan.FromSeconds(30),
});
}
}
private void OpenMainUi()
{
// Settings is the most useful landing surface — same target as the
// Configure button. SettingsWindow.Toggle is internal and already
// wired to OpenConfigUi, so re-using IsOpen keeps both entry points
// behaviourally identical.
SettingsWindow.IsOpen = !SettingsWindow.IsOpen;
}
private void RunRetentionSweepIfDue()
{
if (!Config.RetentionEnabled)
return;
if (DateTimeOffset.UtcNow - Config.RetentionLastRunAt < TimeSpan.FromHours(24))
return;
// Snapshot the policy so the user can edit settings while we run.
// Spec defaults form the baseline; explicit user overrides win.
var policy = new Dictionary<int, int>();
foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays)
policy[(int)(ushort)type] = days;
foreach (var (type, days) in Config.RetentionPerChannelDays)
policy[(int)(ushort)type] = days;
var defaultDays = Config.RetentionDefaultDays;
new Thread(() =>
{
// Bail out cheaply if a manual sweep is already in flight; the
// lock around the actual work would queue us up otherwise and
// we would just re-do whatever the manual run already did.
lock (RetentionSweepLock)
{
if (RetentionSweepRunning)
return;
RetentionSweepRunning = true;
}
try
{
var deleted = MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays);
Config.RetentionLastRunAt = DateTimeOffset.UtcNow;
SaveConfig();
if (deleted > 0)
{
Log.Information($"Retention sweep deleted {deleted} expired messages.");
Framework.Run(() =>
{
MessageManager.ClearAllTabs();
MessageManager.FilterAllTabsAsync();
}).Wait();
}
else
{
Log.Information("Retention sweep ran, nothing expired.");
}
}
catch (Exception e)
{
Log.Error(e, "Retention sweep failed");
}
finally
{
lock (RetentionSweepLock)
RetentionSweepRunning = false;
}
}) { IsBackground = true }.Start();
}
private void Draw()
{
// Hellion theme is pushed once per frame here so every plugin window
// (chat log, settings, viewers, wizard, file dialog) renders with
// the same palette. Skipping the push leaves the upstream Dalamud
// look untouched for users who flipped the toggle off.
using IDisposable? _style = Config.HellionThemeEnabled
? HellionStyle.PushGlobal(Config.HellionThemeWindowOpacity)
: null;
ChatLogWindow.BeginFrame();
if (Config.HideInLoadingScreens && Condition[ConditionFlag.BetweenAreas])
{
ChatLogWindow.FinalizeFrame();
TypingIpc.Update();
return;
}
ChatLogWindow.HideStateCheck();
Interface.UiBuilder.DisableUserUiHide = !Config.HideWhenUiHidden;
ChatLogWindow.DefaultText = ImGui.GetStyle().Colors[(int) ImGuiCol.Text];
using ((Config.FontsEnabled ? FontManager.RegularFont : FontManager.Axis).Push())
WindowSystem.Draw();
ChatLogWindow.FinalizeFrame();
TypingIpc.Update();
FileDialogManager.Draw();
}
internal void SaveConfig()
{
// Hellion Chat — Auto-Tell-Tabs are session-only. Strip them out
// before serialization so a crash mid-session can never persist
// them. We snapshot the full tab list first and restore it after
// the save, preserving the user's order and open conversations.
var snapshot = Config.Tabs.ToList();
Config.Tabs.RemoveAll(t => t.IsTempTab);
Interface.SavePluginConfig(Config);
Config.Tabs.Clear();
Config.Tabs.AddRange(snapshot);
}
internal void LanguageChanged(string langCode)
{
var info = Config.LanguageOverride is LanguageOverride.None
? new CultureInfo(langCode)
: new CultureInfo(Config.LanguageOverride.Code());
Language.Culture = info;
HellionStrings.Culture = info;
}
private static readonly string[] ChatAddonNames =
[
"ChatLog",
"ChatLogPanel_0",
"ChatLogPanel_1",
"ChatLogPanel_2",
"ChatLogPanel_3"
];
private void FrameworkUpdate(IFramework framework)
{
if (DeferredSaveFrames >= 0 && DeferredSaveFrames-- == 0)
SaveConfig();
if (!Config.HideChat)
return;
foreach (var name in ChatAddonNames)
if (GameFunctions.GameFunctions.IsAddonInteractable(name))
GameFunctions.GameFunctions.SetAddonInteractable(name, false);
}
public static bool InBattle => Condition[ConditionFlag.InCombat];
public static bool GposeActive => Condition[ConditionFlag.WatchingCutscene];
public static bool CutsceneActive => Condition[ConditionFlag.OccupiedInCutSceneEvent] || Condition[ConditionFlag.WatchingCutscene78];
}
+111
View File
@@ -0,0 +1,111 @@
using HellionChat.Code;
namespace HellionChat.Privacy;
internal static class PrivacyDefaults
{
// Privacy-First default whitelist (DSGVO Art. 25 - Privacy by Default).
// Only the player's own conversations are persisted out-of-the-box.
// Public chat (Say/Shout/Yell), Novice Network, NPC dialogue, system
// logs and battle messages are NOT persisted unless the user opts in.
internal static readonly IReadOnlySet<ChatType> PrivacyFirstWhitelist = new HashSet<ChatType>
{
ChatType.TellIncoming,
ChatType.TellOutgoing,
ChatType.Party,
ChatType.CrossParty,
ChatType.Alliance,
ChatType.FreeCompany,
ChatType.Linkshell1,
ChatType.Linkshell2,
ChatType.Linkshell3,
ChatType.Linkshell4,
ChatType.Linkshell5,
ChatType.Linkshell6,
ChatType.Linkshell7,
ChatType.Linkshell8,
ChatType.CrossLinkshell1,
ChatType.CrossLinkshell2,
ChatType.CrossLinkshell3,
ChatType.CrossLinkshell4,
ChatType.CrossLinkshell5,
ChatType.CrossLinkshell6,
ChatType.CrossLinkshell7,
ChatType.CrossLinkshell8,
ChatType.ExtraChatLinkshell1,
ChatType.ExtraChatLinkshell2,
ChatType.ExtraChatLinkshell3,
ChatType.ExtraChatLinkshell4,
ChatType.ExtraChatLinkshell5,
ChatType.ExtraChatLinkshell6,
ChatType.ExtraChatLinkshell7,
ChatType.ExtraChatLinkshell8,
};
// Default retention windows per channel (in days). Channels not listed
// here fall back to Configuration.RetentionDefaultDays. Reflects the
// design spec: Tells 365, own-conversation channels 90, everything else
// shorter via the global default.
internal static readonly IReadOnlyDictionary<ChatType, int> DefaultRetentionDays = new Dictionary<ChatType, int>
{
[ChatType.TellIncoming] = 365,
[ChatType.TellOutgoing] = 365,
[ChatType.Party] = 90,
[ChatType.CrossParty] = 90,
[ChatType.Alliance] = 90,
[ChatType.PvpTeam] = 90,
[ChatType.FreeCompany] = 90,
[ChatType.Linkshell1] = 90,
[ChatType.Linkshell2] = 90,
[ChatType.Linkshell3] = 90,
[ChatType.Linkshell4] = 90,
[ChatType.Linkshell5] = 90,
[ChatType.Linkshell6] = 90,
[ChatType.Linkshell7] = 90,
[ChatType.Linkshell8] = 90,
[ChatType.CrossLinkshell1] = 90,
[ChatType.CrossLinkshell2] = 90,
[ChatType.CrossLinkshell3] = 90,
[ChatType.CrossLinkshell4] = 90,
[ChatType.CrossLinkshell5] = 90,
[ChatType.CrossLinkshell6] = 90,
[ChatType.CrossLinkshell7] = 90,
[ChatType.CrossLinkshell8] = 90,
[ChatType.ExtraChatLinkshell1] = 90,
[ChatType.ExtraChatLinkshell2] = 90,
[ChatType.ExtraChatLinkshell3] = 90,
[ChatType.ExtraChatLinkshell4] = 90,
[ChatType.ExtraChatLinkshell5] = 90,
[ChatType.ExtraChatLinkshell6] = 90,
[ChatType.ExtraChatLinkshell7] = 90,
[ChatType.ExtraChatLinkshell8] = 90,
};
// Casual profile = Privacy-First plus public chat (Say/Shout/Yell, both
// emote types, Novice Network), kept for a short 24-hour window so the
// last RP scene or shout trade is still searchable but third-party data
// doesn't accumulate forever.
internal static readonly IReadOnlySet<ChatType> CasualWhitelist = new HashSet<ChatType>(PrivacyFirstWhitelist)
{
ChatType.Say,
ChatType.Shout,
ChatType.Yell,
ChatType.CustomEmote,
ChatType.StandardEmote,
ChatType.NoviceNetwork,
};
internal static readonly IReadOnlyDictionary<ChatType, int> CasualRetentionOverrides = new Dictionary<ChatType, int>
{
[ChatType.Say] = 1,
[ChatType.Shout] = 1,
[ChatType.Yell] = 1,
[ChatType.CustomEmote] = 1,
[ChatType.StandardEmote] = 1,
[ChatType.NoviceNetwork] = 1,
};
}
+316
View File
@@ -0,0 +1,316 @@
using System.Collections.Generic;
using HellionChat.Code;
using HellionChat.Util;
namespace HellionChat.Resources;
// Hellion Chat — v0.6.0 built-in colour presets for the ChatColours
// settings section. Read-only static data; users apply a preset via the
// settings UI which overwrites Configuration.ChatColours immediately.
// Battle-channel types are intentionally NOT covered by the stylistic
// presets so that combat-log tuning the user has done stays intact.
public sealed record ChatColourPreset(
string DisplayName,
string LocalizationKey,
bool IsBrandPreset,
IReadOnlyDictionary<ChatType, uint> Colours);
public static class ChatColourPresets
{
public static IReadOnlyDictionary<string, ChatColourPreset> All { get; } = BuildAll();
private static Dictionary<string, ChatColourPreset> BuildAll()
{
return new Dictionary<string, ChatColourPreset>
{
["Default"] = new(
DisplayName: "ChatTwo Default",
LocalizationKey: "ChatColourPresets_Default",
IsBrandPreset: false,
Colours: BuildDefault()),
["HighContrast"] = new(
DisplayName: "High-Contrast",
LocalizationKey: "ChatColourPresets_HighContrast",
IsBrandPreset: false,
Colours: BuildHighContrast()),
["Pastell"] = new(
DisplayName: "Pastell",
LocalizationKey: "ChatColourPresets_Pastell",
IsBrandPreset: false,
Colours: BuildPastell()),
["DarkModeTuned"] = new(
DisplayName: "Dark-Mode-Tuned",
LocalizationKey: "ChatColourPresets_DarkModeTuned",
IsBrandPreset: false,
Colours: BuildDarkModeTuned()),
["Hellion"] = new(
DisplayName: "Hellion",
LocalizationKey: "ChatColourPresets_Hellion",
IsBrandPreset: true,
Colours: BuildHellion()),
["NightBlue"] = new(
DisplayName: "Night Blue",
LocalizationKey: "ChatColourPresets_NightBlue",
IsBrandPreset: false,
Colours: BuildNightBlue()),
["IndigoViolet"] = new(
DisplayName: "Indigo Violet",
LocalizationKey: "ChatColourPresets_IndigoViolet",
IsBrandPreset: false,
Colours: BuildIndigoViolet()),
};
}
// The Default preset spiegelt 1:1 die Werte aus ChatTypeExt.DefaultColor.
// Channels ohne Default-Wert (return null) werden ausgelassen — wer sie
// anwenden will, behält seine aktuelle Farbe.
private static IReadOnlyDictionary<ChatType, uint> BuildDefault()
{
var dict = new Dictionary<ChatType, uint>();
foreach (var (_, types) in ChatTypeExt.SortOrder)
{
foreach (var type in types)
{
var def = type.DefaultColor();
if (def.HasValue)
dict[type] = def.Value;
}
}
return dict;
}
private static IReadOnlyDictionary<ChatType, uint> BuildHighContrast()
{
return new Dictionary<ChatType, uint>
{
[ChatType.Say] = ColourUtil.ComponentsToRgba(255, 255, 255),
[ChatType.Yell] = ColourUtil.ComponentsToRgba(255, 192, 0),
[ChatType.Shout] = ColourUtil.ComponentsToRgba(255, 96, 0),
[ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(255, 128, 255),
[ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(255, 128, 255),
[ChatType.Party] = ColourUtil.ComponentsToRgba(128, 192, 255),
[ChatType.Alliance] = ColourUtil.ComponentsToRgba(255, 128, 64),
[ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(96, 192, 255),
[ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(192, 255, 64),
[ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(255, 128, 128),
[ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(255, 192, 128),
[ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(255, 255, 128),
[ChatType.Linkshell4] = ColourUtil.ComponentsToRgba(192, 255, 128),
[ChatType.Linkshell5] = ColourUtil.ComponentsToRgba(128, 255, 192),
[ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(128, 192, 255),
[ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(192, 128, 255),
[ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(255, 128, 192),
[ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(255, 96, 96),
[ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(255, 160, 96),
[ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(255, 255, 96),
[ChatType.CrossLinkshell4] = ColourUtil.ComponentsToRgba(160, 255, 96),
[ChatType.CrossLinkshell5] = ColourUtil.ComponentsToRgba(96, 255, 160),
[ChatType.CrossLinkshell6] = ColourUtil.ComponentsToRgba(96, 160, 255),
[ChatType.CrossLinkshell7] = ColourUtil.ComponentsToRgba(160, 96, 255),
[ChatType.CrossLinkshell8] = ColourUtil.ComponentsToRgba(255, 96, 160),
};
}
private static IReadOnlyDictionary<ChatType, uint> BuildPastell()
{
return new Dictionary<ChatType, uint>
{
[ChatType.Say] = ColourUtil.ComponentsToRgba(232, 232, 232),
[ChatType.Yell] = ColourUtil.ComponentsToRgba(245, 216, 155),
[ChatType.Shout] = ColourUtil.ComponentsToRgba(245, 176, 155),
[ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(224, 176, 224),
[ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(224, 176, 224),
[ChatType.Party] = ColourUtil.ComponentsToRgba(176, 204, 224),
[ChatType.Alliance] = ColourUtil.ComponentsToRgba(224, 192, 160),
[ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(168, 200, 224),
[ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(200, 224, 176),
[ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(224, 176, 176),
[ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(224, 200, 176),
[ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(224, 224, 176),
[ChatType.Linkshell4] = ColourUtil.ComponentsToRgba(200, 224, 176),
[ChatType.Linkshell5] = ColourUtil.ComponentsToRgba(176, 224, 200),
[ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(176, 200, 224),
[ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(200, 176, 224),
[ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(224, 176, 200),
[ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(224, 160, 160),
[ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(224, 192, 160),
[ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(224, 224, 160),
[ChatType.CrossLinkshell4] = ColourUtil.ComponentsToRgba(192, 224, 160),
[ChatType.CrossLinkshell5] = ColourUtil.ComponentsToRgba(160, 224, 192),
[ChatType.CrossLinkshell6] = ColourUtil.ComponentsToRgba(160, 192, 224),
[ChatType.CrossLinkshell7] = ColourUtil.ComponentsToRgba(192, 160, 224),
[ChatType.CrossLinkshell8] = ColourUtil.ComponentsToRgba(224, 160, 192),
};
}
private static IReadOnlyDictionary<ChatType, uint> BuildDarkModeTuned()
{
return new Dictionary<ChatType, uint>
{
[ChatType.Say] = ColourUtil.ComponentsToRgba(240, 240, 240),
[ChatType.Yell] = ColourUtil.ComponentsToRgba(255, 208, 64),
[ChatType.Shout] = ColourUtil.ComponentsToRgba(255, 128, 64),
[ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(255, 160, 255),
[ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(255, 160, 255),
[ChatType.Party] = ColourUtil.ComponentsToRgba(160, 208, 255),
[ChatType.Alliance] = ColourUtil.ComponentsToRgba(255, 160, 96),
[ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(128, 200, 255),
[ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(192, 255, 96),
[ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(255, 160, 160),
[ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(255, 192, 160),
[ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(255, 255, 160),
[ChatType.Linkshell4] = ColourUtil.ComponentsToRgba(192, 255, 160),
[ChatType.Linkshell5] = ColourUtil.ComponentsToRgba(160, 255, 192),
[ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(160, 192, 255),
[ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(192, 160, 255),
[ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(255, 160, 192),
[ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(255, 128, 128),
[ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(255, 160, 128),
[ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(255, 255, 128),
[ChatType.CrossLinkshell4] = ColourUtil.ComponentsToRgba(160, 255, 128),
[ChatType.CrossLinkshell5] = ColourUtil.ComponentsToRgba(128, 255, 160),
[ChatType.CrossLinkshell6] = ColourUtil.ComponentsToRgba(128, 160, 255),
[ChatType.CrossLinkshell7] = ColourUtil.ComponentsToRgba(160, 128, 255),
[ChatType.CrossLinkshell8] = ColourUtil.ComponentsToRgba(255, 128, 160),
};
}
// Hellion brand preset — Arctic Cyan + Ember Orange palette aus
// /mnt/ssd-fast/Projekte/hellion-media/hellion-media-website/BRANDING.md
// (Schema-Stand 2026-04-16). Channels sind über das ganze Brand-Spektrum
// verteilt damit jede Zeile auf einen Glance unterscheidbar ist:
// Cyan-Familie für Standard/Tell, Ember + Warning für laute Channels,
// Status-Farben (Success, Danger) für Linkshells. CrossLinkshells
// nutzen die dunkleren/sattersten Varianten derselben Hue-Familien.
private static IReadOnlyDictionary<ChatType, uint> BuildHellion()
{
return new Dictionary<ChatType, uint>
{
// Standard / Tell — Cyan-Familie (Brand-Primary)
[ChatType.Say] = ColourUtil.ComponentsToRgba(77, 217, 232), // Cyan-light #4DD9E8
[ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(0, 190, 210), // Brand Cyan #00BED2
[ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(0, 151, 167), // Cyan-dark #0097A7
// Laute Channels — Ember/Warning
[ChatType.Yell] = ColourUtil.ComponentsToRgba(240, 173, 78), // Warning #F0AD4E
[ChatType.Shout] = ColourUtil.ComponentsToRgba(249, 115, 22), // Brand Ember #F97316
// Gruppen-Channels — Success/Ember-dark/Cyan
[ChatType.Party] = ColourUtil.ComponentsToRgba(92, 184, 92), // Success #5CB85C
[ChatType.Alliance] = ColourUtil.ComponentsToRgba(232, 93, 4), // Ember-dark #E85D04
[ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(0, 190, 210), // Brand Cyan
[ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(77, 217, 232),// Cyan-light
// Linkshells 1-8 — über das ganze Brand-Spektrum verteilt
[ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(251, 146, 60), // Ember-light #FB923C
[ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(240, 173, 78), // Warning
[ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(92, 184, 92), // Success
[ChatType.Linkshell4] = ColourUtil.ComponentsToRgba(77, 217, 232), // Cyan-light
[ChatType.Linkshell5] = ColourUtil.ComponentsToRgba(0, 190, 210), // Brand Cyan
[ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(0, 151, 167), // Cyan-dark
[ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(249, 115, 22), // Brand Ember
[ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(217, 83, 79), // Danger #D9534F
// CrossWorld-Linkshells 1-8 — dunklere/sattersere Varianten
[ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(232, 93, 4), // Ember-dark
[ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(200, 140, 50), // Warning-dark
[ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(60, 140, 60), // Success-dark
[ChatType.CrossLinkshell4] = ColourUtil.ComponentsToRgba(0, 190, 210), // Brand Cyan
[ChatType.CrossLinkshell5] = ColourUtil.ComponentsToRgba(0, 151, 167), // Cyan-dark
[ChatType.CrossLinkshell6] = ColourUtil.ComponentsToRgba(0, 110, 130), // Cyan-darker
[ChatType.CrossLinkshell7] = ColourUtil.ComponentsToRgba(220, 90, 30), // Ember-medium
[ChatType.CrossLinkshell8] = ColourUtil.ComponentsToRgba(170, 60, 60), // Danger-dark
};
}
// Bonus preset — Night Blue, KAZAMA-Stimmungs-Theme aus
// /mnt/HDD-Data1/Obsidian/Vault/Systeme/KAZAMA/Theming/Night Blue + Indigo Violet Themes.md
// Klassisch, kühl, technisch — Marineblau-Tiefe ohne Lila-Anteil.
// Bewusst NICHT als Brand-Preset markiert (Vault-Boundary): die KAZAMA-Themes
// sind persönliche Stimmungs-Themes, nicht Teil des Hellion-Brand-Systems.
private static IReadOnlyDictionary<ChatType, uint> BuildNightBlue()
{
return new Dictionary<ChatType, uint>
{
// Standard / Tell — Royal Blue Akzent-Familie
[ChatType.Say] = ColourUtil.ComponentsToRgba(230, 237, 247), // text-primary
[ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(106, 176, 255),// akzent-hot
[ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(74, 144, 226), // akzent-primary
// Laute Channels — Warning/Danger Status-Töne
[ChatType.Yell] = ColourUtil.ComponentsToRgba(255, 184, 74), // warning
[ChatType.Shout] = ColourUtil.ComponentsToRgba(255, 92, 122), // danger
// Gruppen — Success/Akzent-Variations
[ChatType.Party] = ColourUtil.ComponentsToRgba(61, 220, 151), // success
[ChatType.Alliance] = ColourUtil.ComponentsToRgba(255, 144, 100), // warm-orange-light
[ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(74, 144, 226), // akzent-primary
[ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(140, 160, 191),// text-dim
// Linkshells 1-8 — über Spektrum verteilt
[ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(255, 184, 74),
[ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(255, 144, 100),
[ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(255, 220, 130),
[ChatType.Linkshell4] = ColourUtil.ComponentsToRgba(130, 220, 100),
[ChatType.Linkshell5] = ColourUtil.ComponentsToRgba(61, 220, 151),
[ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(100, 200, 220),
[ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(106, 176, 255),
[ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(140, 160, 191),
// CrossWorld-Linkshells — gedämpfte Variants
[ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(200, 130, 50),
[ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(220, 110, 80),
[ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(200, 180, 60),
[ChatType.CrossLinkshell4] = ColourUtil.ComponentsToRgba(90, 180, 80),
[ChatType.CrossLinkshell5] = ColourUtil.ComponentsToRgba(30, 170, 110),
[ChatType.CrossLinkshell6] = ColourUtil.ComponentsToRgba(50, 130, 170),
[ChatType.CrossLinkshell7] = ColourUtil.ComponentsToRgba(50, 110, 180),
[ChatType.CrossLinkshell8] = ColourUtil.ComponentsToRgba(90, 100, 130),
};
}
// Bonus preset — Indigo Violet, KAZAMA-Stimmungs-Theme aus demselben
// Vault-Doc. Warm-mystisch, "Galaxy/Glitter/Nordlicht" — tiefes Indigo
// mit kräftigem Violet-Akzent. Persönlicher Favorit (siehe Vault).
// Auch nicht als Brand-Preset (siehe NightBlue-Note oben).
private static IReadOnlyDictionary<ChatType, uint> BuildIndigoViolet()
{
return new Dictionary<ChatType, uint>
{
// Standard / Tell — Royal Violet Akzent-Familie
[ChatType.Say] = ColourUtil.ComponentsToRgba(240, 230, 255), // text-primary (light lavender)
[ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(176, 124, 255),// akzent-hot
[ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(139, 77, 222), // akzent-primary
// Laute Channels — geteilt mit Night Blue (Status-Farben)
[ChatType.Yell] = ColourUtil.ComponentsToRgba(255, 184, 74),
[ChatType.Shout] = ColourUtil.ComponentsToRgba(255, 92, 122),
// Gruppen
[ChatType.Party] = ColourUtil.ComponentsToRgba(61, 220, 151),
[ChatType.Alliance] = ColourUtil.ComponentsToRgba(255, 144, 100),
[ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(139, 77, 222), // akzent-primary
[ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(168, 144, 208),// text-dim
// Linkshells 1-8
[ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(255, 184, 74),
[ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(255, 144, 100),
[ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(255, 220, 130),
[ChatType.Linkshell4] = ColourUtil.ComponentsToRgba(200, 124, 255),
[ChatType.Linkshell5] = ColourUtil.ComponentsToRgba(176, 124, 255),
[ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(139, 77, 222),
[ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(130, 90, 200),
[ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(168, 144, 208),
// CrossWorld-Linkshells
[ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(200, 130, 50),
[ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(220, 110, 80),
[ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(200, 180, 60),
[ChatType.CrossLinkshell4] = ColourUtil.ComponentsToRgba(130, 80, 180),
[ChatType.CrossLinkshell5] = ColourUtil.ComponentsToRgba(100, 60, 160),
[ChatType.CrossLinkshell6] = ColourUtil.ComponentsToRgba(91, 42, 154),
[ChatType.CrossLinkshell7] = ColourUtil.ComponentsToRgba(80, 50, 130),
[ChatType.CrossLinkshell8] = ColourUtil.ComponentsToRgba(117, 96, 160),
};
}
}
+93
View File
@@ -0,0 +1,93 @@
Copyright 2013 The Exo 2 Project Authors (https://github.com/googlefonts/Exo-2.0)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+278
View File
@@ -0,0 +1,278 @@
//------------------------------------------------------------------------------
// <auto-generated>
// Hand-maintained strongly-typed accessor for HellionStrings.resx.
// Mirrors the layout of Language.Designer.cs so the same Plugin.cs
// LanguageChanged handler can update Culture for both classes.
// </auto-generated>
//------------------------------------------------------------------------------
#nullable enable
namespace HellionChat.Resources;
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute]
internal class HellionStrings
{
private static global::System.Resources.ResourceManager? resourceMan;
private static global::System.Globalization.CultureInfo? resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal HellionStrings() { }
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager
{
get
{
if (resourceMan is null)
resourceMan = new global::System.Resources.ResourceManager("HellionChat.Resources.HellionStrings", typeof(HellionStrings).Assembly);
return resourceMan;
}
}
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo? Culture
{
get => resourceCulture;
set => resourceCulture = value;
}
private static string Get(string key)
=> ResourceManager.GetString(key, resourceCulture) ?? key;
internal static string Privacy_Tab_Title => Get(nameof(Privacy_Tab_Title));
internal static string Privacy_FilterEnabled_Name => Get(nameof(Privacy_FilterEnabled_Name));
internal static string Privacy_FilterEnabled_Description => Get(nameof(Privacy_FilterEnabled_Description));
internal static string Privacy_FilterEnabled_StorageOnly_Help => Get(nameof(Privacy_FilterEnabled_StorageOnly_Help));
internal static string Privacy_Filter_Tree_Heading => Get(nameof(Privacy_Filter_Tree_Heading));
internal static string Privacy_Whitelist_Help => Get(nameof(Privacy_Whitelist_Help));
internal static string Privacy_Preset_PrivacyFirst => Get(nameof(Privacy_Preset_PrivacyFirst));
internal static string Privacy_Preset_ClearAll => Get(nameof(Privacy_Preset_ClearAll));
internal static string Privacy_Preset_SelectAll => Get(nameof(Privacy_Preset_SelectAll));
internal static string Privacy_Group_DirectMessages => Get(nameof(Privacy_Group_DirectMessages));
internal static string Privacy_Group_PartyAlliance => Get(nameof(Privacy_Group_PartyAlliance));
internal static string Privacy_Group_FreeCompany => Get(nameof(Privacy_Group_FreeCompany));
internal static string Privacy_Group_Linkshells => Get(nameof(Privacy_Group_Linkshells));
internal static string Privacy_Group_CrossLinkshells => Get(nameof(Privacy_Group_CrossLinkshells));
internal static string Privacy_Group_ExtraChat => Get(nameof(Privacy_Group_ExtraChat));
internal static string Privacy_Group_PublicChat => Get(nameof(Privacy_Group_PublicChat));
internal static string Privacy_Group_SystemLogs => Get(nameof(Privacy_Group_SystemLogs));
internal static string Privacy_PersistUnknown_Name => Get(nameof(Privacy_PersistUnknown_Name));
internal static string Privacy_PersistUnknown_Description => Get(nameof(Privacy_PersistUnknown_Description));
internal static string Cleanup_Heading => Get(nameof(Cleanup_Heading));
internal static string Cleanup_Help_Intro => Get(nameof(Cleanup_Help_Intro));
internal static string Cleanup_Help_SavedNote => Get(nameof(Cleanup_Help_SavedNote));
internal static string Cleanup_Preview_Stale => Get(nameof(Cleanup_Preview_Stale));
internal static string Retention_Help_SavedNote => Get(nameof(Retention_Help_SavedNote));
internal static string Cleanup_RefreshPreview => Get(nameof(Cleanup_RefreshPreview));
internal static string Cleanup_NoPreview => Get(nameof(Cleanup_NoPreview));
internal static string Cleanup_TotalStored => Get(nameof(Cleanup_TotalStored));
internal static string Cleanup_WillKeep => Get(nameof(Cleanup_WillKeep));
internal static string Cleanup_WillDelete => Get(nameof(Cleanup_WillDelete));
internal static string Cleanup_Breakdown => Get(nameof(Cleanup_Breakdown));
internal static string Cleanup_Marker_Keep => Get(nameof(Cleanup_Marker_Keep));
internal static string Cleanup_Marker_Delete => Get(nameof(Cleanup_Marker_Delete));
internal static string Cleanup_Apply_Label => Get(nameof(Cleanup_Apply_Label));
internal static string Cleanup_Apply_Tooltip => Get(nameof(Cleanup_Apply_Tooltip));
internal static string Cleanup_Running => Get(nameof(Cleanup_Running));
internal static string Cleanup_PreviewError => Get(nameof(Cleanup_PreviewError));
internal static string Cleanup_Success => Get(nameof(Cleanup_Success));
internal static string Cleanup_Error => Get(nameof(Cleanup_Error));
internal static string Retention_Heading => Get(nameof(Retention_Heading));
internal static string Retention_Enabled_Name => Get(nameof(Retention_Enabled_Name));
internal static string Retention_Enabled_Description => Get(nameof(Retention_Enabled_Description));
internal static string Retention_Default_Label => Get(nameof(Retention_Default_Label));
internal static string Retention_Default_Help => Get(nameof(Retention_Default_Help));
internal static string Retention_Reset_Spec => Get(nameof(Retention_Reset_Spec));
internal static string Retention_Clear_Overrides => Get(nameof(Retention_Clear_Overrides));
internal static string Retention_Tree_Heading => Get(nameof(Retention_Tree_Heading));
internal static string Retention_Tag_Override => Get(nameof(Retention_Tag_Override));
internal static string Retention_Tag_Spec => Get(nameof(Retention_Tag_Spec));
internal static string Retention_Tag_Global => Get(nameof(Retention_Tag_Global));
internal static string Retention_Reset_Button => Get(nameof(Retention_Reset_Button));
internal static string Retention_Apply_Label => Get(nameof(Retention_Apply_Label));
internal static string Retention_Apply_Tooltip => Get(nameof(Retention_Apply_Tooltip));
internal static string Retention_Running => Get(nameof(Retention_Running));
internal static string Retention_LastRun_Never => Get(nameof(Retention_LastRun_Never));
internal static string Retention_LastRun_At => Get(nameof(Retention_LastRun_At));
internal static string Retention_Success => Get(nameof(Retention_Success));
internal static string Retention_Error => Get(nameof(Retention_Error));
internal static string Wizard_Title => Get(nameof(Wizard_Title));
internal static string Wizard_Intro => Get(nameof(Wizard_Intro));
internal static string Wizard_Profile_PrivacyFirst_Heading => Get(nameof(Wizard_Profile_PrivacyFirst_Heading));
internal static string Wizard_Profile_PrivacyFirst_Description => Get(nameof(Wizard_Profile_PrivacyFirst_Description));
internal static string Wizard_Profile_PrivacyFirst_Apply => Get(nameof(Wizard_Profile_PrivacyFirst_Apply));
internal static string Wizard_Profile_Casual_Heading => Get(nameof(Wizard_Profile_Casual_Heading));
internal static string Wizard_Profile_Casual_Description => Get(nameof(Wizard_Profile_Casual_Description));
internal static string Wizard_Profile_Casual_Apply => Get(nameof(Wizard_Profile_Casual_Apply));
internal static string Wizard_Profile_FullHistory_Heading => Get(nameof(Wizard_Profile_FullHistory_Heading));
internal static string Wizard_Profile_FullHistory_Description => Get(nameof(Wizard_Profile_FullHistory_Description));
internal static string Wizard_Profile_FullHistory_GdprWarning => Get(nameof(Wizard_Profile_FullHistory_GdprWarning));
internal static string Wizard_Profile_FullHistory_Apply => Get(nameof(Wizard_Profile_FullHistory_Apply));
internal static string Wizard_Reopen_Button => Get(nameof(Wizard_Reopen_Button));
internal static string Export_Heading => Get(nameof(Export_Heading));
internal static string Export_Help => Get(nameof(Export_Help));
internal static string Export_Range_Label => Get(nameof(Export_Range_Label));
internal static string Export_Sender_Label => Get(nameof(Export_Sender_Label));
internal static string Export_Channels_Heading => Get(nameof(Export_Channels_Heading));
internal static string Export_Channels_AllOff => Get(nameof(Export_Channels_AllOff));
internal static string Export_Format_Label => Get(nameof(Export_Format_Label));
internal static string Export_Format_Markdown => Get(nameof(Export_Format_Markdown));
internal static string Export_Format_Json => Get(nameof(Export_Format_Json));
internal static string Export_Format_Csv => Get(nameof(Export_Format_Csv));
internal static string Export_Button => Get(nameof(Export_Button));
internal static string Export_Dialog_Title => Get(nameof(Export_Dialog_Title));
internal static string Export_Running => Get(nameof(Export_Running));
internal static string Export_Success => Get(nameof(Export_Success));
internal static string Export_Empty => Get(nameof(Export_Empty));
internal static string Export_Error => Get(nameof(Export_Error));
internal static string Theme_Enabled_Name => Get(nameof(Theme_Enabled_Name));
internal static string Theme_Enabled_Description => Get(nameof(Theme_Enabled_Description));
internal static string Theme_WindowOpacity_Label => Get(nameof(Theme_WindowOpacity_Label));
internal static string Theme_WindowOpacity_Help => Get(nameof(Theme_WindowOpacity_Help));
internal static string Theme_UseHellionFont_Name => Get(nameof(Theme_UseHellionFont_Name));
internal static string Theme_UseHellionFont_Description => Get(nameof(Theme_UseHellionFont_Description));
internal static string About_Maintainer_Heading => Get(nameof(About_Maintainer_Heading));
internal static string About_Maintainer_Body => Get(nameof(About_Maintainer_Body));
internal static string About_Maintainer_Website_Label => Get(nameof(About_Maintainer_Website_Label));
internal static string About_Mission_Heading => Get(nameof(About_Mission_Heading));
internal static string About_Mission_P1 => Get(nameof(About_Mission_P1));
internal static string About_Mission_P2 => Get(nameof(About_Mission_P2));
internal static string About_Mission_P3 => Get(nameof(About_Mission_P3));
internal static string About_BuiltOn_Heading => Get(nameof(About_BuiltOn_Heading));
internal static string About_BuiltOn_P1 => Get(nameof(About_BuiltOn_P1));
internal static string About_BuiltOn_P2 => Get(nameof(About_BuiltOn_P2));
internal static string About_BuiltOn_Upstream_Label => Get(nameof(About_BuiltOn_Upstream_Label));
internal static string About_License_Heading => Get(nameof(About_License_Heading));
internal static string About_License_P1 => Get(nameof(About_License_P1));
internal static string About_License_P2 => Get(nameof(About_License_P2));
internal static string About_License_P3 => Get(nameof(About_License_P3));
internal static string About_SE_Heading => Get(nameof(About_SE_Heading));
internal static string About_SE_P1 => Get(nameof(About_SE_P1));
internal static string About_SE_P2 => Get(nameof(About_SE_P2));
internal static string About_Localization_Heading => Get(nameof(About_Localization_Heading));
internal static string About_Localization_P1 => Get(nameof(About_Localization_P1));
internal static string About_Localization_P2 => Get(nameof(About_Localization_P2));
internal static string About_Translators_TreeNode => Get(nameof(About_Translators_TreeNode));
// Hellion Chat — Auto-Tell-Tabs runtime strings
internal static string AutoTellTabs_SectionHeader => Get(nameof(AutoTellTabs_SectionHeader));
internal static string AutoTellTabs_HistorySeparator => Get(nameof(AutoTellTabs_HistorySeparator));
internal static string AutoTellTabs_HistoryLoadError => Get(nameof(AutoTellTabs_HistoryLoadError));
internal static string AutoTellTabs_GreetedTooltip => Get(nameof(AutoTellTabs_GreetedTooltip));
internal static string AutoTellTabs_UnGreetedTooltip => Get(nameof(AutoTellTabs_UnGreetedTooltip));
// Hellion Chat — Auto-Tell-Tabs Chat settings tab
internal static string ChatLog_AutoTellTabs_Section_Title => Get(nameof(ChatLog_AutoTellTabs_Section_Title));
internal static string ChatLog_AutoTellTabs_Enable_Name => Get(nameof(ChatLog_AutoTellTabs_Enable_Name));
internal static string ChatLog_AutoTellTabs_Enable_Description => Get(nameof(ChatLog_AutoTellTabs_Enable_Description));
internal static string ChatLog_AutoTellTabs_Limit_Name => Get(nameof(ChatLog_AutoTellTabs_Limit_Name));
internal static string ChatLog_AutoTellTabs_Limit_Description => Get(nameof(ChatLog_AutoTellTabs_Limit_Description));
internal static string ChatLog_AutoTellTabs_Compact_Name => Get(nameof(ChatLog_AutoTellTabs_Compact_Name));
internal static string ChatLog_AutoTellTabs_Compact_Description => Get(nameof(ChatLog_AutoTellTabs_Compact_Description));
internal static string ChatLog_AutoTellTabs_GreetedToggle_Name => Get(nameof(ChatLog_AutoTellTabs_GreetedToggle_Name));
internal static string ChatLog_AutoTellTabs_GreetedToggle_Description => Get(nameof(ChatLog_AutoTellTabs_GreetedToggle_Description));
internal static string ChatLog_AutoTellTabs_OpenAsPopout_Name => Get(nameof(ChatLog_AutoTellTabs_OpenAsPopout_Name));
internal static string ChatLog_AutoTellTabs_OpenAsPopout_Description => Get(nameof(ChatLog_AutoTellTabs_OpenAsPopout_Description));
internal static string ChatLog_AutoTellTabs_PreloadHint => Get(nameof(ChatLog_AutoTellTabs_PreloadHint));
internal static string ChatLog_AutoTellTabs_ConflictHint => Get(nameof(ChatLog_AutoTellTabs_ConflictHint));
// Hellion Chat — Auto-Tell-Tabs Privacy settings tab
internal static string Privacy_AutoTellTabs_Section_Title => Get(nameof(Privacy_AutoTellTabs_Section_Title));
internal static string Privacy_AutoTellTabs_Preload_Name => Get(nameof(Privacy_AutoTellTabs_Preload_Name));
internal static string Privacy_AutoTellTabs_Preload_Description => Get(nameof(Privacy_AutoTellTabs_Preload_Description));
internal static string Privacy_AutoTellTabs_Preload_Hint => Get(nameof(Privacy_AutoTellTabs_Preload_Hint));
// Hellion Chat — Settings UX Polish v10 wipe migration
internal static string SettingsRefactor_Migration_Title => Get(nameof(SettingsRefactor_Migration_Title));
internal static string SettingsRefactor_Migration_Content => Get(nameof(SettingsRefactor_Migration_Content));
// Hellion Chat — Settings UX Polish 8-tab structure
internal static string Settings_Tab_General => Get(nameof(Settings_Tab_General));
internal static string Settings_Tab_Appearance => Get(nameof(Settings_Tab_Appearance));
internal static string Settings_Tab_Window => Get(nameof(Settings_Tab_Window));
internal static string Settings_Tab_Chat => Get(nameof(Settings_Tab_Chat));
internal static string Settings_Tab_Tabs => Get(nameof(Settings_Tab_Tabs));
internal static string Settings_Tab_Database => Get(nameof(Settings_Tab_Database));
internal static string Settings_Tab_Information => Get(nameof(Settings_Tab_Information));
// Hellion Chat — General-Tab section headings
internal static string Settings_General_Input_Heading => Get(nameof(Settings_General_Input_Heading));
internal static string Settings_General_Audio_Heading => Get(nameof(Settings_General_Audio_Heading));
internal static string Settings_General_Performance_Heading => Get(nameof(Settings_General_Performance_Heading));
internal static string Settings_General_Language_Heading => Get(nameof(Settings_General_Language_Heading));
// Hellion Chat — Appearance-Tab section headings
internal static string Settings_Appearance_Theme_Heading => Get(nameof(Settings_Appearance_Theme_Heading));
internal static string Settings_Appearance_Fonts_Heading => Get(nameof(Settings_Appearance_Fonts_Heading));
internal static string Settings_Appearance_Colours_Heading => Get(nameof(Settings_Appearance_Colours_Heading));
internal static string Settings_Appearance_Timestamps_Heading => Get(nameof(Settings_Appearance_Timestamps_Heading));
// Hellion Chat — Window-Tab section headings
internal static string Settings_Window_Hide_Heading => Get(nameof(Settings_Window_Hide_Heading));
internal static string Settings_Window_InactivityHide_Heading => Get(nameof(Settings_Window_InactivityHide_Heading));
internal static string Settings_Window_Frame_Heading => Get(nameof(Settings_Window_Frame_Heading));
internal static string Settings_Window_Tooltips_Heading => Get(nameof(Settings_Window_Tooltips_Heading));
// Hellion Chat — Chat-Tab section headings
internal static string Settings_Chat_AutoTellTabs_Heading => Get(nameof(Settings_Chat_AutoTellTabs_Heading));
internal static string Settings_Chat_Behaviour_Heading => Get(nameof(Settings_Chat_Behaviour_Heading));
internal static string Settings_Chat_Preview_Heading => Get(nameof(Settings_Chat_Preview_Heading));
internal static string Settings_Chat_Emotes_Heading => Get(nameof(Settings_Chat_Emotes_Heading));
// Hellion Chat — Database-Tab section headings
internal static string Settings_Database_Storage_Heading => Get(nameof(Settings_Database_Storage_Heading));
internal static string Settings_Database_Viewer_Heading => Get(nameof(Settings_Database_Viewer_Heading));
internal static string Settings_Database_Stats_Heading => Get(nameof(Settings_Database_Stats_Heading));
// Hellion Chat — Information-Tab section headings
internal static string Settings_Information_VersionInfo_Heading => Get(nameof(Settings_Information_VersionInfo_Heading));
internal static string Settings_Information_About_Heading => Get(nameof(Settings_Information_About_Heading));
internal static string Settings_Information_Changelog_Heading => Get(nameof(Settings_Information_Changelog_Heading));
// Hellion Chat — Default tab presets (channel-themed)
internal static string Tabs_Presets_System => Get(nameof(Tabs_Presets_System));
internal static string Tabs_Presets_FreeCompany => Get(nameof(Tabs_Presets_FreeCompany));
internal static string Tabs_Presets_Party => Get(nameof(Tabs_Presets_Party));
internal static string Tabs_Presets_Beginner => Get(nameof(Tabs_Presets_Beginner));
internal static string Tabs_Presets_Linkshell => Get(nameof(Tabs_Presets_Linkshell));
internal static string Tabs_Presets_Linkshell_Hint => Get(nameof(Tabs_Presets_Linkshell_Hint));
// Hellion Chat — v0.6.0 chat colour presets (display labels)
internal static string ChatColourPresets_Default => Get(nameof(ChatColourPresets_Default));
internal static string ChatColourPresets_HighContrast => Get(nameof(ChatColourPresets_HighContrast));
internal static string ChatColourPresets_Pastell => Get(nameof(ChatColourPresets_Pastell));
internal static string ChatColourPresets_DarkModeTuned => Get(nameof(ChatColourPresets_DarkModeTuned));
internal static string ChatColourPresets_Hellion => Get(nameof(ChatColourPresets_Hellion));
internal static string ChatColourPresets_NightBlue => Get(nameof(ChatColourPresets_NightBlue));
internal static string ChatColourPresets_IndigoViolet => Get(nameof(ChatColourPresets_IndigoViolet));
// Hellion Chat — v0.6.0 chat colour presets section copy
internal static string Settings_Appearance_Colours_PresetsHint => Get(nameof(Settings_Appearance_Colours_PresetsHint));
// Hellion Chat — v0.6.0 pop-out input master switch
internal static string Settings_Window_PopOutInputEnabled_Name => Get(nameof(Settings_Window_PopOutInputEnabled_Name));
internal static string Settings_Window_PopOutInputEnabled_Description => Get(nameof(Settings_Window_PopOutInputEnabled_Description));
// Hellion Chat — v0.6.0 one-time hint banner shown inside pop-outs
internal static string Popout_v060_HintText => Get(nameof(Popout_v060_HintText));
internal static string Popout_v060_HintAck => Get(nameof(Popout_v060_HintAck));
internal static string Popout_v060_HintOpenSettings => Get(nameof(Popout_v060_HintOpenSettings));
// Hellion Chat — v0.6.1 pop-out header hint banner (discoverability)
internal static string Hint_v061_PopOutHeader_Body => Get(nameof(Hint_v061_PopOutHeader_Body));
internal static string Hint_v061_PopOutHeader_Ack => Get(nameof(Hint_v061_PopOutHeader_Ack));
internal static string Hint_v061_PopOutHeader_OpenSettings => Get(nameof(Hint_v061_PopOutHeader_OpenSettings));
// Hellion Chat — v1.0.0 Chat 2 parallel-load conflict detection
internal static string ChatTwoConflictTitle => Get(nameof(ChatTwoConflictTitle));
internal static string ChatTwoConflictBody => Get(nameof(ChatTwoConflictBody));
internal static string ChatTwoConflictAction => Get(nameof(ChatTwoConflictAction));
}
@@ -0,0 +1,621 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Privacy_Tab_Title" xml:space="preserve">
<value>Datenschutz</value>
</data>
<data name="Privacy_FilterEnabled_Name" xml:space="preserve">
<value>Datenschutz-Filter aktivieren</value>
</data>
<data name="Privacy_FilterEnabled_Description" xml:space="preserve">
<value>Wenn aktiviert, werden nur Nachrichten aus den erlaubten Kanälen in die Datenbank gespeichert. Beim Deaktivieren gilt wieder das Standardverhalten, also alles außer Battle-Logs wird gespeichert.</value>
</data>
<data name="Privacy_FilterEnabled_StorageOnly_Help" xml:space="preserve">
<value>Der Filter steuert nur, was in die lokale Datenbank geschrieben wird. Im Chat-Log siehst du weiterhin jede Nachricht live, ausgeschlossene Kanäle werden nur nicht mehr gespeichert. Wenn du Kanäle auch aus der sichtbaren Anzeige entfernen willst, nutze die normalen Chat-Tab-Filter im Spiel.</value>
</data>
<data name="Privacy_Filter_Tree_Heading" xml:space="preserve">
<value>Privacy-Filter und Whitelist</value>
</data>
<data name="Privacy_Whitelist_Help" xml:space="preserve">
<value>Wähle aus, welche Kanäle in die lokale Datenbank gespeichert werden. Standard nach Datensparsamkeit: nur deine eigenen Konversationen. Über die Buttons unten kannst du eine Voreinstellung anwenden.</value>
</data>
<data name="Privacy_Preset_PrivacyFirst" xml:space="preserve">
<value>Datensparsamkeit (empfohlen)</value>
</data>
<data name="Privacy_Preset_ClearAll" xml:space="preserve">
<value>Alle abwählen</value>
</data>
<data name="Privacy_Preset_SelectAll" xml:space="preserve">
<value>Alle auswählen</value>
</data>
<data name="Privacy_Group_DirectMessages" xml:space="preserve">
<value>Direktnachrichten</value>
</data>
<data name="Privacy_Group_PartyAlliance" xml:space="preserve">
<value>Gruppe &amp; Allianz</value>
</data>
<data name="Privacy_Group_FreeCompany" xml:space="preserve">
<value>Free Company</value>
</data>
<data name="Privacy_Group_Linkshells" xml:space="preserve">
<value>Linkshells</value>
</data>
<data name="Privacy_Group_CrossLinkshells" xml:space="preserve">
<value>Cross-World-Linkshells</value>
</data>
<data name="Privacy_Group_ExtraChat" xml:space="preserve">
<value>ExtraChat (verschlüsselt)</value>
</data>
<data name="Privacy_Group_PublicChat" xml:space="preserve">
<value>Öffentlicher Chat (Daten Dritter)</value>
</data>
<data name="Privacy_Group_SystemLogs" xml:space="preserve">
<value>System &amp; Spiel-Logs</value>
</data>
<data name="Privacy_PersistUnknown_Name" xml:space="preserve">
<value>Unbekannte Kanal-Typen speichern</value>
</data>
<data name="Privacy_PersistUnknown_Description" xml:space="preserve">
<value>Sicherheitsnetz für ChatTypes, die durch zukünftige FFXIV-Patches dazukommen und dem Plugin noch nicht bekannt sind. Standard ist AUS (Datensparsamkeit). Aktivieren, wenn du auch zukünftige Kanäle vollständig mitloggen willst.</value>
</data>
<data name="Cleanup_Heading" xml:space="preserve">
<value>Filter auf bestehende Datenbank anwenden</value>
</data>
<data name="Cleanup_Help_Intro" xml:space="preserve">
<value>Der Datenschutz-Filter wirkt nur auf neue Nachrichten. Über das Aufräumen unten kannst du bereits gespeicherte Nachrichten nachträglich entfernen, die nicht zu deiner gespeicherten Whitelist passen.</value>
</data>
<data name="Cleanup_Help_SavedNote" xml:space="preserve">
<value>Das Aufräumen nutzt deine GESPEICHERTE Whitelist (Plugin.Config), nicht ungespeicherte Änderungen oben. Klicke zuerst Speichern, wenn du deine aktuellen Änderungen anwenden willst.</value>
</data>
<data name="Retention_Help_SavedNote" xml:space="preserve">
<value>Der manuelle Lauf nutzt deine GESPEICHERTE Retention-Policy, nicht die Slider-Werte oben. Klicke zuerst Speichern, wenn der Lauf deine aktuellen Änderungen anwenden soll.</value>
</data>
<data name="Cleanup_Preview_Stale" xml:space="preserve">
<value>Vorschau veraltet, deine Whitelist hat sich seit dem letzten Aktualisieren geändert. Klicke Aktualisieren, um neu zu berechnen.</value>
</data>
<data name="Cleanup_RefreshPreview" xml:space="preserve">
<value>Vorschau aktualisieren</value>
</data>
<data name="Cleanup_NoPreview" xml:space="preserve">
<value>Noch keine Vorschau. Klicke Aktualisieren, um die Auswirkung zu berechnen.</value>
</data>
<data name="Cleanup_TotalStored" xml:space="preserve">
<value>Gespeicherte Nachrichten gesamt: {0:N0}</value>
</data>
<data name="Cleanup_WillKeep" xml:space="preserve">
<value>Behalten: {0:N0}</value>
</data>
<data name="Cleanup_WillDelete" xml:space="preserve">
<value>Löschen: {0:N0}</value>
</data>
<data name="Cleanup_Breakdown" xml:space="preserve">
<value>Aufschlüsselung pro Kanal</value>
</data>
<data name="Cleanup_Marker_Keep" xml:space="preserve">
<value>[BEHALTEN]</value>
</data>
<data name="Cleanup_Marker_Delete" xml:space="preserve">
<value>[LÖSCHEN] </value>
</data>
<data name="Cleanup_Apply_Label" xml:space="preserve">
<value>Aktuellen Filter auf Datenbank anwenden</value>
</data>
<data name="Cleanup_Apply_Tooltip" xml:space="preserve">
<value>Strg+Umschalt: Löscht {0:N0} Nachrichten unwiderruflich und führt danach VACUUM aus. Nicht rückgängig zu machen.</value>
</data>
<data name="Cleanup_Running" xml:space="preserve">
<value>Aufräumen läuft im Hintergrund…</value>
</data>
<data name="Cleanup_PreviewError" xml:space="preserve">
<value>Vorschau konnte nicht berechnet werden, siehe /xllog</value>
</data>
<data name="Cleanup_Success" xml:space="preserve">
<value>Aufräumen abgeschlossen, {0:N0} Nachrichten entfernt.</value>
</data>
<data name="Cleanup_Error" xml:space="preserve">
<value>Aufräumen fehlgeschlagen, siehe /xllog</value>
</data>
<data name="Retention_Heading" xml:space="preserve">
<value>Aufbewahrung von Nachrichten</value>
</data>
<data name="Retention_Enabled_Name" xml:space="preserve">
<value>Nachrichten nach Kanal-Aufbewahrung automatisch löschen</value>
</data>
<data name="Retention_Enabled_Description" xml:space="preserve">
<value>Wenn aktiviert, werden Nachrichten älter als das eingestellte Fenster bei jedem Plugin-Start gelöscht (höchstens einmal pro 24 Stunden). Standard ist AUS, das Plugin löscht ohne deine ausdrückliche Zustimmung nichts.</value>
</data>
<data name="Retention_Default_Label" xml:space="preserve">
<value>Standard-Aufbewahrung (Tage, 0 = nie)</value>
</data>
<data name="Retention_Default_Help" xml:space="preserve">
<value>Gilt für Kanäle, die unten keine eigene Vorgabe haben.</value>
</data>
<data name="Retention_Reset_Spec" xml:space="preserve">
<value>Vorgaben auf Spec-Defaults setzen</value>
</data>
<data name="Retention_Clear_Overrides" xml:space="preserve">
<value>Alle Vorgaben entfernen</value>
</data>
<data name="Retention_Tree_Heading" xml:space="preserve">
<value>Aufbewahrung pro Kanal</value>
</data>
<data name="Retention_Tag_Override" xml:space="preserve">
<value>[eigen]</value>
</data>
<data name="Retention_Tag_Spec" xml:space="preserve">
<value>[spec]</value>
</data>
<data name="Retention_Tag_Global" xml:space="preserve">
<value>[global]</value>
</data>
<data name="Retention_Reset_Button" xml:space="preserve">
<value>zurück</value>
</data>
<data name="Retention_Apply_Label" xml:space="preserve">
<value>Aufbewahrung jetzt anwenden</value>
</data>
<data name="Retention_Apply_Tooltip" xml:space="preserve">
<value>Strg+Umschalt: Führt die Aufbewahrungs-Bereinigung sofort mit der GESPEICHERTEN Vorgabe aus. Speichere deine Änderungen vorher.</value>
</data>
<data name="Retention_Running" xml:space="preserve">
<value>Aufbewahrungs-Bereinigung läuft im Hintergrund…</value>
</data>
<data name="Retention_LastRun_Never" xml:space="preserve">
<value>Letzter Lauf: nie</value>
</data>
<data name="Retention_LastRun_At" xml:space="preserve">
<value>Letzter Lauf: {0:yyyy-MM-dd HH:mm}</value>
</data>
<data name="Retention_Success" xml:space="preserve">
<value>Aufbewahrungs-Bereinigung abgeschlossen, {0:N0} Nachrichten entfernt.</value>
</data>
<data name="Retention_Error" xml:space="preserve">
<value>Aufbewahrungs-Bereinigung fehlgeschlagen, siehe /xllog</value>
</data>
<data name="Wizard_Title" xml:space="preserve">
<value>Hellion Chat — Willkommen</value>
</data>
<data name="Wizard_Intro" xml:space="preserve">
<value>Wähle ein Start-Profil. Du kannst später alles unter Einstellungen → Datenschutz anpassen.</value>
</data>
<data name="Wizard_Profile_PrivacyFirst_Heading" xml:space="preserve">
<value>Datensparsamkeit (empfohlen)</value>
</data>
<data name="Wizard_Profile_PrivacyFirst_Description" xml:space="preserve">
<value>Es werden nur deine eigenen Konversationen gespeichert: Tells, Gruppe, FC, Linkshells, Cross-World-Linkshells, Allianz und ExtraChat. Öffentlicher Chat, NPC-Dialoge und System-Spam werden auf der Storage-Ebene verworfen. Aufbewahrung nach Spec-Defaults (Tells 365 Tage, eigene Konversations-Kanäle 90 Tage).</value>
</data>
<data name="Wizard_Profile_PrivacyFirst_Apply" xml:space="preserve">
<value>Datensparsamkeit übernehmen</value>
</data>
<data name="Wizard_Profile_Casual_Heading" xml:space="preserve">
<value>Locker</value>
</data>
<data name="Wizard_Profile_Casual_Description" xml:space="preserve">
<value>Datensparsamkeit plus ein 24-Stunden-Fenster für öffentlichen Chat (Sagen, Schreien, Rufen, beide Emote-Typen, Anfänger-Netzwerk). Für RP-Spieler, die die letzte Szene nochmal nachlesen wollen, ohne öffentlichen Chat ewig zu behalten.</value>
</data>
<data name="Wizard_Profile_Casual_Apply" xml:space="preserve">
<value>Locker übernehmen</value>
</data>
<data name="Wizard_Profile_FullHistory_Heading" xml:space="preserve">
<value>Volle Historie</value>
</data>
<data name="Wizard_Profile_FullHistory_Description" xml:space="preserve">
<value>Deaktiviert den Datenschutz-Filter komplett. Speichert alles außer Battle-Logs (das ursprüngliche Voll-Historie-Verhalten). Aufbewahrung ist AUS, die Historie wächst dauerhaft.</value>
</data>
<data name="Wizard_Profile_FullHistory_GdprWarning" xml:space="preserve">
<value>DSGVO-Hinweis: Wenn du Nachrichten Dritter (Sagen/Schreien/Rufen fremder Spieler, NPC-Dialoge mit Spielernamen usw.) zeitlich unbegrenzt speicherst, kann das die Ausnahme für rein persönliche oder familiäre Tätigkeiten (Art. 2 Abs. 2 Buchst. c) sprengen. Nutze dieses Profil nur, wenn du einen klaren Grund hast, das volle Archiv zu behalten.</value>
</data>
<data name="Wizard_Profile_FullHistory_Apply" xml:space="preserve">
<value>Volle Historie übernehmen</value>
</data>
<data name="Wizard_Reopen_Button" xml:space="preserve">
<value>Wizard erneut zeigen</value>
</data>
<data name="Export_Heading" xml:space="preserve">
<value>Export (DSGVO Art. 15 — Auskunftsrecht)</value>
</data>
<data name="Export_Help" xml:space="preserve">
<value>Gespeicherte Nachrichten als Markdown, JSON oder CSV exportieren. Damit kannst du einer Auskunftsanfrage einer Person nachkommen, deren Nachrichten du gespeichert hast, oder deine eigene Historie mitnehmen.</value>
</data>
<data name="Export_Range_Label" xml:space="preserve">
<value>Letzte X Tage (0 = ohne Zeitlimit)</value>
</data>
<data name="Export_Sender_Label" xml:space="preserve">
<value>Sender enthält (optional, Groß-/Kleinschreibung egal)</value>
</data>
<data name="Export_Channels_Heading" xml:space="preserve">
<value>Auf Kanäle einschränken</value>
</data>
<data name="Export_Channels_AllOff" xml:space="preserve">
<value>(nichts ausgewählt = alle gespeicherten Kanäle)</value>
</data>
<data name="Export_Format_Label" xml:space="preserve">
<value>Format</value>
</data>
<data name="Export_Format_Markdown" xml:space="preserve">
<value>Markdown</value>
</data>
<data name="Export_Format_Json" xml:space="preserve">
<value>JSON</value>
</data>
<data name="Export_Format_Csv" xml:space="preserve">
<value>CSV</value>
</data>
<data name="Export_Button" xml:space="preserve">
<value>In Datei exportieren…</value>
</data>
<data name="Export_Dialog_Title" xml:space="preserve">
<value>Export speichern</value>
</data>
<data name="Export_Running" xml:space="preserve">
<value>Export läuft im Hintergrund…</value>
</data>
<data name="Export_Success" xml:space="preserve">
<value>Export abgeschlossen, {0:N0} Nachrichten in {1} geschrieben</value>
</data>
<data name="Export_Empty" xml:space="preserve">
<value>Export abgeschlossen, keine Nachricht passte zum Filter.</value>
</data>
<data name="Export_Error" xml:space="preserve">
<value>Export fehlgeschlagen, siehe /xllog</value>
</data>
<data name="Theme_Enabled_Name" xml:space="preserve">
<value>Hellion-Theme für alle Plugin-Fenster verwenden</value>
</data>
<data name="Theme_Enabled_Description" xml:space="preserve">
<value>Hellion-Online-Media-Palette aus Arctic Cyan und Ember Orange, angewendet auf Chat-Fenster, Einstellungen, Viewer und Wizard. Deaktivieren, um das Standard-Dalamud-Erscheinungsbild zu nutzen.</value>
</data>
<data name="Theme_WindowOpacity_Label" xml:space="preserve">
<value>Fenster-Deckkraft</value>
</data>
<data name="Theme_WindowOpacity_Help" xml:space="preserve">
<value>Wie deckend die Plugin-Fenster sind. Niedrigere Werte lassen das Spiel durchscheinen, Form-Felder und Dialoge bleiben oben drauf deckend und gut lesbar.</value>
</data>
<data name="Theme_UseHellionFont_Name" xml:space="preserve">
<value>Mitgelieferte Hellion-Schrift (Exo 2) verwenden</value>
</data>
<data name="Theme_UseHellionFont_Description" xml:space="preserve">
<value>Rendert Chat und UI in Exo 2 (SIL Open Font License 1.1), die mit dem Plugin ausgeliefert wird. Deaktivieren, um auf die unter Einstellungen → Schrift gewählte Schriftart zurückzufallen.</value>
</data>
<data name="About_Maintainer_Heading" xml:space="preserve">
<value>Maintainer</value>
</data>
<data name="About_Maintainer_Body" xml:space="preserve">
<value>Ich pflege Hellion Chat über Hellion Online Media. Auf der Website findest du die Kontaktdaten für lizenzrechtliche, rechtliche oder geschäftliche Fragen.</value>
</data>
<data name="About_Maintainer_Website_Label" xml:space="preserve">
<value>Website:</value>
</data>
<data name="About_Mission_Heading" xml:space="preserve">
<value>Warum es diesen Fork gibt</value>
</data>
<data name="About_Mission_P1" xml:space="preserve">
<value>Hellion Chat soll Chat 2 nicht ersetzen. Chat 2 liefert ein vollständiges Chat-Erlebnis mit kompletter Historie, die für Filter, Suche und Replay zur Verfügung steht. Dieser Default ist für die meisten Nutzer der richtige. Dieser Fork wählt einen anderen Ansatz: einen kleineren Default-Footprint, mit zusätzlichen Stellschrauben für Nutzer, die weniger fremden Chat auf der Festplatte behalten möchten.</value>
</data>
<data name="About_Mission_P2" xml:space="preserve">
<value>Der Wunsch nach diesem engeren Default war persönlich. Nach zwei Jahren mit Chat 2 lag meine Datenbank bei über zwei Millionen Nachrichten, der Großteil davon /say, /shout und /yell von Fremden in Limsa. Genau diese Daten machen Chat 2's Voll-Historie nützlich, und die meisten Nutzer behalten sie gerne. Mein eigener Geschmack wollte einen kleineren Default. Also habe ich diesen Fork gebaut.</value>
</data>
<data name="About_Mission_P3" xml:space="preserve">
<value>Ich strebe keine große Zielgruppe an, und der Fork steht nicht in Konkurrenz zu Chat 2. Der Code liegt offen unter derselben EUPL-1.2-Lizenz wie das Original. Infi, Anna oder sonst jemand dürfen reinschauen, Ideen mitnehmen, Fragen stellen oder das Projekt einfach ignorieren. Alles drei ist für mich in Ordnung.</value>
</data>
<data name="About_BuiltOn_Heading" xml:space="preserve">
<value>Aufbauend auf Chat 2</value>
</data>
<data name="About_BuiltOn_P1" xml:space="preserve">
<value>Hellion Chat ist ein Fork von Chat 2 von Infi und Anna (ascclemens). Das Chat-Replacement-Fenster, die IPC-Integration, die Render-Engine und der komplette Storage-Kern stammen aus dem Original.</value>
</data>
<data name="About_BuiltOn_P2" xml:space="preserve">
<value>Das Webinterface ist das einzige größere Teil, das ich entfernt habe. Es ist für den Remote-Zugriff auf den Chat von einem zweiten Gerät gebaut, also für einen anderen Fokus als der kleinere Default-Footprint, den dieser Fork verfolgt. Es an diese Defaults anzupassen hätte einen erheblichen Umbau bedeutet, also war die Entfernung der saubere Weg für genau diesen Fork.</value>
</data>
<data name="About_BuiltOn_Upstream_Label" xml:space="preserve">
<value>Upstream-Repository:</value>
</data>
<data name="About_License_Heading" xml:space="preserve">
<value>Lizenz</value>
</data>
<data name="About_License_P1" xml:space="preserve">
<value>Hellion Chat und Chat 2 stehen beide unter der European Union Public Licence v1.2 (EUPL-1.2).</value>
</data>
<data name="About_License_P2" xml:space="preserve">
<value>© 2023 bis 2026, die Chat-2-Autoren (Infi, Anna und die Upstream-Mitwirkenden).</value>
</data>
<data name="About_License_P3" xml:space="preserve">
<value>© 2026 Hellion Online Media für die Erweiterungen in diesem Fork.</value>
</data>
<data name="About_SE_Heading" xml:space="preserve">
<value>FINAL FANTASY XIV-Hinweis</value>
</data>
<data name="About_SE_P1" xml:space="preserve">
<value>FINAL FANTASY XIV © SQUARE ENIX CO., LTD. Alle Rechte vorbehalten.</value>
</data>
<data name="About_SE_P2" xml:space="preserve">
<value>Hellion Chat ist ein inoffizielles Fan-Plugin. Es steht in keiner Verbindung zu Square Enix und wird von ihnen weder unterstützt, gesponsert noch genehmigt.</value>
</data>
<data name="About_Localization_Heading" xml:space="preserve">
<value>Lokalisierung</value>
</data>
<data name="About_Localization_P1" xml:space="preserve">
<value>Die deutschen Übersetzungen der Hellion-spezifischen Strings stammen von mir. Weitere Sprachen sind aktuell nicht verfügbar.</value>
</data>
<data name="About_Localization_P2" xml:space="preserve">
<value>Die Übersetzerliste weiter unten gehört zu den Chat-2-Strings auf Crowdin. Diese Freiwilligen haben Chat 2 übersetzt, nicht die Hellion-Erweiterungen.</value>
</data>
<data name="About_Translators_TreeNode" xml:space="preserve">
<value>Chat-2-Community-Übersetzer (Upstream)</value>
</data>
<!-- Hellion Chat — Auto-Tell-Tabs (Runtime-Strings) -->
<data name="AutoTellTabs_SectionHeader" xml:space="preserve">
<value>Aktive Tells</value>
</data>
<data name="AutoTellTabs_HistorySeparator" xml:space="preserve">
<value>— Frühere Unterhaltungen —</value>
</data>
<data name="AutoTellTabs_HistoryLoadError" xml:space="preserve">
<value>Verlauf konnte nicht geladen werden.</value>
</data>
<data name="AutoTellTabs_GreetedTooltip" xml:space="preserve">
<value>Als begrüßt markiert. Klicken um die Markierung zu entfernen.</value>
</data>
<data name="AutoTellTabs_UnGreetedTooltip" xml:space="preserve">
<value>Als begrüßt markieren.</value>
</data>
<!-- Hellion Chat — Auto-Tell-Tabs (Chat-Einstellungstab) -->
<data name="ChatLog_AutoTellTabs_Section_Title" xml:space="preserve">
<value>Auto-Tell-Tabs</value>
</data>
<data name="ChatLog_AutoTellTabs_Enable_Name" xml:space="preserve">
<value>Bei jedem /tell automatisch einen Tab pro Gesprächspartner öffnen</value>
</data>
<data name="ChatLog_AutoTellTabs_Enable_Description" xml:space="preserve">
<value>Sobald du einen /tell empfängst oder sendest, wird automatisch ein temporärer Tab für diesen Spieler geöffnet. Die Tabs verschwinden beim Logout.</value>
</data>
<data name="ChatLog_AutoTellTabs_Limit_Name" xml:space="preserve">
<value>Maximale Anzahl der Auto-Tell-Tabs</value>
</data>
<data name="ChatLog_AutoTellTabs_Limit_Description" xml:space="preserve">
<value>Beim Erreichen werden begrüßte Tabs mit der ältesten Aktivität zuerst geschlossen. Änderungen greifen beim nächsten /tell.</value>
</data>
<data name="ChatLog_AutoTellTabs_Compact_Name" xml:space="preserve">
<value>Kompakte Anzeige</value>
</data>
<data name="ChatLog_AutoTellTabs_Compact_Description" xml:space="preserve">
<value>Zeigt nur einen dünnen Separator zwischen normalen Tabs und Auto-Tell-Tabs, ohne Sektions-Header.</value>
</data>
<data name="ChatLog_AutoTellTabs_GreetedToggle_Name" xml:space="preserve">
<value>„Als begrüßt markieren"-Button anzeigen</value>
</data>
<data name="ChatLog_AutoTellTabs_GreetedToggle_Description" xml:space="preserve">
<value>Fügt neben jedem Auto-Tell-Tab einen Klick-Button hinzu, um einen Gesprächspartner als bereits begrüßt zu markieren — der Tab-Name wird dann gedimmt. Nützlich für Club-Greeter, die parallel viele Konversationen führen. Standardmäßig aus.</value>
</data>
<data name="ChatLog_AutoTellTabs_OpenAsPopout_Name" xml:space="preserve">
<value>Neue /tell-Tabs direkt als Pop-Out öffnen</value>
</data>
<data name="ChatLog_AutoTellTabs_OpenAsPopout_Description" xml:space="preserve">
<value>Wenn aktiv, wird jeder neu angelegte /tell-Tab sofort als eigenes Fenster geöffnet. Beim Schließen des Fensters kehrt der Tab in die Seitenleiste zurück.</value>
</data>
<data name="ChatLog_AutoTellTabs_PreloadHint" xml:space="preserve">
<value>Die Anzahl der vorgeladenen Tells lässt sich im Datenschutz-Tab einstellen.</value>
</data>
<data name="ChatLog_AutoTellTabs_ConflictHint" xml:space="preserve">
<value>Hinweis: Falls XIV Messanger oder ein ähnliches Plugin Tells unterdrückt, dort die Option „Suppress DMs" deaktivieren, damit Hellion Chat Tells empfangen und die Auto-Tabs öffnen kann.</value>
</data>
<!-- Hellion Chat — Auto-Tell-Tabs (Datenschutz-Einstellungstab) -->
<data name="Privacy_AutoTellTabs_Section_Title" xml:space="preserve">
<value>Tell-Verlauf in Auto-Tabs</value>
</data>
<data name="Privacy_AutoTellTabs_Preload_Name" xml:space="preserve">
<value>Anzahl der vorgeladenen Tells</value>
</data>
<data name="Privacy_AutoTellTabs_Preload_Description" xml:space="preserve">
<value>Wie viele frühere Tell-Nachrichten beim Öffnen eines Auto-Tell-Tabs aus der Datenbank geladen werden. 0 deaktiviert die Vorladung.</value>
</data>
<data name="Privacy_AutoTellTabs_Preload_Hint" xml:space="preserve">
<value>Greift nur, wenn Auto-Tell-Tabs im Chat-Tab aktiviert sind.</value>
</data>
<!-- Hellion Chat — Settings UX Polish v10 Wipe-Migration -->
<data name="SettingsRefactor_Migration_Title" xml:space="preserve">
<value>Settings umstrukturiert</value>
</data>
<data name="SettingsRefactor_Migration_Content" xml:space="preserve">
<value>Hellion Chat 0.5.0 hat die Settings in thematische Tabs umstrukturiert. Deine Chat-Datenbank und dein Nachrichtenverlauf bleiben unverändert. Settings wurden auf Defaults zurückgesetzt. Falls du das Privacy-Profil neu wählen willst, findest du den Reopen-Button im Datenschutz-Tab. Ein Backup der vorherigen Config liegt unter HellionChat.json.pre-v10-backup neben der aktiven Config-Datei.</value>
</data>
<!-- Hellion Chat — Settings UX Polish 8-Tab-Struktur -->
<data name="Settings_Tab_General" xml:space="preserve">
<value>Allgemein</value>
</data>
<data name="Settings_Tab_Appearance" xml:space="preserve">
<value>Aussehen</value>
</data>
<data name="Settings_Tab_Window" xml:space="preserve">
<value>Fenster</value>
</data>
<data name="Settings_Tab_Chat" xml:space="preserve">
<value>Chat</value>
</data>
<data name="Settings_Tab_Tabs" xml:space="preserve">
<value>Kanäle</value>
</data>
<data name="Settings_Tab_Database" xml:space="preserve">
<value>Datenbank</value>
</data>
<data name="Settings_Tab_Information" xml:space="preserve">
<value>Über</value>
</data>
<!-- Hellion Chat — Sektions-Überschriften des Allgemein-Tabs -->
<data name="Settings_General_Input_Heading" xml:space="preserve">
<value>Eingabe</value>
</data>
<data name="Settings_General_Audio_Heading" xml:space="preserve">
<value>Audio &amp; Benachrichtigungen</value>
</data>
<data name="Settings_General_Performance_Heading" xml:space="preserve">
<value>Performance</value>
</data>
<data name="Settings_General_Language_Heading" xml:space="preserve">
<value>Sprache &amp; Eingabe-Hilfen</value>
</data>
<!-- Hellion Chat — Sektions-Überschriften des Aussehen-Tabs -->
<data name="Settings_Appearance_Theme_Heading" xml:space="preserve">
<value>Theme</value>
</data>
<data name="Settings_Appearance_Fonts_Heading" xml:space="preserve">
<value>Schriftarten</value>
</data>
<data name="Settings_Appearance_Colours_Heading" xml:space="preserve">
<value>Chat-Farben</value>
</data>
<data name="Settings_Appearance_Timestamps_Heading" xml:space="preserve">
<value>Zeitstempel</value>
</data>
<!-- Hellion Chat — Sektions-Überschriften des Fenster-Tabs -->
<data name="Settings_Window_Hide_Heading" xml:space="preserve">
<value>Verstecken</value>
</data>
<data name="Settings_Window_InactivityHide_Heading" xml:space="preserve">
<value>Inaktivitäts-Verstecken</value>
</data>
<data name="Settings_Window_Frame_Heading" xml:space="preserve">
<value>Fenster-Rahmen</value>
</data>
<data name="Settings_Window_Tooltips_Heading" xml:space="preserve">
<value>Tooltips</value>
</data>
<!-- Hellion Chat — Sektions-Überschriften des Chat-Tabs -->
<data name="Settings_Chat_AutoTellTabs_Heading" xml:space="preserve">
<value>Auto-Tell-Tabs</value>
</data>
<data name="Settings_Chat_Behaviour_Heading" xml:space="preserve">
<value>Nachrichten-Verhalten</value>
</data>
<data name="Settings_Chat_Preview_Heading" xml:space="preserve">
<value>Vorschau</value>
</data>
<data name="Settings_Chat_Emotes_Heading" xml:space="preserve">
<value>Emotes</value>
</data>
<!-- Hellion Chat — Sektions-Überschriften des Database-Tabs -->
<data name="Settings_Database_Storage_Heading" xml:space="preserve">
<value>Speicherung</value>
</data>
<data name="Settings_Database_Viewer_Heading" xml:space="preserve">
<value>Übersicht</value>
</data>
<data name="Settings_Database_Stats_Heading" xml:space="preserve">
<value>Wartung</value>
</data>
<!-- Hellion Chat — Sektions-Überschriften des Information-Tabs -->
<data name="Settings_Information_VersionInfo_Heading" xml:space="preserve">
<value>Versionsinfo</value>
</data>
<data name="Settings_Information_About_Heading" xml:space="preserve">
<value>Über HellionChat</value>
</data>
<data name="Settings_Information_Changelog_Heading" xml:space="preserve">
<value>Changelog</value>
</data>
<!-- Hellion Chat — Default-Tab-Presets (kanalspezifisch) -->
<data name="Tabs_Presets_System" xml:space="preserve">
<value>System</value>
</data>
<data name="Tabs_Presets_FreeCompany" xml:space="preserve">
<value>Free Company</value>
</data>
<data name="Tabs_Presets_Party" xml:space="preserve">
<value>Gruppe</value>
</data>
<data name="Tabs_Presets_Beginner" xml:space="preserve">
<value>Neulinge</value>
</data>
<data name="Tabs_Presets_Linkshell" xml:space="preserve">
<value>Linkshell</value>
</data>
<data name="Tabs_Presets_Linkshell_Hint" xml:space="preserve">
<value>Wenn du mehrere Linkshells benutzt, empfiehlt der Maintainer einen Tab pro Shell für eine sauberere Übersicht. Tab duplizieren und je Kopie die Kanalauswahl einschränken.</value>
</data>
<data name="ChatColourPresets_Default" xml:space="preserve">
<value>Klassik (Chat 2 Default)</value>
</data>
<data name="ChatColourPresets_HighContrast" xml:space="preserve">
<value>Hoher Kontrast</value>
</data>
<data name="ChatColourPresets_Pastell" xml:space="preserve">
<value>Pastell</value>
</data>
<data name="ChatColourPresets_DarkModeTuned" xml:space="preserve">
<value>Dunkelmodus-optimiert</value>
</data>
<data name="ChatColourPresets_Hellion" xml:space="preserve">
<value>Hellion</value>
</data>
<data name="ChatColourPresets_NightBlue" xml:space="preserve">
<value>Night Blue</value>
</data>
<data name="ChatColourPresets_IndigoViolet" xml:space="preserve">
<value>Indigo Violet</value>
</data>
<data name="Settings_Appearance_Colours_PresetsHint" xml:space="preserve">
<value>Tipp: Presets überschreiben deine aktuellen Channel-Farben sofort.</value>
</data>
<data name="Settings_Window_PopOutInputEnabled_Name" xml:space="preserve">
<value>Eingabe in Pop-Outs aktivieren</value>
</data>
<data name="Settings_Window_PopOutInputEnabled_Description" xml:space="preserve">
<value>Master-Switch: erlaubt direktes Tippen und Absenden in jedem Pop-Out-Fenster (inkl. Auto-Tell-Tabs). Channel-Wechsel im Pop-Out wirkt global wie im Hauptfenster; Text-Buffer und History-Cursor sind pro Pop-Out unabhängig.</value>
</data>
<data name="Popout_v060_HintText" xml:space="preserve">
<value>Neu in v0.6.0: Du kannst jetzt direkt im Pop-Out tippen. Master-Switch in den Fenster-Settings aktivieren.</value>
</data>
<data name="Popout_v060_HintAck" xml:space="preserve">
<value>Verstanden</value>
</data>
<data name="Popout_v060_HintOpenSettings" xml:space="preserve">
<value>Fenster-Settings öffnen</value>
</data>
<data name="Hint_v061_PopOutHeader_Body" xml:space="preserve">
<value>Du kannst jeden Chat-Tab als eigenes Fenster öffnen. Klicke auf das Fenster-Symbol oben rechts oder rechtsklicke den Tab. Neu in v0.6.1: die Pop-Out-Eingabe ist standardmäßig aktiv (abschaltbar unter Einstellungen → Fenster).</value>
</data>
<data name="Hint_v061_PopOutHeader_Ack" xml:space="preserve">
<value>Verstanden</value>
</data>
<data name="Hint_v061_PopOutHeader_OpenSettings" xml:space="preserve">
<value>Einstellungen öffnen</value>
</data>
<data name="ChatTwoConflictTitle" xml:space="preserve">
<value>Hellion Chat kann nicht starten, solange Chat 2 geladen ist.</value>
</data>
<data name="ChatTwoConflictBody" xml:space="preserve">
<value>Hellion Chat ist ein eigenständiger Fork von Chat 2. Beide Plugins ersetzen dasselbe Chat-Fenster im Spiel und würden zur Laufzeit kollidieren.</value>
</data>
<data name="ChatTwoConflictAction" xml:space="preserve">
<value>Chat 2 in /xlplugins deaktivieren, danach Hellion Chat erneut aktivieren.</value>
</data>
</root>
+621
View File
@@ -0,0 +1,621 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Privacy_Tab_Title" xml:space="preserve">
<value>Privacy</value>
</data>
<data name="Privacy_FilterEnabled_Name" xml:space="preserve">
<value>Enable privacy filter</value>
</data>
<data name="Privacy_FilterEnabled_Description" xml:space="preserve">
<value>When enabled, only messages from whitelisted channels are persisted to the database. Disabling restores the original behavior (everything except battle messages is stored).</value>
</data>
<data name="Privacy_FilterEnabled_StorageOnly_Help" xml:space="preserve">
<value>The filter only controls what is written to the local database. The chat log itself keeps showing every message live, disallowed channels just stop being saved. Use the channel hide options in your in-game chat tabs if you want to remove channels from the visible chat.</value>
</data>
<data name="Privacy_Filter_Tree_Heading" xml:space="preserve">
<value>Privacy filter and whitelist</value>
</data>
<data name="Privacy_Whitelist_Help" xml:space="preserve">
<value>Pick which channels are stored in the local database. Privacy-First default: only your own conversations. Use the buttons below to apply a preset.</value>
</data>
<data name="Privacy_Preset_PrivacyFirst" xml:space="preserve">
<value>Privacy-First (recommended)</value>
</data>
<data name="Privacy_Preset_ClearAll" xml:space="preserve">
<value>Clear all</value>
</data>
<data name="Privacy_Preset_SelectAll" xml:space="preserve">
<value>Select all</value>
</data>
<data name="Privacy_Group_DirectMessages" xml:space="preserve">
<value>Direct Messages</value>
</data>
<data name="Privacy_Group_PartyAlliance" xml:space="preserve">
<value>Party &amp; Alliance</value>
</data>
<data name="Privacy_Group_FreeCompany" xml:space="preserve">
<value>Free Company</value>
</data>
<data name="Privacy_Group_Linkshells" xml:space="preserve">
<value>Linkshells</value>
</data>
<data name="Privacy_Group_CrossLinkshells" xml:space="preserve">
<value>Cross-World Linkshells</value>
</data>
<data name="Privacy_Group_ExtraChat" xml:space="preserve">
<value>ExtraChat (Encrypted)</value>
</data>
<data name="Privacy_Group_PublicChat" xml:space="preserve">
<value>Public Chat (third-party data)</value>
</data>
<data name="Privacy_Group_SystemLogs" xml:space="preserve">
<value>System &amp; Game Logs</value>
</data>
<data name="Privacy_PersistUnknown_Name" xml:space="preserve">
<value>Persist unknown channel types</value>
</data>
<data name="Privacy_PersistUnknown_Description" xml:space="preserve">
<value>Failsafe for ChatTypes added by future FFXIV patches that this plugin does not yet know about. Default OFF (Privacy-First). Turn ON if you want a complete log including future channels.</value>
</data>
<data name="Cleanup_Heading" xml:space="preserve">
<value>Apply filter to existing database</value>
</data>
<data name="Cleanup_Help_Intro" xml:space="preserve">
<value>The privacy filter only applies to new messages. Use the cleanup below to retroactively remove already-stored messages that don't match your saved whitelist.</value>
</data>
<data name="Cleanup_Help_SavedNote" xml:space="preserve">
<value>Cleanup uses your SAVED whitelist (Plugin.Config), not unsaved edits above. Click Save first if you want to apply your current edits.</value>
</data>
<data name="Retention_Help_SavedNote" xml:space="preserve">
<value>The manual sweep uses your SAVED retention policy, not the slider values above. Click Save first if you want the run to apply your current edits.</value>
</data>
<data name="Cleanup_Preview_Stale" xml:space="preserve">
<value>Preview is out of date — your whitelist has changed since the last refresh. Click Refresh to recalculate.</value>
</data>
<data name="Cleanup_RefreshPreview" xml:space="preserve">
<value>Refresh preview</value>
</data>
<data name="Cleanup_NoPreview" xml:space="preserve">
<value>No preview yet. Click Refresh to compute the impact.</value>
</data>
<data name="Cleanup_TotalStored" xml:space="preserve">
<value>Total stored messages: {0:N0}</value>
</data>
<data name="Cleanup_WillKeep" xml:space="preserve">
<value>Will keep: {0:N0}</value>
</data>
<data name="Cleanup_WillDelete" xml:space="preserve">
<value>Will delete: {0:N0}</value>
</data>
<data name="Cleanup_Breakdown" xml:space="preserve">
<value>Per-channel breakdown</value>
</data>
<data name="Cleanup_Marker_Keep" xml:space="preserve">
<value>[KEEP] </value>
</data>
<data name="Cleanup_Marker_Delete" xml:space="preserve">
<value>[DELETE]</value>
</data>
<data name="Cleanup_Apply_Label" xml:space="preserve">
<value>Apply current filter to database</value>
</data>
<data name="Cleanup_Apply_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: Hard-deletes {0:N0} messages, then runs VACUUM. Cannot be undone.</value>
</data>
<data name="Cleanup_Running" xml:space="preserve">
<value>Cleanup running in background…</value>
</data>
<data name="Cleanup_PreviewError" xml:space="preserve">
<value>Failed to compute cleanup preview, see /xllog</value>
</data>
<data name="Cleanup_Success" xml:space="preserve">
<value>Privacy cleanup complete: {0:N0} messages removed.</value>
</data>
<data name="Cleanup_Error" xml:space="preserve">
<value>Privacy cleanup failed, see /xllog</value>
</data>
<data name="Retention_Heading" xml:space="preserve">
<value>Message retention</value>
</data>
<data name="Retention_Enabled_Name" xml:space="preserve">
<value>Auto-delete messages after a per-channel retention window</value>
</data>
<data name="Retention_Enabled_Description" xml:space="preserve">
<value>When enabled, messages older than the configured window are deleted on every plugin start (at most once per 24 hours). Off by default. The plugin never deletes history without your explicit consent.</value>
</data>
<data name="Retention_Default_Label" xml:space="preserve">
<value>Default retention (days, 0 = never)</value>
</data>
<data name="Retention_Default_Help" xml:space="preserve">
<value>Applies to channels without an explicit override below.</value>
</data>
<data name="Retention_Reset_Spec" xml:space="preserve">
<value>Reset overrides to spec defaults</value>
</data>
<data name="Retention_Clear_Overrides" xml:space="preserve">
<value>Clear all overrides</value>
</data>
<data name="Retention_Tree_Heading" xml:space="preserve">
<value>Per-channel retention overrides</value>
</data>
<data name="Retention_Tag_Override" xml:space="preserve">
<value>[override]</value>
</data>
<data name="Retention_Tag_Spec" xml:space="preserve">
<value>[spec]</value>
</data>
<data name="Retention_Tag_Global" xml:space="preserve">
<value>[global]</value>
</data>
<data name="Retention_Reset_Button" xml:space="preserve">
<value>reset</value>
</data>
<data name="Retention_Apply_Label" xml:space="preserve">
<value>Apply retention policy now</value>
</data>
<data name="Retention_Apply_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: runs the retention sweep immediately using the SAVED policy. Save your changes first.</value>
</data>
<data name="Retention_Running" xml:space="preserve">
<value>Retention sweep running in background…</value>
</data>
<data name="Retention_LastRun_Never" xml:space="preserve">
<value>Last run: never</value>
</data>
<data name="Retention_LastRun_At" xml:space="preserve">
<value>Last run: {0:yyyy-MM-dd HH:mm}</value>
</data>
<data name="Retention_Success" xml:space="preserve">
<value>Retention sweep complete: {0:N0} messages removed.</value>
</data>
<data name="Retention_Error" xml:space="preserve">
<value>Retention sweep failed, see /xllog</value>
</data>
<data name="Wizard_Title" xml:space="preserve">
<value>Hellion Chat — Welcome</value>
</data>
<data name="Wizard_Intro" xml:space="preserve">
<value>Pick a starting profile. You can change anything later under Settings → Privacy.</value>
</data>
<data name="Wizard_Profile_PrivacyFirst_Heading" xml:space="preserve">
<value>Privacy-First (recommended)</value>
</data>
<data name="Wizard_Profile_PrivacyFirst_Description" xml:space="preserve">
<value>Only your own conversations are stored: Tells, Party, FC, Linkshells, Cross-World Linkshells, Alliance and ExtraChat. Public chat, NPC dialogue and system spam are dropped at the storage layer. Retention follows the spec defaults (Tells 365 days, own-conversation channels 90 days).</value>
</data>
<data name="Wizard_Profile_PrivacyFirst_Apply" xml:space="preserve">
<value>Use Privacy-First</value>
</data>
<data name="Wizard_Profile_Casual_Heading" xml:space="preserve">
<value>Casual</value>
</data>
<data name="Wizard_Profile_Casual_Description" xml:space="preserve">
<value>Privacy-First plus a 24-hour window for public chat (Say, Shout, Yell, both emote types, Novice Network). For RP players who want to look up the last scene without keeping public chat forever.</value>
</data>
<data name="Wizard_Profile_Casual_Apply" xml:space="preserve">
<value>Use Casual</value>
</data>
<data name="Wizard_Profile_FullHistory_Heading" xml:space="preserve">
<value>Full History</value>
</data>
<data name="Wizard_Profile_FullHistory_Description" xml:space="preserve">
<value>Disables the privacy filter entirely. Stores everything except battle logs (the original full-history behavior). Retention is OFF, history grows forever.</value>
</data>
<data name="Wizard_Profile_FullHistory_GdprWarning" xml:space="preserve">
<value>GDPR notice: storing third-party messages (Say/Shout/Yell of strangers, NPC dialogue with player names, etc.) for an unlimited time may exceed the personal/household exemption (Art. 2(2)(c)). Use this profile only if you have a clear reason to keep the full archive.</value>
</data>
<data name="Wizard_Profile_FullHistory_Apply" xml:space="preserve">
<value>Use Full History</value>
</data>
<data name="Wizard_Reopen_Button" xml:space="preserve">
<value>Show wizard again</value>
</data>
<data name="Export_Heading" xml:space="preserve">
<value>Export (GDPR Art. 15 — right of access)</value>
</data>
<data name="Export_Help" xml:space="preserve">
<value>Export stored messages to Markdown, JSON or CSV. Use this to fulfil a request for access from someone whose messages you have, or to take your own history with you.</value>
</data>
<data name="Export_Range_Label" xml:space="preserve">
<value>Last X days (0 = all time)</value>
</data>
<data name="Export_Sender_Label" xml:space="preserve">
<value>Sender contains (optional, case-insensitive)</value>
</data>
<data name="Export_Channels_Heading" xml:space="preserve">
<value>Limit to channels</value>
</data>
<data name="Export_Channels_AllOff" xml:space="preserve">
<value>(none selected = all stored channels)</value>
</data>
<data name="Export_Format_Label" xml:space="preserve">
<value>Format</value>
</data>
<data name="Export_Format_Markdown" xml:space="preserve">
<value>Markdown</value>
</data>
<data name="Export_Format_Json" xml:space="preserve">
<value>JSON</value>
</data>
<data name="Export_Format_Csv" xml:space="preserve">
<value>CSV</value>
</data>
<data name="Export_Button" xml:space="preserve">
<value>Export to file…</value>
</data>
<data name="Export_Dialog_Title" xml:space="preserve">
<value>Save export</value>
</data>
<data name="Export_Running" xml:space="preserve">
<value>Export running in background…</value>
</data>
<data name="Export_Success" xml:space="preserve">
<value>Export complete: {0:N0} messages written to {1}</value>
</data>
<data name="Export_Empty" xml:space="preserve">
<value>Export complete: no messages matched the filter.</value>
</data>
<data name="Export_Error" xml:space="preserve">
<value>Export failed, see /xllog</value>
</data>
<data name="Theme_Enabled_Name" xml:space="preserve">
<value>Use the Hellion theme across all plugin windows</value>
</data>
<data name="Theme_Enabled_Description" xml:space="preserve">
<value>Hellion Online Media palette of Arctic Cyan plus Ember Orange, applied across the chat log, settings, viewers and the wizard. Disable to fall back to the default Dalamud look.</value>
</data>
<data name="Theme_WindowOpacity_Label" xml:space="preserve">
<value>Window opacity</value>
</data>
<data name="Theme_WindowOpacity_Help" xml:space="preserve">
<value>How opaque the plugin panes are. Lower values let the game shine through; form fields and dialogs stay opaque on top so they remain readable.</value>
</data>
<data name="Theme_UseHellionFont_Name" xml:space="preserve">
<value>Use the bundled Hellion font (Exo 2)</value>
</data>
<data name="Theme_UseHellionFont_Description" xml:space="preserve">
<value>Renders chat and UI in Exo 2 (SIL Open Font License 1.1) which ships with the plugin. Disable to fall back to whatever font you picked under Settings → Fonts.</value>
</data>
<data name="About_Maintainer_Heading" xml:space="preserve">
<value>Maintainer</value>
</data>
<data name="About_Maintainer_Body" xml:space="preserve">
<value>I maintain Hellion Chat through Hellion Online Media. The website has the contact details for licensing, legal or business questions.</value>
</data>
<data name="About_Maintainer_Website_Label" xml:space="preserve">
<value>Website:</value>
</data>
<data name="About_Mission_Heading" xml:space="preserve">
<value>Why this fork exists</value>
</data>
<data name="About_Mission_P1" xml:space="preserve">
<value>Hellion Chat is not trying to replace Chat 2. Chat 2 ships a complete chat experience with full history available for filtering, search and replay. That default is the right one for most users. This fork takes a different stance: a smaller default footprint, with extra knobs for users who want to keep less third-party chat on disk.</value>
</data>
<data name="About_Mission_P2" xml:space="preserve">
<value>The reason I wanted that narrower default was personal. After two years on Chat 2 my database had grown past two million messages, most of them /say, /shout and /yell from strangers in Limsa. That data is exactly what makes Chat 2's full-history view powerful and most users are happy to keep it. For my own taste I wanted a smaller default. So I built this fork.</value>
</data>
<data name="About_Mission_P3" xml:space="preserve">
<value>I am not chasing a big audience and the fork is not in competition with Chat 2. The code is open under the same EUPL-1.2 licence as the upstream plugin. Infi, Anna or anyone else are welcome to read it, borrow ideas, ask questions, or ignore the project. All three are fine by me.</value>
</data>
<data name="About_BuiltOn_Heading" xml:space="preserve">
<value>Built on Chat 2</value>
</data>
<data name="About_BuiltOn_P1" xml:space="preserve">
<value>Hellion Chat is a fork of Chat 2 by Infi and Anna (ascclemens). The chat replacement window, the IPC integration, the rendering engine and the entire storage core come from upstream Chat 2.</value>
</data>
<data name="About_BuiltOn_P2" xml:space="preserve">
<value>The webinterface is the only major piece I removed. It is built for remote access to chat from a second device, which is a different focus than the smaller default footprint this fork is built around. Aligning it with these defaults would have meant a substantial rebuild, so removing it was the cleaner path for this particular fork.</value>
</data>
<data name="About_BuiltOn_Upstream_Label" xml:space="preserve">
<value>Upstream repository:</value>
</data>
<data name="About_License_Heading" xml:space="preserve">
<value>License</value>
</data>
<data name="About_License_P1" xml:space="preserve">
<value>Hellion Chat and Chat 2 both ship under the European Union Public Licence v1.2 (EUPL-1.2).</value>
</data>
<data name="About_License_P2" xml:space="preserve">
<value>© 2023 to 2026, the Chat 2 authors (Infi, Anna and the upstream contributors).</value>
</data>
<data name="About_License_P3" xml:space="preserve">
<value>© 2026 Hellion Online Media for the additions made in this fork.</value>
</data>
<data name="About_SE_Heading" xml:space="preserve">
<value>FINAL FANTASY XIV disclaimer</value>
</data>
<data name="About_SE_P1" xml:space="preserve">
<value>FINAL FANTASY XIV © SQUARE ENIX CO., LTD. All rights reserved.</value>
</data>
<data name="About_SE_P2" xml:space="preserve">
<value>Hellion Chat is an unofficial, fan-made plugin. It has no affiliation with Square Enix and is not endorsed, sponsored or approved by them.</value>
</data>
<data name="About_Localization_Heading" xml:space="preserve">
<value>Localization</value>
</data>
<data name="About_Localization_P1" xml:space="preserve">
<value>The German translations of the Hellion-specific strings come from me. Other languages are not provided yet.</value>
</data>
<data name="About_Localization_P2" xml:space="preserve">
<value>The translator list below covers the upstream Chat 2 strings on Crowdin. Those volunteers translated Chat 2, not the Hellion additions.</value>
</data>
<data name="About_Translators_TreeNode" xml:space="preserve">
<value>Chat 2 community translators (upstream)</value>
</data>
<!-- Hellion Chat — Auto-Tell-Tabs (runtime strings) -->
<data name="AutoTellTabs_SectionHeader" xml:space="preserve">
<value>Active Tells</value>
</data>
<data name="AutoTellTabs_HistorySeparator" xml:space="preserve">
<value>— Earlier conversations —</value>
</data>
<data name="AutoTellTabs_HistoryLoadError" xml:space="preserve">
<value>History could not be loaded.</value>
</data>
<data name="AutoTellTabs_GreetedTooltip" xml:space="preserve">
<value>Marked as greeted. Click to remove the marker.</value>
</data>
<data name="AutoTellTabs_UnGreetedTooltip" xml:space="preserve">
<value>Mark as greeted.</value>
</data>
<!-- Hellion Chat — Auto-Tell-Tabs (Chat settings tab) -->
<data name="ChatLog_AutoTellTabs_Section_Title" xml:space="preserve">
<value>Auto-Tell-Tabs</value>
</data>
<data name="ChatLog_AutoTellTabs_Enable_Name" xml:space="preserve">
<value>Open a tab automatically for each tell partner</value>
</data>
<data name="ChatLog_AutoTellTabs_Enable_Description" xml:space="preserve">
<value>When you receive or send a /tell, a temporary tab dedicated to that player is opened automatically. Tabs vanish on logout.</value>
</data>
<data name="ChatLog_AutoTellTabs_Limit_Name" xml:space="preserve">
<value>Maximum number of auto tell tabs</value>
</data>
<data name="ChatLog_AutoTellTabs_Limit_Description" xml:space="preserve">
<value>When the limit is reached, greeted tabs with the oldest activity are dropped first. The change applies on the next /tell.</value>
</data>
<data name="ChatLog_AutoTellTabs_Compact_Name" xml:space="preserve">
<value>Compact display</value>
</data>
<data name="ChatLog_AutoTellTabs_Compact_Description" xml:space="preserve">
<value>Show only a thin separator between persistent tabs and auto tell tabs, without the section header.</value>
</data>
<data name="ChatLog_AutoTellTabs_GreetedToggle_Name" xml:space="preserve">
<value>Show "mark as greeted" button</value>
</data>
<data name="ChatLog_AutoTellTabs_GreetedToggle_Description" xml:space="preserve">
<value>Adds a click-to-toggle button next to each auto tell tab to mark a partner as already greeted, dimming the tab name when set. Useful for club greeters tracking many parallel conversations; off by default.</value>
</data>
<data name="ChatLog_AutoTellTabs_OpenAsPopout_Name" xml:space="preserve">
<value>Open new /tell tabs directly as pop-out</value>
</data>
<data name="ChatLog_AutoTellTabs_OpenAsPopout_Description" xml:space="preserve">
<value>When enabled, each newly created /tell tab opens directly as its own window. Closing the window returns the tab to the sidebar.</value>
</data>
<data name="ChatLog_AutoTellTabs_PreloadHint" xml:space="preserve">
<value>The number of preloaded tells is configured in the Privacy tab.</value>
</data>
<data name="ChatLog_AutoTellTabs_ConflictHint" xml:space="preserve">
<value>Heads-up: if XIV Messanger or a similar plugin is suppressing direct messages, turn its "Suppress DMs" option off so Hellion Chat can receive tells and open the auto tabs.</value>
</data>
<!-- Hellion Chat — Auto-Tell-Tabs (Privacy settings tab) -->
<data name="Privacy_AutoTellTabs_Section_Title" xml:space="preserve">
<value>Tell history in auto tabs</value>
</data>
<data name="Privacy_AutoTellTabs_Preload_Name" xml:space="preserve">
<value>Number of preloaded tells</value>
</data>
<data name="Privacy_AutoTellTabs_Preload_Description" xml:space="preserve">
<value>How many earlier tell messages are loaded from the database when an auto tell tab is opened. 0 disables the preload.</value>
</data>
<data name="Privacy_AutoTellTabs_Preload_Hint" xml:space="preserve">
<value>Only takes effect when auto tell tabs are enabled in the Chat tab.</value>
</data>
<!-- Hellion Chat — Settings UX Polish v10 wipe migration -->
<data name="SettingsRefactor_Migration_Title" xml:space="preserve">
<value>Settings reorganised</value>
</data>
<data name="SettingsRefactor_Migration_Content" xml:space="preserve">
<value>Hellion Chat 0.5.0 reorganised the settings into themed tabs. Your chat database and your message history stay untouched. Settings have been reset to defaults; if you want to pick a privacy profile again, the reopen button is in the Privacy tab. A backup of your previous config is at HellionChat.json.pre-v10-backup next to the live config file.</value>
</data>
<!-- Hellion Chat — Settings UX Polish 8-tab structure -->
<data name="Settings_Tab_General" xml:space="preserve">
<value>General</value>
</data>
<data name="Settings_Tab_Appearance" xml:space="preserve">
<value>Appearance</value>
</data>
<data name="Settings_Tab_Window" xml:space="preserve">
<value>Window</value>
</data>
<data name="Settings_Tab_Chat" xml:space="preserve">
<value>Chat</value>
</data>
<data name="Settings_Tab_Tabs" xml:space="preserve">
<value>Tabs</value>
</data>
<data name="Settings_Tab_Database" xml:space="preserve">
<value>Database</value>
</data>
<data name="Settings_Tab_Information" xml:space="preserve">
<value>Information</value>
</data>
<!-- Hellion Chat — General-Tab section headings -->
<data name="Settings_General_Input_Heading" xml:space="preserve">
<value>Input</value>
</data>
<data name="Settings_General_Audio_Heading" xml:space="preserve">
<value>Audio &amp; Notifications</value>
</data>
<data name="Settings_General_Performance_Heading" xml:space="preserve">
<value>Performance</value>
</data>
<data name="Settings_General_Language_Heading" xml:space="preserve">
<value>Language &amp; Input Helpers</value>
</data>
<!-- Hellion Chat — Appearance-Tab section headings -->
<data name="Settings_Appearance_Theme_Heading" xml:space="preserve">
<value>Theme</value>
</data>
<data name="Settings_Appearance_Fonts_Heading" xml:space="preserve">
<value>Fonts</value>
</data>
<data name="Settings_Appearance_Colours_Heading" xml:space="preserve">
<value>Chat Colours</value>
</data>
<data name="Settings_Appearance_Timestamps_Heading" xml:space="preserve">
<value>Timestamps</value>
</data>
<!-- Hellion Chat — Window-Tab section headings -->
<data name="Settings_Window_Hide_Heading" xml:space="preserve">
<value>Hide</value>
</data>
<data name="Settings_Window_InactivityHide_Heading" xml:space="preserve">
<value>Inactivity Hide</value>
</data>
<data name="Settings_Window_Frame_Heading" xml:space="preserve">
<value>Window Frame</value>
</data>
<data name="Settings_Window_Tooltips_Heading" xml:space="preserve">
<value>Tooltips</value>
</data>
<!-- Hellion Chat — Chat-Tab section headings -->
<data name="Settings_Chat_AutoTellTabs_Heading" xml:space="preserve">
<value>Auto-Tell-Tabs</value>
</data>
<data name="Settings_Chat_Behaviour_Heading" xml:space="preserve">
<value>Message Behaviour</value>
</data>
<data name="Settings_Chat_Preview_Heading" xml:space="preserve">
<value>Preview</value>
</data>
<data name="Settings_Chat_Emotes_Heading" xml:space="preserve">
<value>Emotes</value>
</data>
<!-- Hellion Chat — Database-Tab section headings -->
<data name="Settings_Database_Storage_Heading" xml:space="preserve">
<value>Storage</value>
</data>
<data name="Settings_Database_Viewer_Heading" xml:space="preserve">
<value>Overview</value>
</data>
<data name="Settings_Database_Stats_Heading" xml:space="preserve">
<value>Maintenance</value>
</data>
<!-- Hellion Chat — Information-Tab section headings -->
<data name="Settings_Information_VersionInfo_Heading" xml:space="preserve">
<value>Version Info</value>
</data>
<data name="Settings_Information_About_Heading" xml:space="preserve">
<value>About HellionChat</value>
</data>
<data name="Settings_Information_Changelog_Heading" xml:space="preserve">
<value>Changelog</value>
</data>
<!-- Hellion Chat — Default tab presets (channel-themed) -->
<data name="Tabs_Presets_System" xml:space="preserve">
<value>System</value>
</data>
<data name="Tabs_Presets_FreeCompany" xml:space="preserve">
<value>Free Company</value>
</data>
<data name="Tabs_Presets_Party" xml:space="preserve">
<value>Party</value>
</data>
<data name="Tabs_Presets_Beginner" xml:space="preserve">
<value>Beginner</value>
</data>
<data name="Tabs_Presets_Linkshell" xml:space="preserve">
<value>Linkshell</value>
</data>
<data name="Tabs_Presets_Linkshell_Hint" xml:space="preserve">
<value>If you use multiple linkshells, the maintainer recommends one tab per shell for cleaner readability. Duplicate this tab and narrow the channel selection per copy.</value>
</data>
<data name="ChatColourPresets_Default" xml:space="preserve">
<value>Klassik (Chat 2 Default)</value>
</data>
<data name="ChatColourPresets_HighContrast" xml:space="preserve">
<value>High-Contrast</value>
</data>
<data name="ChatColourPresets_Pastell" xml:space="preserve">
<value>Pastell</value>
</data>
<data name="ChatColourPresets_DarkModeTuned" xml:space="preserve">
<value>Dark-Mode-Tuned</value>
</data>
<data name="ChatColourPresets_Hellion" xml:space="preserve">
<value>Hellion</value>
</data>
<data name="ChatColourPresets_NightBlue" xml:space="preserve">
<value>Night Blue</value>
</data>
<data name="ChatColourPresets_IndigoViolet" xml:space="preserve">
<value>Indigo Violet</value>
</data>
<data name="Settings_Appearance_Colours_PresetsHint" xml:space="preserve">
<value>Tip: presets overwrite your current channel colours immediately.</value>
</data>
<data name="Settings_Window_PopOutInputEnabled_Name" xml:space="preserve">
<value>Enable input in pop-outs</value>
</data>
<data name="Settings_Window_PopOutInputEnabled_Description" xml:space="preserve">
<value>Master switch: lets you type and send messages directly inside every pop-out window (including auto-tell tabs). Channel changes inside a pop-out apply globally just like in the main window; the text buffer and history cursor stay independent per pop-out.</value>
</data>
<data name="Popout_v060_HintText" xml:space="preserve">
<value>New in v0.6.0: you can type directly inside pop-out windows. Toggle the master switch in the window settings to enable it.</value>
</data>
<data name="Popout_v060_HintAck" xml:space="preserve">
<value>Got it</value>
</data>
<data name="Popout_v060_HintOpenSettings" xml:space="preserve">
<value>Open window settings</value>
</data>
<data name="Hint_v061_PopOutHeader_Body" xml:space="preserve">
<value>You can open any chat tab as its own window. Click the window icon in the top right or right-click the tab. New in v0.6.1: pop-out input is enabled by default (can be turned off under Settings → Window).</value>
</data>
<data name="Hint_v061_PopOutHeader_Ack" xml:space="preserve">
<value>Got it</value>
</data>
<data name="Hint_v061_PopOutHeader_OpenSettings" xml:space="preserve">
<value>Open Settings</value>
</data>
<data name="ChatTwoConflictTitle" xml:space="preserve">
<value>Hellion Chat cannot start while Chat 2 is loaded.</value>
</data>
<data name="ChatTwoConflictBody" xml:space="preserve">
<value>Hellion Chat is a standalone fork of Chat 2. Both plugins replace the same in-game chat window and would conflict at runtime if loaded together.</value>
</data>
<data name="ChatTwoConflictAction" xml:space="preserve">
<value>Disable Chat 2 in /xlplugins, then re-enable Hellion Chat.</value>
</data>
</root>
+4095
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
using Dalamud.Game.ClientState.Objects.SubKinds;
using Lumina.Excel;
using Lumina.Excel.Sheets;
namespace HellionChat;
public static class Sheets
{
public static readonly ExcelSheet<Item> ItemSheet;
public static readonly ExcelSheet<World> WorldSheet;
public static readonly ExcelSheet<Status> StatusSheet;
public static readonly ExcelSheet<LogKind> LogKindSheet;
public static readonly ExcelSheet<LogFilter> LogFilterSheet;
public static readonly ExcelSheet<EventItem> EventItemSheet;
public static readonly ExcelSheet<Completion> CompletionSheet;
public static readonly ExcelSheet<TerritoryType> TerritorySheet;
public static readonly ExcelSheet<TextCommand> TextCommandSheet;
public static readonly ExcelSheet<EventItemHelp> EventItemHelpSheet;
static Sheets()
{
ItemSheet = Plugin.DataManager.GetExcelSheet<Item>();
WorldSheet = Plugin.DataManager.GetExcelSheet<World>();
StatusSheet = Plugin.DataManager.GetExcelSheet<Status>();
LogKindSheet = Plugin.DataManager.GetExcelSheet<LogKind>();
LogFilterSheet = Plugin.DataManager.GetExcelSheet<LogFilter>();
EventItemSheet = Plugin.DataManager.GetExcelSheet<EventItem>();
CompletionSheet = Plugin.DataManager.GetExcelSheet<Completion>();
TerritorySheet = Plugin.DataManager.GetExcelSheet<TerritoryType>();
TextCommandSheet = Plugin.DataManager.GetExcelSheet<TextCommand>();
EventItemHelpSheet = Plugin.DataManager.GetExcelSheet<EventItemHelp>();
}
public static bool IsInForay() =>
TerritorySheet.TryGetRow(Plugin.ClientState.TerritoryType, out var row) &&
row.TerritoryIntendedUse.RowId is 41 or 61;
public static IEnumerable<World> WorldsOnDatacenter(IPlayerCharacter character)
{
var dcRow = character.HomeWorld.Value.DataCenter.Value.Region.RowId;
return WorldSheet.Where(world => world.IsPublic && world.DataCenter.Value.Region.RowId == dcRow);
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace HellionChat.Ui;
internal class AutoCompleteInfo
{
internal string ToComplete;
internal int StartPos { get; }
internal int EndPos { get; }
internal AutoCompleteInfo(string toComplete, int startPos, int endPos)
{
ToComplete = toComplete;
StartPos = startPos;
EndPos = endPos;
}
}
+246
View File
@@ -0,0 +1,246 @@
using System;
using System.Numerics;
using HellionChat.Code;
using HellionChat.Util;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Utility.Raii;
namespace HellionChat.Ui;
// Hellion Chat — v0.6.0 input bar component for pop-out windows.
//
// Pragmatischer Refactor-Scope: Render() ist ein leerer Marker-Stub für
// das Hauptfenster — der bestehende Input-Layer in ChatLogWindow bleibt
// unangetastet, weil ein 400-Zeilen-Extract aus einem 1926-Zeilen-File
// das v0.6.0-Risiko unverhältnismäßig erhöhen würde. Pop-Outs nutzen
// ausschließlich RenderCompact(), das ist der ganze v0.6.0-Mehrwert.
// Sollte das Hauptfenster selber später eine Compact-Variante brauchen
// (oder das große Extract sich aus anderem Grund lohnen), kann Render()
// in einem späteren Cycle gefüllt werden.
public sealed class ChatInputBar
{
private readonly Plugin _plugin;
private readonly ChatLogWindow _host;
private readonly Func<Tab?> _activeTabAccessor;
private readonly InputState _state = new();
public ChatInputBar(Plugin plugin, ChatLogWindow host, Func<Tab?> activeTabAccessor)
{
_plugin = plugin;
_host = host;
_activeTabAccessor = activeTabAccessor;
}
public InputState State => _state;
public bool IsFocused { get; private set; }
// Stub. v0.6.0 belässt den Hauptfenster-Input wie er ist.
public void Render()
{
}
// Compact rendering for pop-out windows.
//
// v0.6.0 Compact-Layout: Channel-Icon-Button links (Background-Farbe
// aus ChatColours), Text-Input rechts daneben. Auto-Translate-Picker
// ist bewusst NICHT im Compact-Mode (Spec-Abweichung Layout D → A).
// Rechtfertigung: das Hauptfenster-Auto-Complete-Popup ist nicht ohne
// grossen Refactor pro Window instanzierbar; typische Pop-Out-Use-Cases
// (FC-Greeter, Club-Hostess) brauchen Auto-Translate selten dort.
// Eigene Compact-Auto-Complete-Implementation kann ein späterer
// Cycle nachreichen wenn Tester-Feedback das verlangt.
//
// Channel-Switch wirkt via Plugin.Functions.Chat global (FFXIV-API).
// Pro Pop-Out unabhängig bleiben Text-Buffer und History-Cursor.
public void RenderCompact()
{
var tab = _activeTabAccessor();
if (tab == null)
return;
DrawChannelIconButton(tab);
ImGui.SameLine();
DrawCompactInput(tab);
}
private void DrawCompactInput(Tab tab)
{
// Input takes the whole remaining width — no auto-translate button
// reserved on the right side in v0.6.0 (see RenderCompact comment).
var inputWidth = ImGui.GetContentRegionAvail().X;
if (inputWidth < 60f)
inputWidth = 60f;
ImGui.SetNextItemWidth(inputWidth);
// CallbackHistory wires up Up/Down navigation against the shared
// InputHistoryService. Submit is detected the same way the main
// window does it: via IsItemDeactivated + Enter, NOT EnterReturnsTrue
// (matching v0.5.x ChatLogWindow.cs behavior).
const ImGuiInputTextFlags flags = ImGuiInputTextFlags.CallbackHistory;
ImGui.InputText($"##chat-compact-input-{tab.Identifier}", ref _state.Buffer, 500, flags, CompactCallback);
IsFocused = ImGui.IsItemActive();
if (ImGui.IsItemDeactivated()
&& (ImGui.IsKeyDown(ImGuiKey.Enter) || ImGui.IsKeyDown(ImGuiKey.KeypadEnter)))
{
SubmitCompact(tab);
}
}
private void SubmitCompact(Tab tab)
{
if (string.IsNullOrWhiteSpace(_state.Buffer))
return;
var text = _state.Buffer;
_state.Buffer = string.Empty;
_state.HistoryCursor = -1;
_host.SendChatBoxFromExternal(tab, text);
}
// History-navigation callback for the compact input. Mirrors the main
// window's logic but operates on _state.HistoryCursor and the shared
// InputHistoryService. Index semantics match v0.5.x InputBacklog:
// 0 = oldest, Count-1 = newest.
private unsafe int CompactCallback(scoped ref ImGuiInputTextCallbackData data)
{
if (data.EventFlag != ImGuiInputTextFlags.CallbackHistory)
return 0;
var prev = _state.HistoryCursor;
switch (data.EventKey)
{
case ImGuiKey.UpArrow:
switch (_state.HistoryCursor)
{
case -1:
var offset = 0;
if (!string.IsNullOrWhiteSpace(_state.Buffer))
{
InputHistoryService.Push(_state.Buffer);
offset = 1;
}
_state.HistoryCursor = InputHistoryService.Count - 1 - offset;
break;
case > 0:
_state.HistoryCursor--;
break;
}
break;
case ImGuiKey.DownArrow:
if (_state.HistoryCursor != -1)
if (++_state.HistoryCursor >= InputHistoryService.Count)
_state.HistoryCursor = -1;
break;
}
if (prev == _state.HistoryCursor)
return 0;
var historyStr = InputHistoryService.GetByCursor(_state.HistoryCursor) ?? string.Empty;
data.DeleteChars(0, data.BufTextLen);
data.InsertChars(0, historyStr);
return 0;
}
private void DrawChannelIconButton(Tab tab)
{
var inputType = tab.CurrentChannel.UseTempChannel
? tab.CurrentChannel.TempChannel.ToChatType()
: tab.CurrentChannel.Channel.ToChatType();
var rgba = Plugin.Config.ChatColours.TryGetValue(inputType, out var c)
? c
: (inputType.DefaultColor() ?? 0xFFFFFFFFu);
var v3 = ColourUtil.RgbaToVector3(rgba);
var bg = new Vector4(v3.X, v3.Y, v3.Z, 1f);
// Compute readable foreground — black on bright, white on dark
var luminance = 0.2126f * v3.X + 0.7152f * v3.Y + 0.0722f * v3.Z;
var fg = luminance > 0.55f ? new Vector4(0f, 0f, 0f, 1f) : new Vector4(1f, 1f, 1f, 1f);
const string popupId = "chat-channel-picker-compact";
const float buttonSize = 22f;
using (ImRaii.PushColor(ImGuiCol.Button, bg))
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, bg))
using (ImRaii.PushColor(ImGuiCol.ButtonActive, bg))
using (ImRaii.PushColor(ImGuiCol.Text, fg))
{
// Single-letter glyph derived from the channel — quick visual cue
// until we have a proper icon font available in the compact bar.
var label = ChannelGlyph(inputType);
if (ImGui.Button($"{label}##chan-compact", new Vector2(buttonSize, buttonSize)) && tab.Channel is null)
ImGui.OpenPopup(popupId);
}
if (tab.Channel is not null && ImGui.IsItemHovered())
{
ImGui.SetTooltip(Resources.Language.ChatLog_SwitcherDisabled);
}
else if (ImGui.IsItemHovered())
{
ImGui.SetTooltip(inputType.Name());
}
using (var popup = ImRaii.Popup(popupId))
{
if (popup)
{
var channels = _host.GetValidChannels();
foreach (var (name, channel) in channels)
if (ImGui.Selectable(name))
_host.SetChannel(channel);
}
}
}
private static string ChannelGlyph(ChatType type) => type switch
{
ChatType.Say => "S",
ChatType.Yell => "Y",
ChatType.Shout => "!",
ChatType.TellIncoming or ChatType.TellOutgoing => "T",
ChatType.Party or ChatType.CrossParty => "P",
ChatType.Alliance => "A",
ChatType.FreeCompany => "F",
ChatType.NoviceNetwork => "N",
ChatType.Linkshell1 => "1",
ChatType.Linkshell2 => "2",
ChatType.Linkshell3 => "3",
ChatType.Linkshell4 => "4",
ChatType.Linkshell5 => "5",
ChatType.Linkshell6 => "6",
ChatType.Linkshell7 => "7",
ChatType.Linkshell8 => "8",
ChatType.CrossLinkshell1 => "①",
ChatType.CrossLinkshell2 => "②",
ChatType.CrossLinkshell3 => "③",
ChatType.CrossLinkshell4 => "④",
ChatType.CrossLinkshell5 => "⑤",
ChatType.CrossLinkshell6 => "⑥",
ChatType.CrossLinkshell7 => "⑦",
ChatType.CrossLinkshell8 => "⑧",
_ => "?",
};
// Forwards a tab-cycle keybind delta to the host so all windows
// navigate the same active-tab pointer (single source of truth).
public void HandleKeybindForward(int delta)
{
_host.ChangeTabDelta(delta);
}
}
// Per-window input state. Each ChatInputBar instance owns one of these
// so pop-outs and the main window keep independent buffers and channels
// (State-Sync-Entscheidung A in the v0.6.0 spec).
public sealed class InputState
{
public string Buffer = string.Empty;
public InputChannel? Channel;
public int HistoryCursor = -1;
}
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
using System.Numerics;
using HellionChat.Util;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Windowing;
using Dalamud.Utility;
using Dalamud.Bindings.ImGui;
using Lumina.Text.ReadOnly;
namespace HellionChat.Ui;
public class CommandHelpWindow : Window {
private ChatLogWindow LogWindow { get; }
private ReadOnlySeString? CommandDescription { get; set; }
internal CommandHelpWindow(ChatLogWindow logWindow) : base("command help##chat2-commandhelp")
{
LogWindow = logWindow;
Flags = ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoMove |
ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.AlwaysAutoResize;
RespectCloseHotkey = false;
DisableWindowSounds = true;
}
// Sets IsOpen to true if it should be drawn
public void UpdateContent(ReadOnlySeString commandDesc)
{
CommandDescription = commandDesc;
var width = 350;
var scaledWidth = width * ImGuiHelpers.GlobalScale;
var pos = LogWindow.LastWindowPos;
switch (Plugin.Config.CommandHelpSide) {
case CommandHelpSide.Right:
pos.X += LogWindow.LastWindowSize.X;
break;
case CommandHelpSide.Left:
pos.X -= scaledWidth;
break;
case CommandHelpSide.None:
default:
IsOpen = false;
return;
}
Position = pos;
SizeConstraints = new WindowSizeConstraints
{
MinimumSize = new Vector2(width, 0),
MaximumSize = LogWindow.LastWindowSize with { X = width }
};
IsOpen = true;
}
public override void Draw()
{
if (CommandDescription == null)
return;
LogWindow.DrawChunks(ChunkUtil.ToChunks(CommandDescription.Value.ToDalamudString(), ChunkSource.None, null).ToList());
}
}
+447
View File
@@ -0,0 +1,447 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Numerics;
using System.Text;
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing;
using Dalamud.Bindings.ImGui;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface.Components;
using Dalamud.Interface.ImGuiNotification;
using Lumina.Data.Files;
using Lumina.Text.ReadOnly;
using MoreLinq;
namespace HellionChat.Ui;
public class DbViewer : Window
{
public const int RowPerPage = 1000;
private readonly Plugin Plugin;
private static readonly DateTime MinimalDate = new(2021, 1, 1);
private DateTime AfterDate;
private DateTime BeforeDate;
private int CurrentPage = 1;
private string SimpleSearchTerm = "";
private bool OnlyCurrentCharacter = true;
private readonly Dictionary<ChatType, (ChatSource, ChatSource)> SelectedChannels;
private bool IsProcessing;
private long ProcessingStart = Environment.TickCount64;
private (DateTime Min, DateTime Max, int Page, bool Local, int ChannelCount) LastProcessed;
private string MinDateString = "";
private string MaxDateString = "";
private readonly string DateFormat;
private readonly string DateTimeFormat;
private long Count;
private Message[] Messages = []; // Messages are only touched while processing is false
private ConcurrentStack<Message> Filtered = []; // Is used every frame, so ConcurrentStack for safety
private bool IsExporting;
private string InputPath = string.Empty;
private IActiveNotification Notification = null!;
private bool NeedsScrollReset;
public DbViewer(Plugin plugin) : base("DBViewer###chat2-dbviewer")
{
Plugin = plugin;
SelectedChannels = TabsUtil.MostlyPlayer;
DateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern;
DateTimeFormat = "ddd, dd MMM yyy HH:mm:ss";
LastProcessed = (AfterDate, BeforeDate, CurrentPage, OnlyCurrentCharacter, SelectedChannels.Count);
DateReset();
SizeConstraints = new WindowSizeConstraints
{
MinimumSize = new Vector2(475, 600),
MaximumSize = new Vector2(float.MaxValue, float.MaxValue)
};
RespectCloseHotkey = false;
DisableWindowSounds = true;
Plugin.Commands.Register("/hellionView", "Get access to your message history, with simple filter options.", true).Execute += Toggle;
}
public void Dispose()
{
Plugin.Commands.Register("/hellionView", "Get access to your message history, with simple filter options.", true).Execute -= Toggle;
}
private void Toggle(string _, string __) => Toggle();
public override void Draw()
{
var totalPages = (int)Math.Ceiling((double)Count / RowPerPage);
if (totalPages < 1)
totalPages = 1;
if (CurrentPage > totalPages)
CurrentPage = 1;
// First row
ImGui.AlignTextToFramePadding();
ImGui.TextColored(ImGuiColors.DalamudViolet, Language.DbViewer_DatePicker_FromTo);
ImGui.SameLine();
var spacing = 3.0f * ImGuiHelpers.GlobalScale;
DateWidget.DatePickerWithInput("##FromDate", 1, ref MinDateString, ref AfterDate, DateFormat);
DateWidget.DatePickerWithInput("##ToDate", 2, ref MaxDateString, ref BeforeDate, DateFormat, true);
ImGui.SameLine(0, spacing);
if (ImGuiUtil.IconButton(FontAwesomeIcon.Recycle))
DateReset();
if (ImGui.IsItemHovered())
ImGui.SetTooltip(Language.DbViewer_Date_Reset_Tooltip);
ImGui.SameLine(0, spacing);
ChannelSelection();
var skipText = Language.DbViewer_CharacterOption;
var textLength = ImGui.GetTextLineHeight() + ImGui.CalcTextSize(skipText).X + ImGui.GetStyle().ItemInnerSpacing.X + ImGui.GetStyle().FramePadding.X * 2;
ImGui.SameLine(ImGui.GetContentRegionMax().X - textLength);
ImGui.Checkbox(skipText, ref OnlyCurrentCharacter);
// Second row
ImGui.AlignTextToFramePadding();
ImGui.TextColored(ImGuiColors.DalamudViolet, "Export:");
ImGui.SameLine(0, spacing);
ImGui.SetNextItemWidth(ImGui.GetWindowWidth() * 0.4f);
ImGui.InputText("##InputPath", ref InputPath, 255);
ImGui.SameLine(0, spacing);
if (ImGuiUtil.IconButton(FontAwesomeIcon.FolderClosed, "##folderPicker"))
ImGui.OpenPopup("InputPathDialog");
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
ImGui.SetTooltip(Language.Folder_Export_Location_Tooltip);
using (var innerPopup = ImRaii.Popup("InputPathDialog"))
{
if (innerPopup.Success)
Plugin.FileDialogManager.OpenFolderDialog(Language.Folder_Selection_Header, (b, s) => { if (b) InputPath = s; }, null, true);
}
ImGui.SameLine(0, spacing);
using (ImRaii.Disabled(InputPath.Length == 0 || IsExporting))
{
if (ImGuiUtil.IconButton(FontAwesomeIcon.Save))
{
Notification = Plugin.Notification.AddNotification(
new Notification
{
Title = "Chat2 Text Export",
Content = Language.ChatExport_Initial,
Type = NotificationType.Info,
Minimized = false,
UserDismissable = false,
InitialDuration = TimeSpan.FromSeconds(10000),
Progress = 0.0f,
});
CreateTxtBackup();
}
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
ImGui.SetTooltip(Language.Export_Txt_Tooltip);
// Hellion Chat: the JSON export button used to dump the database in
// the upstream webinterface's wire format. With the webinterface
// removed there is no consumer for that format any more, so the
// button is dropped. The Privacy tab's MessageExporter covers the
// same ground (Markdown / JSON / CSV) with channel and date filters
// and is the supported way to get history out of the plugin.
var width = 350 * ImGuiHelpers.GlobalScale;
var loadingIndicator = IsProcessing && ProcessingStart < Environment.TickCount64;
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted(string.Format(Language.DbViewer_Page, CurrentPage, totalPages, Count, loadingIndicator ? Language.DbViewer_LoadingIndicator : ""));
ImGuiUtil.DrawArrows(ref CurrentPage, 1, totalPages, spacing, tooltipLeft: Language.Page_ArrowLeft_Tooltip, tooltipRight: Language.Page_ArrowRight_Tooltip);
ImGui.SameLine(ImGui.GetContentRegionMax().X - width);
ImGui.SetNextItemWidth(width);
if (ImGui.InputTextWithHint("##searchbar", Language.DbViewer_SearcHint, ref SimpleSearchTerm, 30))
Filtered = Filter(Messages);
// Third row
if (DateWidget.Validate(MinimalDate, ref AfterDate, ref BeforeDate))
DateRefresh();
if (!IsProcessing && LastProcessed != (AfterDate, BeforeDate, CurrentPage, OnlyCurrentCharacter, SelectedChannels.Count))
{
// Page hasn't changed, so we reset it back to 1
if (LastProcessed.Page == CurrentPage)
CurrentPage = 1;
AdjustDates();
IsProcessing = true;
ProcessingStart = Environment.TickCount64 + 1_000; // + 1 second
LastProcessed = (AfterDate, BeforeDate, CurrentPage, OnlyCurrentCharacter, SelectedChannels.Count);
Task.Run(() =>
{
try
{
ulong? character = OnlyCurrentCharacter ? Plugin.PlayerState.ContentId : null;
var channels = SelectedChannels.Select(pair => (byte) pair.Key).ToArray();
// We only want to fetch count if this is the first page
if (CurrentPage == 1)
Count = Plugin.MessageManager.Store.CountDateRange(AfterDate, BeforeDate, channels, character);
using var rangeMessageEnumerator = Plugin.MessageManager.Store.GetPagedDateRange(AfterDate, BeforeDate, channels, character, CurrentPage - 1);
Messages = rangeMessageEnumerator.ToArray();
Filtered = Filter(Messages);
NeedsScrollReset = true;
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Failed reading messages from database");
}
finally
{
IsProcessing = false;
}
});
}
ImGuiHelpers.ScaledDummy(5.0f);
if (Filtered.IsEmpty)
{
ImGui.TextUnformatted(SimpleSearchTerm == "" ? Language.DbViewer_Status_NothingFound : Language.DbViewer_Status_NoSearchResult);
return;
}
using var child = ImRaii.Child("##tableChild");
if (!child.Success)
return;
if (NeedsScrollReset)
{
NeedsScrollReset = false;
ImGui.SetScrollY(0.0f);
}
using var table = ImRaii.Table("##messageHistory", 4, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.Resizable);
if (!table.Success)
return;
var columnWidth = ImGui.CalcTextSize(Language.DbViewer_TableField_Type);
ImGui.TableSetupColumn(Language.DbViewer_TableField_Date, ImGuiTableColumnFlags.WidthFixed | ImGuiTableColumnFlags.NoResize);
ImGui.TableSetupColumn(Language.DbViewer_TableField_Type, ImGuiTableColumnFlags.WidthFixed | ImGuiTableColumnFlags.NoResize, columnWidth.X);
ImGui.TableSetupColumn(Language.DbViewer_TableField_Sender);
ImGui.TableSetupColumn(Language.DbViewer_TableField_Content);
ImGui.TableHeadersRow();
foreach (var message in Filtered)
{
ImGui.TableNextColumn();
ImGui.TextUnformatted(message.Date.ToLocalTime().ToString(DateTimeFormat));
ImGui.TableNextColumn();
var pos = ImGui.GetCursorPos();
ImGuiUtil.CenterText($"{(byte)message.Code.Type}");
ImGui.SetCursorPos(pos);
ImGui.Dummy(columnWidth);
if (ImGui.IsItemHovered())
ImGuiUtil.Tooltip(message.Code.Type.Name());
ImGui.TableNextColumn();
Plugin.ChatLogWindow.DrawChunks(message.Sender);
ImGui.TableNextColumn();
Plugin.ChatLogWindow.DrawChunks(message.Content);
}
}
private void ChannelSelection()
{
const string addTabPopup = "add-channel-popup";
var spacing = 3.0f * ImGuiHelpers.GlobalScale;
if (ImGui.Button("Channels"))
ImGui.OpenPopup(addTabPopup);
using var popup = ImRaii.Popup(addTabPopup);
if (!popup.Success)
return;
using var channelNode = ImRaii.TreeNode(Language.Options_Tabs_Channels);
if (!channelNode.Success)
return;
foreach (var (header, types) in ChatTypeExt.SortOrder)
{
using var pushedId = ImRaii.PushId(header);
if (ImGuiComponents.IconButton(FontAwesomeIcon.Check))
{
foreach (var type in types)
SelectedChannels.TryAdd(type, (ChatSourceExt.All, ChatSourceExt.All));
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Select all");
ImGui.SameLine(0, spacing);
if (ImGuiComponents.IconButton(FontAwesomeIcon.Times))
{
foreach (var type in types)
SelectedChannels.Remove(type);
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Unselect all");
ImGui.SameLine(0, spacing);
using var headerNode = ImRaii.TreeNode(header);
if (!headerNode.Success)
continue;
foreach (var type in types)
{
if (type.IsGm())
continue;
var enabled = SelectedChannels.ContainsKey(type);
if (ImGui.Checkbox($"##{type.Name()}", ref enabled))
{
if (enabled)
SelectedChannels[type] = (ChatSourceExt.All, ChatSourceExt.All);
else
SelectedChannels.Remove(type);
}
ImGui.SameLine();
ImGui.TextUnformatted(type.Name());
}
}
}
private ConcurrentStack<Message> Filter(Message[] messages)
{
if (SimpleSearchTerm == "")
return new ConcurrentStack<Message>(messages.Reverse().OrderByDescending(m => m.Date));
return new ConcurrentStack<Message>(
messages.Reverse().Where(m =>
ChunkUtil.ToRawString(m.Sender).Contains(SimpleSearchTerm, StringComparison.InvariantCultureIgnoreCase) ||
ChunkUtil.ToRawString(m.Content).Contains(SimpleSearchTerm, StringComparison.InvariantCultureIgnoreCase)
).OrderByDescending(m => m.Date));
}
private void DateRefresh()
{
MinDateString = AfterDate.ToString(DateFormat);
MaxDateString = BeforeDate.ToString(DateFormat);
}
private void AdjustDates()
{
AfterDate = new DateTime(AfterDate.Year, AfterDate.Month, AfterDate.Day, 0, 0, 0);
BeforeDate = new DateTime(BeforeDate.Year, BeforeDate.Month, BeforeDate.Day, 23, 59, 59);
}
private void DateReset()
{
AfterDate = DateTime.Now.AddDays(-5);
BeforeDate = DateTime.Now;
AdjustDates();
DateRefresh();
}
private void CreateTxtBackup()
{
IsExporting = true;
Task.Run(async () =>
{
try
{
ulong? character = OnlyCurrentCharacter ? Plugin.PlayerState.ContentId : null;
var channels = SelectedChannels.Select(pair => (byte)pair.Key).ToArray();
var rangeMessageEnumerator = Plugin.MessageManager.Store.GetDateRange(AfterDate, BeforeDate, channels, character);
var messageHistory = rangeMessageEnumerator.ToArray();
await rangeMessageEnumerator.DisposeAsync();
var filteredHistory = Filter(messageHistory);
var sb = new StringBuilder();
await using var stream = new StreamWriter(Path.Join(InputPath, $"Chat2_{DateTime.Now:yyyy_dd_M__HH_mm_ss}.txt"));
var batch = 0;
foreach (var messages in filteredHistory.Batch(5000))
{
await Plugin.Framework.RunOnTick(() =>
{
foreach (var message in messages)
{
if (!Sheets.LogKindSheet.TryGetRow((uint)message.Code.Type, out var logKind))
logKind = Sheets.LogKindSheet.GetRow(10); // default to say
var rossSender = new ReadOnlySeString(message.SenderSource.Encode());
var rossMessage = new ReadOnlySeString(message.ContentSource.Encode());
var timestamp = message.Date.ToLocalTime().ToString(DateTimeFormat);
var text = Plugin.Evaluator.Evaluate(logKind.Format, [rossSender, rossMessage]).ToString();
sb.AppendLine($"[{timestamp}][{message.Code.Type.Name()}] {text}");
batch++;
}
}, delayTicks: 5);
Notification.Progress = (float)batch / filteredHistory.Count;
Notification.Content = $"Exported {batch} of {filteredHistory.Count} messages";
await stream.WriteAsync(sb.ToString());
sb.Clear();
}
await stream.WriteAsync(sb.ToString());
sb.Clear();
Notification.Progress = 1.0f;
Notification.Content = "Done!!!";
Notification.Type = NotificationType.Success;
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Failed creating txt backup");
Notification.Content = "Error ...";
Notification.Type = NotificationType.Error;
}
finally
{
IsExporting = false;
Notification.UserDismissable = true;
}
});
}
}
+72
View File
@@ -0,0 +1,72 @@
using System.Numerics;
using HellionChat.Code;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Windowing;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using Dalamud.Bindings.ImGui;
using Lumina.Text.ReadOnly;
namespace HellionChat.Ui;
public class DebuggerWindow : Window
{
private readonly Plugin Plugin;
private readonly ChatLogWindow ChatLogWindow;
public DebuggerWindow(Plugin plugin) : base("Debugger###chat2-debugger")
{
Plugin = plugin;
ChatLogWindow = plugin.ChatLogWindow;
SizeConstraints = new WindowSizeConstraints
{
MinimumSize = new Vector2(475, 600),
MaximumSize = new Vector2(float.MaxValue, float.MaxValue)
};
RespectCloseHotkey = false;
DisableWindowSounds = true;
Plugin.Commands.Register("/hellionDebugger", showInHelp: false).Execute += Toggle;
}
public void Dispose()
{
Plugin.Commands.Register("/hellionDebugger", showInHelp: false).Execute -= Toggle;
}
private void Toggle(string _, string __) => Toggle();
public override unsafe void Draw()
{
var agent = (nint) AgentItemDetail.Instance();
ImGui.TextUnformatted($"Current Cursor Pos: {ChatLogWindow.CursorPos}");
if (ImGui.Selectable($"Agent Address: {agent:X}"))
ImGui.SetClipboardText(agent.ToString("X"));
ImGuiHelpers.ScaledDummy(5.0f);
ImGui.TextUnformatted($"Handle Tooltips: {ChatLogWindow.PayloadHandler.HandleTooltips}");
ImGui.TextUnformatted($"Hovered Item: {ChatLogWindow.PayloadHandler.HoveredItem}");
ImGui.TextUnformatted($"Hover Counter: {ChatLogWindow.PayloadHandler.HoverCounter}");
ImGui.TextUnformatted($"Last Hover Counter: {ChatLogWindow.PayloadHandler.LastHoverCounter}");
ImGuiHelpers.ScaledDummy(5.0f);
ImGui.TextColored(ImGuiColors.DalamudOrange, "Current Tab");
ImGui.TextUnformatted($"Name: {Plugin.CurrentTab.Name}");
ImGui.TextUnformatted($"Channel: {Plugin.CurrentTab.CurrentChannel.Channel.ToChatType().Name()}");
ImGui.TextUnformatted($"Tell Target: {Plugin.CurrentTab.CurrentChannel.TellTarget?.ToTargetString() ?? "Null"}");
ImGui.TextUnformatted($"Use Temp? {Plugin.CurrentTab.CurrentChannel.UseTempChannel}");
ImGui.TextUnformatted($"Temp Channel: {Plugin.CurrentTab.CurrentChannel.TempChannel.ToChatType().Name()}");
ImGui.TextUnformatted($"Temp Tell Target: {Plugin.CurrentTab.CurrentChannel.TempTellTarget?.ToTargetString() ?? "Null"}");
ImGui.TextUnformatted($"Name Set? {Plugin.CurrentTab.CurrentChannel.Name.Count > 0}");
ImGui.TextUnformatted($"Name {string.Join(" ", Plugin.CurrentTab.CurrentChannel.Name.Select(c => c.StringValue()))}");
ImGuiHelpers.ScaledDummy(5.0f);
ImGui.TextColored(ImGuiColors.DalamudOrange, "Vanilla Chat");
ImGui.TextUnformatted($"Channel: {new ReadOnlySeString(AgentChatLog.Instance()->ChannelLabel).ExtractText()}");
}
}
+150
View File
@@ -0,0 +1,150 @@
using System.Numerics;
using HellionChat.Code;
using HellionChat.Privacy;
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing;
using Dalamud.Bindings.ImGui;
namespace HellionChat.Ui;
public sealed class FirstRunWizard : Window
{
private readonly Plugin Plugin;
internal FirstRunWizard(Plugin plugin) : base($"{HellionStrings.Wizard_Title}###hellion-firstrun")
{
Plugin = plugin;
Flags = ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoDocking;
SizeCondition = ImGuiCond.Appearing;
Size = new Vector2(900, 560);
SizeConstraints = new WindowSizeConstraints
{
MinimumSize = new Vector2(720, 480),
MaximumSize = new Vector2(float.MaxValue, float.MaxValue),
};
}
public override void OnClose()
{
// Closing the wizard without picking anything = the user accepts
// whatever defaults are already in place. Mark as complete so we
// don't pester them again on the next launch.
if (!Plugin.Config.FirstRunCompleted)
{
Plugin.Config.FirstRunCompleted = true;
Plugin.SaveConfig();
}
}
public override void Draw()
{
ImGui.TextWrapped(HellionStrings.Wizard_Intro);
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
var avail = ImGui.GetContentRegionAvail();
var cardWidth = (avail.X - ImGui.GetStyle().ItemSpacing.X * 2) / 3f;
var cardHeight = avail.Y - ImGui.GetTextLineHeightWithSpacing();
DrawCard("privacy-first", cardWidth, cardHeight,
HellionStrings.Wizard_Profile_PrivacyFirst_Heading,
HellionStrings.Wizard_Profile_PrivacyFirst_Description,
null,
HellionStrings.Wizard_Profile_PrivacyFirst_Apply,
ApplyPrivacyFirst);
ImGui.SameLine();
DrawCard("casual", cardWidth, cardHeight,
HellionStrings.Wizard_Profile_Casual_Heading,
HellionStrings.Wizard_Profile_Casual_Description,
null,
HellionStrings.Wizard_Profile_Casual_Apply,
ApplyCasual);
ImGui.SameLine();
DrawCard("full-history", cardWidth, cardHeight,
HellionStrings.Wizard_Profile_FullHistory_Heading,
HellionStrings.Wizard_Profile_FullHistory_Description,
HellionStrings.Wizard_Profile_FullHistory_GdprWarning,
HellionStrings.Wizard_Profile_FullHistory_Apply,
ApplyFullHistory);
}
private void DrawCard(string id, float width, float height, string heading, string description, string? warning, string buttonLabel, Action onApply)
{
using var child = ImRaii.Child($"##wizard-card-{id}", new Vector2(width, height), true);
if (!child.Success)
return;
ImGui.TextUnformatted(heading);
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
ImGui.TextWrapped(description);
if (warning is not null)
{
ImGui.Spacing();
ImGuiUtil.WarningText(warning);
}
// Push the button to the bottom of the card.
var lineHeight = ImGui.GetFrameHeightWithSpacing();
var remaining = ImGui.GetContentRegionAvail().Y - lineHeight;
if (remaining > 0)
ImGui.Dummy(new Vector2(0, remaining));
if (ImGui.Button($"{buttonLabel}##{id}", new Vector2(-1, 0)))
{
onApply();
Plugin.Config.FirstRunCompleted = true;
Plugin.SaveConfig();
IsOpen = false;
}
}
private void ApplyPrivacyFirst()
{
Plugin.Config.PrivacyFilterEnabled = true;
Plugin.Config.PrivacyPersistChannels = [..PrivacyDefaults.PrivacyFirstWhitelist];
Plugin.Config.PrivacyPersistUnknownChannels = false;
Plugin.Config.RetentionEnabled = true;
Plugin.Config.RetentionDefaultDays = 30;
Plugin.Config.RetentionPerChannelDays =
PrivacyDefaults.DefaultRetentionDays.ToDictionary(p => p.Key, p => p.Value);
}
private void ApplyCasual()
{
Plugin.Config.PrivacyFilterEnabled = true;
Plugin.Config.PrivacyPersistChannels = [..PrivacyDefaults.CasualWhitelist];
Plugin.Config.PrivacyPersistUnknownChannels = false;
Plugin.Config.RetentionEnabled = true;
Plugin.Config.RetentionDefaultDays = 30;
var policy = PrivacyDefaults.DefaultRetentionDays.ToDictionary(p => p.Key, p => p.Value);
foreach (var (type, days) in PrivacyDefaults.CasualRetentionOverrides)
policy[type] = days;
Plugin.Config.RetentionPerChannelDays = policy;
}
private void ApplyFullHistory()
{
// Full history = upstream Chat 2 behavior. Filter off, retention off,
// everything (except battle messages, which Chat 2 itself controls)
// accumulates indefinitely.
Plugin.Config.PrivacyFilterEnabled = false;
Plugin.Config.PrivacyPersistUnknownChannels = true;
Plugin.Config.RetentionEnabled = false;
Plugin.Config.RetentionPerChannelDays.Clear();
}
}
+230
View File
@@ -0,0 +1,230 @@
using HellionChat.Util;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Utility.Raii;
namespace HellionChat.Ui;
/// <summary>
/// ImGui style override for Hellion Chat. Industrial HUD palette with three
/// distinct accents — cyan-teal as the primary action color, industrial
/// amber for active state highlights, slate-violet for title bars and
/// active tabs — on a deep-slate frame background with steel borders.
///
/// Two entry points:
/// Push — local color stack, scoped via using-block. Use inside
/// Hellion-only surfaces (Privacy tab, first-run wizard).
/// PushGlobal — full color + style variable stack. Pushed once per frame
/// in Plugin.Draw so every Hellion-rendered window inherits
/// the look. Cheap to pop because ImGui keeps its own stack.
/// </summary>
internal static class HellionStyle
{
// Encoded as 0xRRGGBBAA, matching ChatTwo convention (see Settings.cs
// Ko-fi buttons). RgbaToAbgr handles the byte swap to the format ImGui
// expects. Hex values are sourced from the Hellion Online Media brand
// guide ("Arctic Cyan + Ember Glow", BRANDING.md in the website repo).
// Primary — Arctic Cyan, used for every interactive control (buttons,
// checks, sliders, separators when hovered). Three brand stages plus a
// hover that lifts to brand-color-light and a press that drops to
// brand-color-dark.
private const uint PrimaryRgba = 0x00BED2FF; // brand-color
private const uint PrimaryHoverRgba = 0x4DD9E8FF; // brand-color-light
private const uint PrimaryActiveRgba = 0x0097A7FF; // brand-color-dark
// Identity — brand-color-dark teal for window title bars and the
// active tab. Sits visibly below the primary cyan on buttons so the
// user sees "where am I" (deep teal) versus "what can I click"
// (brand cyan) without leaving the cyan family.
private const uint IdentityRgba = 0x0097A7FF; // brand-color-dark
private const uint IdentityHoverRgba = 0x4DD9E8FF; // brand-color-light
private const uint IdentityDeepRgba = 0x005670FF; // dimmer teal for unfocused-active tab
// Accent — Ember Orange for warm highlights on grips and scrollbar
// pulls. Replaces the previous industrial amber so the plugin matches
// the website's CTA palette. AccentActive is reserved for any future
// pressed-state on accent surfaces; the current slots only need
// AccentRgba and AccentHoverRgba.
private const uint AccentRgba = 0xF97316FF; // accent-color
private const uint AccentHoverRgba = 0xFB923CFF; // accent-color-light
// Surfaces — Hellion brand background ladder. Window darkest, frame
// hover ladder climbs into surface tones. Matches the website's
// background / background-medium / background-light / surface vars.
private const uint WindowBgRgba = 0x070B12FF; // background
private const uint ChildBgRgba = 0x0C1220FF; // background-medium
private const uint PopupBgRgba = 0x0C1220FF; // background-medium
private const uint FrameBgRgba = 0x141E30FF; // background-light
private const uint FrameBgHoverRgba = 0x1A2538FF; // surface
private const uint FrameBgActiveRgba = 0x22303FFF; // surface-hover
// Cyan-tinted border — matches website --border-brand (cyan @ 40% α).
private const uint BorderRgba = 0x00BED266;
private const uint BorderShadowRgba = 0x00000000;
// Headers / collapsing-headers / tree nodes / selectables — same
// surface ladder as frames so panels feel consistent.
private const uint HeaderRgba = 0x141E30FF;
private const uint HeaderHoverRgba = 0x1A2538FF;
private const uint HeaderActiveRgba = 0x22303FFF;
// Title bars — Identity teal on active so the focused window reads
// as "yours" without using accent or primary slots.
private const uint TitleBgRgba = 0x070B12FF;
private const uint TitleBgActiveRgba = IdentityRgba;
private const uint TitleBgCollapsedRgba = 0x05080EFF;
// Tabs — neutral inactive, Identity-light on hover, Identity teal on
// active. Unfocused-active uses the deeper Identity stage so an
// unfocused window's active tab still reads but does not pull focus.
private const uint TabRgba = 0x141E30FF;
private const uint TabHoveredRgba = IdentityHoverRgba;
private const uint TabActiveRgba = IdentityRgba;
private const uint TabUnfocusedRgba = 0x0C1220FF;
private const uint TabUnfocusedActiveRgba = IdentityDeepRgba;
// Scrollbar — Ember on grab so the pull stands out without competing
// with the cyan action buttons. Idle grab is a subtle surface tone,
// hover/active climb into accent.
private const uint ScrollbarBgRgba = 0x070B12FF;
private const uint ScrollbarGrabRgba = 0x22303FFF; // surface-hover
private const uint ScrollbarGrabHoveredRgba = AccentHoverRgba;
private const uint ScrollbarGrabActiveRgba = AccentRgba;
// Resize grip — same Ember treatment as the scrollbar.
private const uint ResizeGripRgba = 0x141E30FF;
private const uint ResizeGripHoveredRgba = AccentHoverRgba;
private const uint ResizeGripActiveRgba = AccentRgba;
// Separator and check mark / slider follow the primary cyan.
/// <summary>
/// Local color stack for Hellion-only surfaces. Cheap. Use inside a
/// `using var _ = HellionStyle.Push();` block.
/// </summary>
internal static IDisposable Push()
{
var stack = new StackHandle();
stack.PushColor(ImGuiCol.Button, PrimaryRgba);
stack.PushColor(ImGuiCol.ButtonHovered, PrimaryHoverRgba);
stack.PushColor(ImGuiCol.ButtonActive, PrimaryActiveRgba);
stack.PushColor(ImGuiCol.FrameBg, FrameBgRgba);
stack.PushColor(ImGuiCol.FrameBgHovered, FrameBgHoverRgba);
stack.PushColor(ImGuiCol.FrameBgActive, FrameBgActiveRgba);
stack.PushColor(ImGuiCol.Border, BorderRgba);
stack.PushColor(ImGuiCol.Header, HeaderRgba);
stack.PushColor(ImGuiCol.HeaderHovered, HeaderHoverRgba);
stack.PushColor(ImGuiCol.HeaderActive, HeaderActiveRgba);
stack.PushColor(ImGuiCol.CheckMark, PrimaryRgba);
stack.PushColor(ImGuiCol.SliderGrab, PrimaryRgba);
stack.PushColor(ImGuiCol.SliderGrabActive, PrimaryHoverRgba);
return stack;
}
/// <summary>
/// Global color and style-variable stack pushed once per frame in
/// Plugin.Draw. Covers every ImGui surface the plugin renders so the
/// Hellion look is consistent across upstream and Hellion tabs.
/// </summary>
/// <param name="windowOpacity">Window background alpha (0.51.0). Lower
/// values let the game shine through the plugin panes.</param>
internal static IDisposable PushGlobal(float windowOpacity = 1.0f)
{
var stack = new StackHandle();
// Mix the configured opacity into both the outer window and the
// inner content child backgrounds — without ChildBg following the
// slider the chat log stays opaque inside even when the user
// wants to see the game behind it during combat. Form fields and
// popups (FrameBg, PopupBg) still stay opaque so input is readable.
var alphaByte = (uint)Math.Clamp((int)(windowOpacity * 255f), 0x55, 0xFF);
var windowBgWithAlpha = (WindowBgRgba & 0xFFFFFF00u) | alphaByte;
var childBgWithAlpha = (ChildBgRgba & 0xFFFFFF00u) | alphaByte;
// Layout — geometric edges, modest rounding, single-pixel borders.
stack.PushStyleVar(ImGuiStyleVar.WindowRounding, 4f);
stack.PushStyleVar(ImGuiStyleVar.ChildRounding, 3f);
stack.PushStyleVar(ImGuiStyleVar.PopupRounding, 3f);
stack.PushStyleVar(ImGuiStyleVar.FrameRounding, 2f);
stack.PushStyleVar(ImGuiStyleVar.GrabRounding, 2f);
stack.PushStyleVar(ImGuiStyleVar.TabRounding, 2f);
stack.PushStyleVar(ImGuiStyleVar.ScrollbarRounding, 2f);
stack.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 1f);
stack.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f);
// Surfaces.
stack.PushColor(ImGuiCol.WindowBg, windowBgWithAlpha);
stack.PushColor(ImGuiCol.ChildBg, childBgWithAlpha);
stack.PushColor(ImGuiCol.PopupBg, PopupBgRgba);
stack.PushColor(ImGuiCol.Border, BorderRgba);
stack.PushColor(ImGuiCol.BorderShadow, BorderShadowRgba);
// Frames (input fields, combos, sliders).
stack.PushColor(ImGuiCol.FrameBg, FrameBgRgba);
stack.PushColor(ImGuiCol.FrameBgHovered, FrameBgHoverRgba);
stack.PushColor(ImGuiCol.FrameBgActive, FrameBgActiveRgba);
// Title bars — tertiary identity on active.
stack.PushColor(ImGuiCol.TitleBg, TitleBgRgba);
stack.PushColor(ImGuiCol.TitleBgActive, TitleBgActiveRgba);
stack.PushColor(ImGuiCol.TitleBgCollapsed, TitleBgCollapsedRgba);
// Buttons — primary cyan.
stack.PushColor(ImGuiCol.Button, PrimaryRgba);
stack.PushColor(ImGuiCol.ButtonHovered, PrimaryHoverRgba);
stack.PushColor(ImGuiCol.ButtonActive, PrimaryActiveRgba);
// Headers / selectables — slate with subtle steps.
stack.PushColor(ImGuiCol.Header, HeaderRgba);
stack.PushColor(ImGuiCol.HeaderHovered, HeaderHoverRgba);
stack.PushColor(ImGuiCol.HeaderActive, HeaderActiveRgba);
// Tabs — tertiary identity for the active tab.
stack.PushColor(ImGuiCol.Tab, TabRgba);
stack.PushColor(ImGuiCol.TabHovered, TabHoveredRgba);
stack.PushColor(ImGuiCol.TabActive, TabActiveRgba);
stack.PushColor(ImGuiCol.TabUnfocused, TabUnfocusedRgba);
stack.PushColor(ImGuiCol.TabUnfocusedActive, TabUnfocusedActiveRgba);
// Scrollbar.
stack.PushColor(ImGuiCol.ScrollbarBg, ScrollbarBgRgba);
stack.PushColor(ImGuiCol.ScrollbarGrab, ScrollbarGrabRgba);
stack.PushColor(ImGuiCol.ScrollbarGrabHovered, ScrollbarGrabHoveredRgba);
stack.PushColor(ImGuiCol.ScrollbarGrabActive, ScrollbarGrabActiveRgba);
// Resize grip — secondary amber on active.
stack.PushColor(ImGuiCol.ResizeGrip, ResizeGripRgba);
stack.PushColor(ImGuiCol.ResizeGripHovered, ResizeGripHoveredRgba);
stack.PushColor(ImGuiCol.ResizeGripActive, ResizeGripActiveRgba);
// Check mark + slider grab — primary cyan.
stack.PushColor(ImGuiCol.CheckMark, PrimaryRgba);
stack.PushColor(ImGuiCol.SliderGrab, PrimaryRgba);
stack.PushColor(ImGuiCol.SliderGrabActive, PrimaryHoverRgba);
// Separator — primary cyan when hovered/active so the eye
// immediately sees that splitters are interactive.
stack.PushColor(ImGuiCol.Separator, BorderRgba);
stack.PushColor(ImGuiCol.SeparatorHovered, PrimaryHoverRgba);
stack.PushColor(ImGuiCol.SeparatorActive, PrimaryRgba);
return stack;
}
private sealed class StackHandle : IDisposable
{
private readonly List<IDisposable> _items = new(64);
internal void PushColor(ImGuiCol slot, uint rgba)
=> _items.Add(ImRaii.PushColor(slot, ColourUtil.RgbaToAbgr(rgba)));
internal void PushStyleVar(ImGuiStyleVar var, float value)
=> _items.Add(ImRaii.PushStyle(var, value));
public void Dispose()
{
for (var i = _items.Count - 1; i >= 0; i--)
_items[i].Dispose();
_items.Clear();
}
}
}
+261
View File
@@ -0,0 +1,261 @@
using System.Numerics;
using System.Text;
using System.Text.RegularExpressions;
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing;
using Dalamud.Plugin.Services;
using Dalamud.Bindings.ImGui;
namespace HellionChat.Ui;
public partial class InputPreview : Window
{
private ChatLogWindow LogWindow { get; }
private bool Drawing;
private bool HasEvaluation;
internal float PreviewHeight;
private int LastLength;
private Message? PreviewMessage;
private int CursorPosition;
private bool NextChunkIsAutoTranslate;
internal int SelectedCursorPos = -1;
internal InputPreview(ChatLogWindow logWindow) : base("##chat2-inputpreview")
{
LogWindow = logWindow;
Flags = ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoMove |
ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoScrollbar;
RespectCloseHotkey = false;
DisableWindowSounds = true;
IsOpen = true;
Plugin.Framework.Update += UpdateConditionCheck;
}
public void Dispose()
{
Plugin.Framework.Update -= UpdateConditionCheck;
}
private bool ValidDraw => !string.IsNullOrEmpty(LogWindow.Chat) && LogWindow.Chat.Length >= Plugin.Config.PreviewMinimum;
private void UpdateConditionCheck(IFramework framework)
{
Drawing = ValidDraw;
if (!Drawing)
{
LastLength = 0;
PreviewHeight = 0;
PreviewMessage = null;
HasEvaluation = false;
return;
}
if (PreviewMessage == null || LastLength != LogWindow.Chat.Length)
{
LastLength = LogWindow.Chat.Length;
var bytes = Encoding.UTF8.GetBytes(LogWindow.Chat.Trim());
AutoTranslate.ReplaceWithPayload(ref bytes);
var chunks = ChunkUtil.ToChunks(SeString.Parse(bytes), ChunkSource.Content, ChatType.Say).ToList();
PreviewMessage = Message.FakeMessage(chunks, new ChatCode(XivChatType.Say, 0, 0));
PreviewMessage.DecodeTextParam();
}
HasEvaluation = !Plugin.Config.OnlyPreviewIf || PreviewMessage.Content.Count > 1;
}
internal bool IsDrawable => ValidDraw && HasEvaluation;
private static bool IsWindowMode => Plugin.Config.PreviewPosition is PreviewPosition.Top or PreviewPosition.Bottom;
public override bool DrawConditions()
{
return IsWindowMode && IsDrawable;
}
public override void PreDraw()
{
var pos = LogWindow.LastWindowPos;
var size = LogWindow.LastWindowSize;
Size = size with { Y = PreviewHeight };
var y = Plugin.Config.PreviewPosition switch
{
PreviewPosition.Top => pos.Y - PreviewHeight,
PreviewPosition.Bottom => pos.Y + size.Y,
_ => throw new ArgumentOutOfRangeException(nameof(Plugin.Config.PreviewPosition), Plugin.Config.PreviewPosition, null)
};
Position = pos with { Y = y };
PositionCondition = ImGuiCond.Always;
}
public override void Draw()
{
CalculatePreview();
DrawPreview();
}
internal void CalculatePreview()
{
// We Pre-draw this once to get the actual height :HideThePain:
PreviewHeight = 0;
var pos = ImGui.GetCursorPos();
ImGui.SetCursorPos(new Vector2(-500, -500));
var before = ImGui.GetCursorPosY();
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero))
{
ImGui.TextUnformatted(Language.Options_Preview_Header);
DrawChunksPreview(PreviewMessage!.Content);
}
var after = ImGui.GetCursorPosY();
ImGui.SetCursorPos(pos);
PreviewHeight = after - before;
PreviewHeight += IsWindowMode ? ImGui.GetStyle().WindowPadding.Y * 2 : 0;
}
internal void DrawPreview()
{
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero))
{
ImGui.TextUnformatted(Language.Options_Preview_Header);
var handler = LogWindow.HandlerLender.Borrow();
DrawChunksPreview(PreviewMessage!.Content, handler, unique: 10000);
handler.Draw();
}
}
private void DrawChunksPreview(IReadOnlyList<Chunk> chunks, PayloadHandler? handler = null, float lineWidth = 0f, int unique = 0)
{
CursorPosition = 0;
using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero);
for (var i = 0; i < chunks.Count; i++)
{
if (chunks[i] is TextChunk text && string.IsNullOrEmpty(text.Content))
continue;
DrawChunkPreview(chunks[i], handler, lineWidth, unique);
if (i < chunks.Count - 1)
{
ImGui.SameLine();
}
else if (chunks[i].Link is EmotePayload && Plugin.Config.ShowEmotes)
{
// Emote payloads seem to not automatically put newlines, which
// is an issue when modern mode is disabled.
ImGui.SameLine();
// Use default ImGui behavior for newlines.
ImGui.TextUnformatted("");
}
}
}
private void DrawChunkPreview(Chunk chunk, PayloadHandler? handler = null, float lineWidth = 0f, int unique = 0)
{
if (chunk is IconChunk icon)
{
LogWindow.DrawIcon(chunk, icon, handler);
if (icon.Icon != BitmapFontIcon.AutoTranslateBegin)
return;
NextChunkIsAutoTranslate = true;
var payload = (AutoTranslatePayload) chunk.Link!;
CursorPosition += $"<at:{payload.Group},{payload.Key}>".Length;
return;
}
if (chunk is not TextChunk text)
return;
if (chunk.Link is EmotePayload emotePayload && Plugin.Config.ShowEmotes)
{
var emoteSize = ImGui.CalcTextSize("W");
emoteSize = emoteSize with { Y = emoteSize.X } * 1.5f;
// TextWrap doesn't work for emotes, so we have to wrap them manually
if (ImGui.GetContentRegionAvail().X < emoteSize.X)
ImGui.NewLine();
// We only draw a dummy if it is still loading, in case it failed, we draw the actual name
var image = EmoteCache.GetEmote(emotePayload.Code);
if (image is { Failed: false })
{
if (image.IsLoaded)
image.Draw(emoteSize);
else
ImGui.Dummy(emoteSize);
if (ImGui.IsItemHovered())
ImGuiUtil.Tooltip(emotePayload.Code);
CursorPosition += emotePayload.Code.Length;
return;
}
}
if (NextChunkIsAutoTranslate)
{
NextChunkIsAutoTranslate = false;
ImGuiUtil.WrapText(text.Content, chunk, handler, LogWindow.DefaultText, lineWidth);
return;
}
if (text.Link != null)
{
if (text.Link is ItemPayload)
CursorPosition += "<item>".Length;
else if (text.Link is MapLinkPayload)
CursorPosition += "<flag>".Length;
else if (text.Link is EmotePayload emote)
CursorPosition += emote.Code.Length;
else if (text.Link is UriPayload)
CursorPosition += text.Content.Length;
ImGuiUtil.WrapText(text.Content, chunk, handler, LogWindow.DefaultText, lineWidth);
return;
}
foreach (var word in WhitespaceRegex().Split(text.Content).Where(s => s != string.Empty))
{
var wordSize = ImGui.CalcTextSize(word);
if (ImGui.GetContentRegionAvail().X < wordSize.X)
ImGui.NewLine();
foreach (var letter in word)
{
var letterSize = ImGui.CalcTextSize(letter.ToString());
CursorPosition++;
if (ImGui.Selectable($"{letter}##{CursorPosition + unique}", false, ImGuiSelectableFlags.None, letterSize))
{
SelectedCursorPos = CursorPosition;
LogWindow.FocusedPreview = true;
}
ImGui.SameLine();
}
}
ImGui.NewLine();
}
[GeneratedRegex(@"(\s)")]
private static partial Regex WhitespaceRegex();
}
+255
View File
@@ -0,0 +1,255 @@
using System.Numerics;
using Dalamud.Interface.Style;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing;
using Dalamud.Bindings.ImGui;
namespace HellionChat.Ui;
internal class Popout : Window
{
private readonly ChatLogWindow ChatLogWindow;
private readonly Tab Tab;
private readonly int Idx;
private long FrameTime; // set every frame
private long LastActivityTime = Environment.TickCount64;
// v0.6.0 — optional input bar inside the pop-out window. Lazy-allocated
// when the user enables Tab.PopOutInputEnabled and torn down when the
// toggle is turned off (independent text buffer is intentionally
// discarded — see v0.6.0 spec edge-case P1).
public ChatInputBar? InputBar { get; private set; }
public bool HasFocusedInputBar => InputBar?.IsFocused ?? false;
// Hellion Chat — v0.6.1 expose just the tab identifier (not the whole Tab
// reference) so AutoTellTabsService.DropOldestTempTab can locate the
// matching pop-out window when an LRU temp tab gets evicted.
internal Guid TabIdentifier => Tab.Identifier;
public Popout(ChatLogWindow chatLogWindow, Tab tab, int idx) : base($"{tab.Name}##popout")
{
ChatLogWindow = chatLogWindow;
Tab = tab;
Idx = idx;
Size = new Vector2(350, 350);
SizeCondition = ImGuiCond.FirstUseEver;
IsOpen = true;
RespectCloseHotkey = false;
DisableWindowSounds = true;
}
public override void PreOpenCheck()
{
if (!Tab.PopOut)
IsOpen = false;
}
public override bool DrawConditions()
{
FrameTime = Environment.TickCount64;
if (Tab.IndependentHide ? HideStateCheck() : ChatLogWindow.IsHidden)
return false;
if (!Plugin.Config.HideWhenInactive || (!Plugin.Config.InactivityHideActiveDuringBattle && Plugin.InBattle) || !Tab.UnhideOnActivity)
{
LastActivityTime = FrameTime;
return true;
}
// Activity in the tab, this popout window, or the main chat log window.
var lastActivityTime = Math.Max(Tab.LastActivity, LastActivityTime);
lastActivityTime = Math.Max(lastActivityTime, ChatLogWindow.LastActivityTime);
return FrameTime - lastActivityTime <= 1000 * Plugin.Config.InactivityHideTimeout;
}
public override void PreDraw()
{
if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null })
StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Push();
Flags = ImGuiWindowFlags.None;
if (!Plugin.Config.ShowPopOutTitleBar)
Flags |= ImGuiWindowFlags.NoTitleBar;
if (!Tab.CanMove)
Flags |= ImGuiWindowFlags.NoMove;
if (!Tab.CanResize)
Flags |= ImGuiWindowFlags.NoResize;
if (!ChatLogWindow.PopOutDocked[Idx])
{
if (Tab.IndependentOpacity)
{
BgAlpha = Tab.Opacity / 100f;
}
else
{
BgAlpha = Plugin.Config.HellionThemeEnabled
? Plugin.Config.HellionThemeWindowOpacity
: Plugin.Config.WindowAlpha / 100f;
}
}
}
public override void Draw()
{
using var id = ImRaii.PushId($"popout-{Tab.Identifier}");
if (!Plugin.Config.ShowPopOutTitleBar)
{
ImGui.TextUnformatted(Tab.Name);
ImGui.Separator();
}
// v0.6.0 — one-time hint banner explaining the new pop-out input
// feature. Shown once per user; "Got it" or "Open settings"
// dismisses it and persists the flag.
var hintBannerHeight = DrawHintBannerIfNeeded();
// v0.6.0 — pop-out optional input bar. Reserve height first so the
// message log draws into the right region; only shown when the
// global master switch is on. Toggle-OFF resets InputBar so the
// next toggle-ON gives a fresh buffer (no stale text persists).
var inputEnabled = Plugin.Config.PopOutInputEnabled;
if (!inputEnabled && InputBar != null)
{
InputBar = null;
}
if (inputEnabled)
{
InputBar ??= new ChatInputBar(ChatLogWindow.Plugin, ChatLogWindow, () => Tab);
}
var inputBarHeight = inputEnabled
? ImGui.GetFrameHeightWithSpacing() + ImGui.GetStyle().ItemSpacing.Y
: 0f;
var handler = ChatLogWindow.HandlerLender.Borrow();
var logHeight = ImGui.GetContentRegionAvail().Y - inputBarHeight - hintBannerHeight;
ChatLogWindow.DrawMessageLog(Tab, handler, logHeight, false);
if (inputEnabled && InputBar != null)
{
ImGui.Separator();
InputBar.RenderCompact();
}
if (ImGui.IsWindowHovered(ImGuiHoveredFlags.ChildWindows))
LastActivityTime = FrameTime;
}
// Returns the vertical space the banner consumed (0 when not shown)
// so the message log can shrink accordingly.
private float DrawHintBannerIfNeeded()
{
if (Plugin.Config.SeenPopOutInputHint)
return 0f;
var hintText = Resources.HellionStrings.Popout_v060_HintText;
var ackLabel = Resources.HellionStrings.Popout_v060_HintAck;
var openLabel = Resources.HellionStrings.Popout_v060_HintOpenSettings;
var startY = ImGui.GetCursorPosY();
var bg = new System.Numerics.Vector4(0.16f, 0.20f, 0.28f, 1f);
ImGui.PushStyleColor(ImGuiCol.ChildBg, bg);
ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f);
var dismiss = false;
var openSettings = false;
using (var child = ImRaii.Child("##v060-pop-out-hint", new System.Numerics.Vector2(0f, 64f), true))
{
if (child)
{
ImGui.TextWrapped(hintText);
if (ImGui.Button(ackLabel))
dismiss = true;
ImGui.SameLine();
if (ImGui.Button(openLabel))
{
dismiss = true;
openSettings = true;
}
}
}
ImGui.PopStyleVar();
ImGui.PopStyleColor();
ImGui.Spacing();
if (dismiss)
{
Plugin.Config.SeenPopOutInputHint = true;
ChatLogWindow.Plugin.SaveConfig();
Plugin.Log.Debug("Pop-Out input hint dismissed");
if (openSettings)
ChatLogWindow.Plugin.SettingsWindow.Toggle();
}
return ImGui.GetCursorPosY() - startY;
}
public override void PostDraw()
{
ChatLogWindow.PopOutDocked[Idx] = ImGui.IsWindowDocked();
if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null })
StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Pop();
}
public override void OnClose()
{
ChatLogWindow.PopOutWindows.Remove(Tab.Identifier);
ChatLogWindow.Plugin.WindowSystem.RemoveWindow(this);
Tab.PopOut = false;
ChatLogWindow.Plugin.SaveConfig();
}
private enum HideState
{
None,
Cutscene,
CutsceneOverride,
User,
Battle
}
private HideState CurrentHideState = HideState.None;
private bool HideStateCheck()
{
// if the chat has no hide state set, and the player has entered battle, we hide chat if they have configured it
if (Tab.HideInBattle && CurrentHideState == HideState.None && Plugin.InBattle)
CurrentHideState = HideState.Battle;
// If the chat is hidden because of battle, we reset it here
if (CurrentHideState is HideState.Battle && !Plugin.InBattle)
CurrentHideState = HideState.None;
// if the chat has no hide state and in a cutscene, set the hide state to cutscene
if (Tab.HideDuringCutscenes && CurrentHideState == HideState.None && (Plugin.CutsceneActive || Plugin.GposeActive))
{
if (ChatLogWindow.Plugin.Functions.Chat.CheckHideFlags())
CurrentHideState = HideState.Cutscene;
}
// if the chat is hidden because of a cutscene and no longer in a cutscene, set the hide state to none
if (CurrentHideState is HideState.Cutscene or HideState.CutsceneOverride && !Plugin.CutsceneActive && !Plugin.GposeActive)
CurrentHideState = HideState.None;
// if the chat is hidden because of a cutscene and the chat has been activated, show chat
if (CurrentHideState == HideState.Cutscene && ChatLogWindow.Activate)
CurrentHideState = HideState.CutsceneOverride;
// if the user hid the chat and is now activating chat, reset the hide state
if (CurrentHideState == HideState.User && ChatLogWindow.Activate)
CurrentHideState = HideState.None;
return CurrentHideState is HideState.Cutscene or HideState.User or HideState.Battle || (Tab.HideWhenNotLoggedIn && !Plugin.ClientState.IsLoggedIn);
}
}
+326
View File
@@ -0,0 +1,326 @@
using System.Globalization;
using System.Numerics;
using System.Text;
using HellionChat.Util;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing;
using Dalamud.Bindings.ImGui;
using Dalamud.Utility;
using DalamudPartyFinderPayload = Dalamud.Game.Text.SeStringHandling.Payloads.PartyFinderPayload;
namespace HellionChat.Ui;
public class SeStringDebugger : Window
{
private readonly Plugin Plugin;
public SeStringDebugger(Plugin plugin) : base("SeString Debugger###chat2-sestringdebugger")
{
Plugin = plugin;
SizeConstraints = new WindowSizeConstraints
{
MinimumSize = new Vector2(475, 600),
MaximumSize = new Vector2(float.MaxValue, float.MaxValue)
};
RespectCloseHotkey = false;
DisableWindowSounds = true;
#if DEBUG
Plugin.Commands.Register("/hellionSeString", showInHelp: false).Execute += Toggle;
#endif
}
public void Dispose()
{
#if DEBUG
Plugin.Commands.Register("/hellionSeString", showInHelp: false).Execute -= Toggle;
#endif
}
private void Toggle(string _, string __) => Toggle();
public override void Draw()
{
if (Plugin.MessageManager.LastMessage.Sender == null)
{
ImGui.TextUnformatted("Nothing to show");
return;
}
// TODO: Make SeString freely selectable through chat
ImGui.TextUnformatted("Sender Content");
ImGui.Spacing();
if (Plugin.MessageManager.LastMessage.Sender != null)
ProcessPayloads(Plugin.MessageManager.LastMessage.Sender.Payloads);
else
ImGui.TextUnformatted("Nothing to show");
ImGui.TextUnformatted("Message Content");
ImGui.Spacing();
if (Plugin.MessageManager.LastMessage.Message != null)
ProcessPayloads(Plugin.MessageManager.LastMessage.Message.Payloads);
else
ImGui.TextUnformatted("Nothing to show");
}
private void ProcessPayloads(List<Payload> payloads)
{
foreach (var payload in payloads)
{
switch (payload)
{
case UIForegroundPayload color:
{
RenderMetadataDictionary("Link ForegroundColor", new Dictionary<string, string?>
{
{ "Enabled?", color.IsEnabled.ToString() },
{ "ColorKey", color.IsEnabled ? color.ColorKey.ToString() : "Color Ended" },
});
break;
}
case MapLinkPayload map:
{
RenderMetadataDictionary("Link MapLinkPayload", new Dictionary<string, string?>
{
{ "Map.RowId", map.Map.RowId.ToString() },
{ "Map.PlaceName", map.Map.Value.PlaceName.Value.Name.ToString() },
{ "Map.PlaceNameRegion", map.Map.Value.PlaceNameRegion.Value.Name.ToString() },
{ "Map.PlaceNameSub", map.Map.Value.PlaceNameSub.Value.Name.ToString() },
{ "TerritoryType.RowId", map.TerritoryType.RowId.ToString() },
{ "RawX", map.RawX.ToString() },
{ "RawY", map.RawY.ToString() },
{ "XCoord", map.XCoord.ToString(CultureInfo.InvariantCulture) },
{ "YCoord", map.YCoord.ToString(CultureInfo.InvariantCulture) },
{ "CoordinateString", map.CoordinateString },
{ "DataString", map.DataString },
});
break;
}
case QuestPayload quest:
{
RenderMetadataDictionary("Link QuestPayload", new Dictionary<string, string?>
{
{ "Quest.RowId", quest.Quest.RowId.ToString() },
{ "Quest.Name", quest.Quest.Value.Name.ToString() },
});
break;
}
case DalamudLinkPayload link:
{
RenderMetadataDictionary("Link DalamudLinkPayload", new Dictionary<string, string?>
{
{ "CommandId", link.CommandId.ToString() },
{ "Plugin", link.Plugin },
});
break;
}
case DalamudPartyFinderPayload pf:
{
RenderMetadataDictionary("Link PartyFinderPayload", new Dictionary<string, string?>
{
{ "ListingId", pf.ListingId.ToString() },
{ "LinkType", EnumName(pf.LinkType) },
});
break;
}
case PlayerPayload player:
{
RenderMetadataDictionary("Link PlayerPayload", new Dictionary<string, string?>
{
{ "Displayed", player.DisplayedName },
{ "Player Name", player.PlayerName },
{ "World Name", player.World.Value.Name.ExtractText() },
{ "Data", string.Join(" ", player.Encode().Select(b => b.ToString("X2"))) },
});
break;
}
case ItemPayload item:
{
RenderMetadataDictionary("Link ItemPayload", new Dictionary<string, string?>
{
{ "ItemId", item.ItemId.ToString() },
{ "RawItemId", item.RawItemId.ToString() },
{ "Kind", EnumName(item.Kind) },
{ "IsHQ", item.IsHQ.ToString() },
{ "Item.Name", item.Kind == ItemKind.EventItem ? Sheets.EventItemSheet.GetRow(item.ItemId).Name.ExtractText() : Sheets.ItemSheet.GetRow(item.ItemId).Name.ExtractText() },
});
break;
}
case AutoTranslatePayload at:
{
RenderMetadataDictionary("Link AutoTranslatePayload", new Dictionary<string, string?>
{
{ "Text", at.Text },
{ "Key/Group", $"{at.Key}/{at.Group}" },
{ "Data", string.Join(" ", at.Encode().Select(b => b.ToString("X2"))) },
});
break;
}
case IconPayload icon:
{
var found = IconUtil.GfdFileView.TryGetEntry((uint) icon.Icon, out _);
RenderMetadataDictionary("Link IconPayload", new Dictionary<string, string?>
{
{ "Found", found.ToString() },
{ "Icon ID", ((uint) icon.Icon).ToString() },
});
break;
}
case RawPayload raw:
{
var colorPayload = ColorPayload.From(raw.Data);
if (colorPayload != null)
{
RenderMetadataDictionary("Link ColorPayload", new Dictionary<string, string?>
{
{ "Unshifted", colorPayload.UnshiftedColor.ToString("X8") },
{ "Color", colorPayload.Color.ToString("X8") },
{ "Enabled?", colorPayload.Enabled.ToString() },
});
}
else
{
RenderMetadataDictionary("Link RawPayload", new Dictionary<string, string?>
{
{ "Data", string.Join(" ", raw.Data.Select(b => b.ToString("X2"))) },
{ "Type", EnumName(raw.Type) },
});
}
break;
}
case StatusPayload status:
{
RenderMetadataDictionary("Link StatusPayload", new Dictionary<string, string?>
{
{ "Status.RowId", status.Status.RowId.ToString() },
{ "Status.Name", status.Status.Value.Name.ExtractText() },
{ "Status.Icon", status.Status.Value.Icon.ToString() }
});
break;
}
case Util.PartyFinderPayload pf:
{
RenderMetadataDictionary("Link PartyFinderPayload", new Dictionary<string, string?>
{
{ "Id", pf.Id.ToString() }
});
break;
}
case AchievementPayload achievement:
{
RenderMetadataDictionary("Link AchievementPayload", new Dictionary<string, string?>
{
{ "Id", achievement.Id.ToString() }
});
break;
}
default:
var payloadData = payload.Encode();
var initialByte = payloadData.First();
if (initialByte != 0x02)
{
RenderMetadataDictionary("Text Payload", new Dictionary<string, string?>
{
{ "Content", Encoding.UTF8.GetString(payloadData) },
});
}
else
{
var unknown = new RawPayload(payloadData);
RenderMetadataDictionary("Link Unknown", new Dictionary<string, string?>
{
{ "Unknown", string.Join(" ", unknown.Data.Select(b => b.ToString("X2"))) },
});
}
break;
}
}
}
private static string? EnumName<T>(T? value) where T : Enum
{
if (value == null)
{
return null;
}
var rawValue = Convert.ChangeType(value, value.GetTypeCode());
return (Enum.GetName(value.GetType(), value) ?? "Unknown") + $" ({rawValue})";
}
private static void RenderMetadataDictionary(string name, Dictionary<string, string?> metadata)
{
var style = ImGui.GetStyle();
ImGui.Text($"{name}:");
using var indent = ImRaii.PushIndent(style.IndentSpacing);
using (var table = ImRaii.Table($"##chat2-{name}", 2))
{
if (!table.Success)
return;
ImGui.TableSetupColumn($"##chat2-{name}-key", ImGuiTableColumnFlags.WidthStretch, 0.4f);
ImGui.TableSetupColumn($"##chat2-{name}-value");
for (var i = 0; i < metadata.Count; i++)
{
using var id = ImRaii.PushId(i);
var (key, value) = metadata.ElementAt(i);
ImGui.TableNextColumn();
ImGui.Text(key);
ImGui.TableNextColumn();
ImGuiTextVisibleWhitespace(value);
}
}
ImGui.NewLine();
}
// ImGuiTextVisibleWhitespace replaces leading and trailing whitespace with
// visible characters. The extra characters are rendered with a muted font.
private static void ImGuiTextVisibleWhitespace(string? original)
{
if (string.IsNullOrEmpty(original))
{
using var pushedColor = ImRaii.PushColor(ImGuiCol.Text, new Vector4(1, 1, 1, 0.5f));
ImGui.TextUnformatted(original == null ? "(null)" : "(empty)");
return;
}
var text = original;
var start = 0;
var end = text.Length;
using var pushedStyle = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(0, 0));
while (start < end && char.IsWhiteSpace(text[start]))
start++;
if (start > 0)
{
using var pushedColor = ImRaii.PushColor(ImGuiCol.Text, new Vector4(1, 1, 1, 0.5f));
ImGui.TextWrapped(new string('_', start));
ImGui.SameLine();
}
while (end > start && char.IsWhiteSpace(text[end - 1]))
end--;
ImGui.TextWrapped(text[start..end]);
if (end < text.Length)
{
ImGui.SameLine();
using var pushedColor = ImRaii.PushColor(ImGuiCol.Text, new Vector4(1, 1, 1, 0.5f));
ImGui.TextWrapped(new string('_', text.Length - end));
}
}
}
+184
View File
@@ -0,0 +1,184 @@
using System.Numerics;
using HellionChat.Resources;
using HellionChat.Ui.SettingsTabs;
using HellionChat.Util;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing;
using Dalamud.Utility;
using Dalamud.Bindings.ImGui;
namespace HellionChat.Ui;
public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window
{
private readonly Plugin Plugin;
private Configuration Mutable { get; }
private List<ISettingsTab> Tabs { get; }
private int CurrentTab;
internal SettingsWindow(Plugin plugin) : base($"{Language.Settings_Title.Format(Plugin.PluginName)}###chat2-settings")
{
Flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse;
SizeCondition = ImGuiCond.FirstUseEver;
SizeConstraints = new WindowSizeConstraints
{
MinimumSize = new Vector2(475, 600),
MaximumSize = new Vector2(float.MaxValue, float.MaxValue)
};
Plugin = plugin;
Mutable = new Configuration();
Tabs =
[
new General(Plugin, Mutable),
new Appearance(Plugin, Mutable),
new SettingsTabs.Window(Plugin, Mutable),
new Chat(Plugin, Mutable),
new SettingsTabs.Tabs(Plugin, Mutable),
new SettingsTabs.Privacy(Plugin, Mutable),
new Database(Plugin, Mutable),
new Information(Mutable),
];
RespectCloseHotkey = false;
DisableWindowSounds = true;
Initialise();
Plugin.Commands.Register("/hellion", "Perform various actions with Hellion Chat.").Execute += Command;
Plugin.Interface.UiBuilder.OpenConfigUi += Toggle;
}
public void Dispose()
{
Plugin.Interface.UiBuilder.OpenConfigUi -= Toggle;
Plugin.Commands.Register("/hellion").Execute -= Command;
}
private void Command(string command, string args)
{
if (string.IsNullOrWhiteSpace(args))
Toggle();
}
private void Initialise()
{
Mutable.UpdateFrom(Plugin.Config, false);
}
public override void Draw()
{
if (ImGui.IsWindowAppearing())
Initialise();
using (var table = ImRaii.Table("##chat2-settings-table", 2))
{
if (table.Success)
{
ImGui.TableSetupColumn("tab", ImGuiTableColumnFlags.WidthFixed);
ImGui.TableSetupColumn("settings", ImGuiTableColumnFlags.WidthStretch);
ImGui.TableNextColumn();
var changed = false;
for (var i = 0; i < Tabs.Count; i++)
{
if (!ImGui.Selectable($"{Tabs[i].Name}###tab-{i}", CurrentTab == i))
continue;
CurrentTab = i;
changed = true;
}
ImGui.TableNextColumn();
var style = ImGui.GetStyle();
var height = ImGui.GetContentRegionAvail().Y - style.FramePadding.Y * 2 - style.ItemSpacing.Y - style.ItemInnerSpacing.Y * 2 - ImGui.CalcTextSize("A").Y;
using var child = ImRaii.Child("##chat2-settings", new Vector2(-1, height));
if (child.Success)
Tabs[CurrentTab].Draw(changed);
}
}
ImGui.Separator();
var save = ImGui.Button(Language.Settings_Save);
ImGui.SameLine();
if (ImGui.Button(Language.Settings_SaveAndClose))
{
save = true;
IsOpen = false;
}
ImGui.SameLine();
if (ImGui.Button(Language.Settings_Discard))
{
IsOpen = false;
}
const string buttonLabel = "Anna's Ko-fi";
const string buttonLabel2 = "Infi's Ko-fi";
using (ImRaii.PushColor(ImGuiCol.Button, ColourUtil.RgbaToAbgr(0xFF5E5BFF)))
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, ColourUtil.RgbaToAbgr(0xFF7775FF)))
using (ImRaii.PushColor(ImGuiCol.ButtonActive, ColourUtil.RgbaToAbgr(0xFF4542FF)))
using (ImRaii.PushColor(ImGuiCol.Text, 0xFFFFFFFF))
{
var buttonWidth = ImGui.CalcTextSize(buttonLabel).X + ImGui.GetStyle().FramePadding.X * 2;
var buttonWidth2 = ImGui.CalcTextSize(buttonLabel2).X + ImGui.GetStyle().FramePadding.X * 2;
ImGui.SameLine(ImGui.GetContentRegionAvail().X - buttonWidth - buttonWidth2 - ImGui.GetStyle().ItemSpacing.X);
if (ImGui.Button(buttonLabel2))
Dalamud.Utility.Util.OpenLink("https://ko-fi.com/infiii");
ImGui.SameLine();
if (ImGui.Button(buttonLabel))
Dalamud.Utility.Util.OpenLink("https://ko-fi.com/lojewalo");
}
if (!save)
return;
// calculate all conditions before updating config
var hideChanged = !Mutable.HideChat && Mutable.HideChat != Plugin.Config.HideChat;
var languageChanged = Mutable.LanguageOverride != Plugin.Config.LanguageOverride;
var fontChanged = Mutable.GlobalFontV2 != Plugin.Config.GlobalFontV2
|| Mutable.JapaneseFontV2 != Plugin.Config.JapaneseFontV2
|| Mutable.ItalicFontV2 != Plugin.Config.ItalicFontV2
|| Mutable.ExtraGlyphRanges != Plugin.Config.ExtraGlyphRanges
|| Mutable.UseHellionFont != Plugin.Config.UseHellionFont;
var fontSizeChanged = Math.Abs(Mutable.SymbolsFontSizeV2 - Plugin.Config.SymbolsFontSizeV2) > 0.001
|| Math.Abs(Mutable.FontSizeV2 - Plugin.Config.FontSizeV2) > 0.001;
var italicStateChanged = Mutable.ItalicEnabled != Plugin.Config.ItalicEnabled;
Plugin.Config.UpdateFrom(Mutable, true);
// save after 60 frames have passed, which should hopefully not
// commit any changes that cause a crash
Plugin.DeferredSaveFrames = 60;
Plugin.MessageManager.ClearAllTabs();
Plugin.MessageManager.FilterAllTabsAsync();
if (fontChanged || fontSizeChanged || italicStateChanged)
Plugin.FontManager.BuildFonts();
if (languageChanged)
Plugin.LanguageChanged(Plugin.Interface.UiLanguage);
if (hideChanged)
GameFunctions.GameFunctions.SetChatInteractable(true);
if (Plugin.Config.ShowEmotes)
_ = EmoteCache.LoadData(); // Fire-and-forget intentional, exceptions are caught inside
Initialise();
}
}
+355
View File
@@ -0,0 +1,355 @@
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud;
using Dalamud.Interface;
using Dalamud.Interface.FontIdentifier;
using Dalamud.Interface.Style;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Bindings.ImGui;
namespace HellionChat.Ui.SettingsTabs;
internal sealed class Appearance : ISettingsTab
{
private Plugin Plugin { get; }
private Configuration Mutable { get; }
public string Name => HellionStrings.Settings_Tab_Appearance + "###tabs-appearance";
internal Appearance(Plugin plugin, Configuration mutable)
{
Plugin = plugin;
Mutable = mutable;
}
public void Draw(bool changed)
{
DrawThemeSection();
ImGui.Spacing();
DrawFontsSection();
ImGui.Spacing();
DrawColoursSection();
ImGui.Spacing();
DrawTimestampsSection();
}
private void DrawThemeSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Appearance_Theme_Heading);
if (!tree.Success)
{
return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGui.Checkbox(HellionStrings.Theme_Enabled_Name, ref Mutable.HellionThemeEnabled);
ImGuiUtil.HelpMarker(HellionStrings.Theme_Enabled_Description);
// Clamp 0.51.0 stays consistent with Privacy.cs which already
// shipped this slider; lower values would let chat windows
// disappear behind game UI.
using (ImRaii.Disabled(!Mutable.HellionThemeEnabled))
{
ImGui.SetNextItemWidth(200f * ImGuiHelpers.GlobalScale);
var opacity = Mutable.HellionThemeWindowOpacity;
if (ImGui.SliderFloat($"{HellionStrings.Theme_WindowOpacity_Label}##theme-opacity", ref opacity, 0.5f, 1.0f, "%.2f"))
{
Mutable.HellionThemeWindowOpacity = Math.Clamp(opacity, 0.5f, 1.0f);
}
ImGuiUtil.HelpMarker(HellionStrings.Theme_WindowOpacity_Help);
}
ImGui.Spacing();
ImGui.Checkbox(Language.Options_OverrideStyle_Name, ref Mutable.OverrideStyle);
ImGuiUtil.HelpMarker(Language.Options_OverrideStyle_Name_Desc);
if (Mutable.OverrideStyle)
{
DrawStyleCombo();
}
// The Bestand-Slider WindowAlpha targets the chat log window's
// background only. The Hellion theme opacity above already covers
// every plugin window globally, so the two sliders fight each
// other when the theme is active. Disable the legacy slider in
// that case to make Hellion theme the single source of truth.
using (ImRaii.Disabled(Mutable.HellionThemeEnabled))
{
ImGuiUtil.DragFloatVertical(Language.Options_WindowOpacity_Name, ref Mutable.WindowAlpha, .25f, 0f, 100f, $"{Mutable.WindowAlpha:N2}%%", ImGuiSliderFlags.AlwaysClamp);
}
}
}
private void DrawStyleCombo()
{
var styles = StyleModel.GetConfiguredStyles();
if (styles == null)
{
ImGui.TextUnformatted(Language.Options_OverrideStyle_NotAvailable);
return;
}
var currentStyle = Mutable.ChosenStyle ?? Language.Options_OverrideStyle_NotSelected;
using var combo = ImRaii.Combo(Language.Options_OverrideStyleDropdown_Name, currentStyle);
if (!combo)
{
return;
}
foreach (var style in styles)
{
if (ImGui.Selectable(style.Name, Mutable.ChosenStyle == style.Name))
{
Mutable.ChosenStyle = style.Name;
}
}
}
private void DrawFontsSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Appearance_Fonts_Heading);
if (!tree.Success)
{
return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
if (ImGui.Checkbox(HellionStrings.Theme_UseHellionFont_Name, ref Mutable.UseHellionFont))
{
// Mutex with the Bestand custom-font stack. Leaving FontsEnabled
// checked alongside UseHellionFont made both checkboxes look
// active even though the lower stack was greyed out, which
// confused the user during the v0.5.0 walkthrough.
if (Mutable.UseHellionFont)
Mutable.FontsEnabled = false;
}
ImGuiUtil.HelpMarker(HellionStrings.Theme_UseHellionFont_Description);
ImGui.Spacing();
using var fontDisabled = ImRaii.Disabled(Mutable.UseHellionFont);
ImGui.Checkbox(Language.Options_FontsEnabled, ref Mutable.FontsEnabled);
ImGui.Spacing();
var unused = false;
if (!Mutable.FontsEnabled)
{
ImGuiUtil.FontSizeCombo(Language.Options_FontSize_Name, ref Mutable.FontSizeV2);
}
else
{
var globalChooser = ImGuiUtil.FontChooser(Language.Options_Font_Name, Mutable.GlobalFontV2, false, ref unused);
globalChooser?.ResultTask.ContinueWith(r =>
{
if (r.IsCompletedSuccessfully)
{
Plugin.Framework.Run(() => Mutable.GlobalFontV2 = r.Result);
}
});
ImGui.SameLine();
if (ImGui.Button("Reset##global"))
{
Mutable.GlobalFontV2 = new SingleFontSpec { FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular), SizePt = 12.75f };
}
ImGuiUtil.HelpMarker(string.Format(Language.Options_Font_Description, Plugin.PluginName));
ImGuiUtil.WarningText(Language.Options_Font_Warning);
ImGui.Spacing();
// LocaleNames being null means it is likely a game font which all support JP symbols.
var japaneseChooser = ImGuiUtil.FontChooser(Language.Options_JapaneseFont_Name, Mutable.JapaneseFontV2, false, ref unused, id => !id.LocaleNames?.ContainsKey("ja-jp") ?? false, "いろはにほへと ちりぬるを");
japaneseChooser?.ResultTask.ContinueWith(r =>
{
if (r.IsCompletedSuccessfully)
{
Plugin.Framework.Run(() => Mutable.JapaneseFontV2 = r.Result);
}
});
ImGui.SameLine();
if (ImGui.Button("Reset##japanese"))
{
Mutable.JapaneseFontV2 = new SingleFontSpec { FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkMedium), SizePt = 12.75f };
}
ImGuiUtil.HelpMarker(string.Format(Language.Options_JapaneseFont_Description, Plugin.PluginName));
ImGui.Spacing();
var italicChooser = ImGuiUtil.FontChooser(Language.Options_ItalicFont_Name, Mutable.ItalicFontV2, true, ref Mutable.ItalicEnabled);
italicChooser?.ResultTask.ContinueWith(r =>
{
if (r.IsCompletedSuccessfully)
{
Plugin.Framework.Run(() => Mutable.ItalicFontV2 = r.Result);
}
});
ImGui.SameLine();
if (ImGui.Button("Reset##italic"))
{
Mutable.ItalicEnabled = false;
Mutable.ItalicFontV2 = new SingleFontSpec { FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular), SizePt = 12.75f };
}
ImGuiUtil.HelpMarker(string.Format(Language.Options_Italic_Description, Plugin.PluginName));
ImGui.Spacing();
if (ImGui.CollapsingHeader(Language.Options_ExtraGlyphs_Name))
{
ImGuiUtil.HelpMarker(string.Format(Language.Options_ExtraGlyphs_Description, Plugin.PluginName));
var range = (int)Mutable.ExtraGlyphRanges;
foreach (var extra in Enum.GetValues<ExtraGlyphRanges>())
{
ImGui.CheckboxFlags(extra.Name(), ref range, (int)extra);
}
Mutable.ExtraGlyphRanges = (ExtraGlyphRanges)range;
}
ImGui.Spacing();
}
ImGuiUtil.FontSizeCombo(Language.Options_SymbolsFontSize_Name, ref Mutable.SymbolsFontSizeV2);
ImGuiUtil.HelpMarker(Language.Options_SymbolsFontSize_Description);
ImGui.Spacing();
}
}
private void DrawColoursSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Appearance_Colours_Heading);
if (!tree.Success)
{
return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
DrawColourPresetButtons();
ImGui.TextDisabled(HellionStrings.Settings_Appearance_Colours_PresetsHint);
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
foreach (var (_, types) in ChatTypeExt.SortOrder)
{
foreach (var type in types)
{
if (ImGuiUtil.IconButton(FontAwesomeIcon.UndoAlt, $"{type}", Language.Options_ChatColours_Reset))
{
Mutable.ChatColours.Remove(type);
}
ImGui.SameLine();
if (ImGuiUtil.IconButton(FontAwesomeIcon.LongArrowAltDown, $"{type}", Language.Options_ChatColours_Import))
{
var gameColour = Plugin.Functions.Chat.GetChannelColor(type);
Mutable.ChatColours[type] = gameColour ?? type.DefaultColor() ?? 0;
}
ImGui.SameLine();
var vec = Mutable.ChatColours.TryGetValue(type, out var colour)
? ColourUtil.RgbaToVector3(colour)
: ColourUtil.RgbaToVector3(type.DefaultColor() ?? 0);
if (ImGui.ColorEdit3(type.Name(), ref vec, ImGuiColorEditFlags.NoInputs))
{
Mutable.ChatColours[type] = ColourUtil.Vector3ToRgba(vec);
}
}
}
ImGui.Spacing();
}
}
// Hellion Chat — v0.6.0 preset-buttons row above the per-channel colour
// editors. Apply is immediate and overwrites every channel covered by
// the preset; channels not in the preset keep their current colour.
private void DrawColourPresetButtons()
{
var first = true;
foreach (var (_, preset) in ChatColourPresets.All)
{
if (!first)
{
ImGui.SameLine();
}
first = false;
if (preset.IsBrandPreset)
{
// Hellion-Brand visuell hervorheben — blau-violetter Frame-Akzent
var border = ColourUtil.RgbaToVector3(ColourUtil.ComponentsToRgba(255, 128, 200));
var btn = ColourUtil.RgbaToVector3(ColourUtil.ComponentsToRgba(74, 42, 106));
ImGui.PushStyleColor(ImGuiCol.Border, new System.Numerics.Vector4(border.X, border.Y, border.Z, 1f));
ImGui.PushStyleColor(ImGuiCol.Button, new System.Numerics.Vector4(btn.X, btn.Y, btn.Z, 1f));
ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.5f);
}
if (ImGui.Button(GetPresetLabel(preset)))
{
ApplyPreset(preset);
}
if (preset.IsBrandPreset)
{
ImGui.PopStyleVar();
ImGui.PopStyleColor(2);
}
}
}
// Localized label for a preset; falls back to DisplayName if the i18n
// key is missing (defensive — the resource manager returns the key
// string itself when a lookup fails).
private static string GetPresetLabel(ChatColourPreset preset)
{
var localized = HellionStrings.ResourceManager.GetString(preset.LocalizationKey, HellionStrings.Culture);
return string.IsNullOrEmpty(localized) ? preset.DisplayName : localized;
}
private void ApplyPreset(ChatColourPreset preset)
{
foreach (var (channel, colour) in preset.Colours)
{
Mutable.ChatColours[channel] = colour;
}
Plugin.SaveConfig();
GlobalParametersCache.Refresh();
Plugin.Log.Debug($"Applied chat colour preset: {preset.DisplayName}");
}
private void DrawTimestampsSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Appearance_Timestamps_Heading);
if (!tree.Success)
{
return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGui.Checkbox(Language.Options_PrettierTimestamps_Name, ref Mutable.PrettierTimestamps);
ImGuiUtil.HelpMarker(Language.Options_PrettierTimestamps_Description);
if (Mutable.PrettierTimestamps)
{
ImGui.Checkbox(Language.Options_MoreCompactPretty_Name, ref Mutable.MoreCompactPretty);
ImGuiUtil.HelpMarker(Language.Options_MoreCompactPretty_Description);
ImGui.Checkbox(Language.Options_HideSameTimestamps_Name, ref Mutable.HideSameTimestamps);
ImGuiUtil.HelpMarker(Language.Options_HideSameTimestamps_Description);
}
ImGui.Checkbox(Language.Options_Use24HourClock_Name, ref Mutable.Use24HourClock);
ImGuiUtil.HelpMarker(Language.Options_Use24HourClock_Description);
}
}
}
+237
View File
@@ -0,0 +1,237 @@
using System.Numerics;
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Bindings.ImGui;
namespace HellionChat.Ui.SettingsTabs;
// Chat-Tab — vier eigenständige Sektionen: Auto-Tell-Tabs, Behaviour,
// Preview, Emotes. Der Emotes-Block ist 1:1 aus der Bestand-Datei
// Emote.cs übernommen; die Datei wird in Plan-Task 11 (Settings UX
// Polish v0.5.0) entfernt, sobald alle Tabs migriert sind.
internal sealed class Chat : ISettingsTab
{
private Plugin Plugin { get; }
private Configuration Mutable { get; }
public string Name => HellionStrings.Settings_Tab_Chat + "###tabs-chat";
private SearchSelector.SelectorPopupOptions WordPopupOptions;
internal Chat(Plugin plugin, Configuration mutable)
{
Plugin = plugin;
Mutable = mutable;
WordPopupOptions = RefillSheet();
}
private SearchSelector.SelectorPopupOptions RefillSheet()
{
return new SearchSelector.SelectorPopupOptions
{
FilteredSheet = EmoteCache.SortedCodeArray.Where(w => !Mutable.BlockedEmotes.Contains(w)).ToArray()
};
}
public void Draw(bool changed)
{
DrawAutoTellTabsSection();
ImGui.Spacing();
DrawBehaviourSection();
ImGui.Spacing();
DrawPreviewSection();
ImGui.Spacing();
DrawEmotesSection();
}
private void DrawAutoTellTabsSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Chat_AutoTellTabs_Heading);
if (!tree.Success)
{
return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGui.Checkbox(HellionStrings.ChatLog_AutoTellTabs_Enable_Name, ref Mutable.EnableAutoTellTabs);
ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_Enable_Description);
ImGui.SetNextItemWidth(200f * ImGuiHelpers.GlobalScale);
var limit = Mutable.AutoTellTabsLimit;
if (ImGui.SliderInt(HellionStrings.ChatLog_AutoTellTabs_Limit_Name, ref limit, 1, 50))
{
Mutable.AutoTellTabsLimit = limit;
}
ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_Limit_Description);
ImGui.Checkbox(HellionStrings.ChatLog_AutoTellTabs_Compact_Name, ref Mutable.AutoTellTabsCompactDisplay);
ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_Compact_Description);
ImGui.Checkbox(HellionStrings.ChatLog_AutoTellTabs_OpenAsPopout_Name, ref Mutable.AutoTellTabsOpenAsPopout);
ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_OpenAsPopout_Description);
ImGui.Checkbox(HellionStrings.ChatLog_AutoTellTabs_GreetedToggle_Name, ref Mutable.AutoTellTabsShowGreetedToggle);
ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_GreetedToggle_Description);
ImGui.Spacing();
ImGuiUtil.HelpText(HellionStrings.ChatLog_AutoTellTabs_PreloadHint);
ImGui.Spacing();
ImGuiUtil.WarningText(HellionStrings.ChatLog_AutoTellTabs_ConflictHint);
}
}
private void DrawBehaviourSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Chat_Behaviour_Heading);
if (!tree.Success)
{
return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGui.Checkbox(Language.Options_CollapseDuplicateMessages_Name, ref Mutable.CollapseDuplicateMessages);
ImGuiUtil.HelpMarker(Language.Options_CollapseDuplicateMessages_Description);
if (Mutable.CollapseDuplicateMessages)
{
ImGui.Checkbox(Language.Options_CollapseDuplicateMsgUniqueLink_Name, ref Mutable.CollapseKeepUniqueLinks);
ImGuiUtil.HelpMarker(Language.Options_CollapseDuplicateMsgUniqueLink_Description);
}
}
}
private void DrawPreviewSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Chat_Preview_Heading);
if (!tree.Success)
{
return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
using (var combo = ImGuiUtil.BeginComboVertical(Language.Options_Preview_Name, Mutable.PreviewPosition.Name()))
{
if (combo)
{
foreach (var position in Enum.GetValues<PreviewPosition>())
{
if (ImGui.Selectable(position.Name(), Mutable.PreviewPosition == position))
{
Mutable.PreviewPosition = position;
}
}
}
}
ImGuiUtil.HelpMarker(Language.Options_Preview_Description);
if (ImGuiUtil.InputIntVertical(Language.Options_PreviewMinimum_Name, Language.Options_PreviewMinimum_Description, ref Mutable.PreviewMinimum))
{
Mutable.PreviewMinimum = Math.Clamp(Mutable.PreviewMinimum, 1, 250);
}
ImGui.Checkbox(Language.Options_PreviewOnlyIf_Name, ref Mutable.OnlyPreviewIf);
ImGuiUtil.HelpMarker(Language.Options_PreviewOnlyIf_Description);
}
}
private void DrawEmotesSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Chat_Emotes_Heading);
if (!tree.Success)
{
return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGui.Checkbox(Language.Options_ShowEmotes_Name, ref Mutable.ShowEmotes);
ImGuiUtil.HelpMarker(Language.Options_ShowEmotes_Desc);
ImGui.Spacing();
ImGui.TextUnformatted(Language.Options_Emote_BlockedEmotes);
ImGui.Spacing();
if (EmoteCache.State is EmoteCache.LoadingState.Done && WordPopupOptions.FilteredSheet.Length == 0)
{
WordPopupOptions = RefillSheet();
}
var buttonWidth = ImGui.GetContentRegionAvail().X / 3;
using (Plugin.FontManager.FontAwesome.Push())
ImGui.Button(FontAwesomeIcon.Plus.ToIconString(), new Vector2(buttonWidth, 0));
if (SearchSelector.SelectorPopup("WordAddPopup", out var newWord, WordPopupOptions))
{
Mutable.BlockedEmotes.Add(newWord);
}
using (var table = ImRaii.Table("##BlockedWords", 2, ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInner))
{
if (table)
{
ImGui.TableSetupColumn(Language.Options_Emote_EmoteTable);
ImGui.TableSetupColumn("##Del", ImGuiTableColumnFlags.WidthStretch, 0.07f);
ImGui.TableHeadersRow();
var copiedList = Mutable.BlockedEmotes.ToArray();
foreach (var word in copiedList)
{
ImGui.TableNextColumn();
ImGui.TextUnformatted(word);
ImGui.TableNextColumn();
if (ImGuiUtil.Button($"##{word}Del", FontAwesomeIcon.Trash, !ImGui.GetIO().KeyCtrl))
{
Mutable.BlockedEmotes.Remove(word);
}
}
}
}
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
ImGui.TextUnformatted(Language.Options_Emote_EmoteStats);
ImGui.Spacing();
if (EmoteCache.State is EmoteCache.LoadingState.Done)
{
ImGui.TextColored(ImGuiColors.HealerGreen, Language.Options_Emote_Ready);
}
else
{
ImGui.TextColored(ImGuiColors.DPSRed, Language.Options_Emote_NotReady);
}
ImGui.TextUnformatted($"{Language.Options_Emote_Loaded} {EmoteCache.SortedCodeArray.Length}");
using (var emoteTable = ImRaii.Table("##LoadedEmotes", 5, ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInner))
{
if (emoteTable)
{
ImGui.TableSetupColumn("##word1");
ImGui.TableSetupColumn("##word2");
ImGui.TableSetupColumn("##word3");
ImGui.TableSetupColumn("##word4");
ImGui.TableSetupColumn("##word5");
foreach (var word in EmoteCache.SortedCodeArray)
{
ImGui.TableNextColumn();
ImGui.TextUnformatted(word);
}
}
}
}
}
}
+259
View File
@@ -0,0 +1,259 @@
using System.Diagnostics;
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Bindings.ImGui;
using Dalamud.Game.Text;
namespace HellionChat.Ui.SettingsTabs;
internal sealed class Database : ISettingsTab
{
private Plugin Plugin { get; }
private Configuration Mutable { get; }
public string Name => HellionStrings.Settings_Tab_Database + "###tabs-database";
internal Database(Plugin plugin, Configuration mutable)
{
Plugin = plugin;
Mutable = mutable;
}
private bool ShowAdvanced;
private long DatabaseLastRefreshTicks;
private long DatabaseSize;
private long DatabaseLogSize;
private int DatabaseMessageCount;
public void Draw(bool changed)
{
// Shift-on-open keeps the Advanced tools available without a permanent
// toggle in the UI, mirroring upstream Chat 2 behaviour.
if (changed)
ShowAdvanced = ImGui.GetIO().KeyShift;
DrawStorageSection();
ImGui.Spacing();
DrawViewerSection();
ImGui.Spacing();
DrawStatsSection();
}
private void DrawStorageSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Database_Storage_Heading);
if (!tree.Success)
return;
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGui.Checkbox(Language.Options_DatabaseBattleMessages_Name, ref Mutable.DatabaseBattleMessages);
ImGuiUtil.HelpMarker(Language.Options_DatabaseBattleMessages_Description);
if (ImGui.Checkbox(Language.Options_LoadPreviousSession_Name, ref Mutable.LoadPreviousSession))
if (Mutable.LoadPreviousSession)
Mutable.FilterIncludePreviousSessions = true;
ImGuiUtil.HelpMarker(Language.Options_LoadPreviousSession_Description);
if (ImGui.Checkbox(Language.Options_FilterIncludePreviousSessions_Name, ref Mutable.FilterIncludePreviousSessions))
if (!Mutable.FilterIncludePreviousSessions)
Mutable.LoadPreviousSession = false;
ImGuiUtil.HelpMarker(Language.Options_FilterIncludePreviousSessions_Description);
var old = new FileInfo(Path.Join(Plugin.Interface.ConfigDirectory.FullName, "chat.db"));
var migratedOld = new FileInfo(Path.Join(Plugin.Interface.ConfigDirectory.FullName, "chat-litedb.db"));
if (old.Exists || migratedOld.Exists)
{
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
ImGui.TextUnformatted(Language.Options_Database_Old_Heading);
ImGui.Spacing();
if (ImGuiUtil.CtrlShiftButton(Language.Options_Database_Old_Delete, Language.Options_Database_Old_Delete_Tooltip))
{
try
{
if (old.Exists)
old.Delete();
else
migratedOld.Delete();
WrapperUtil.AddNotification(Language.Options_Database_Old_Delete_Success, NotificationType.Success);
}
catch (Exception e)
{
Plugin.Log.Error(e, "Unable to delete old database");
WrapperUtil.AddNotification(Language.Options_Database_Old_Delete_Error, NotificationType.Error);
}
}
}
}
}
private void DrawViewerSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Database_Viewer_Heading);
if (!tree.Success)
return;
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
// Refresh the database size and message count every 5 seconds to avoid
// constant stat calls and spamming the database.
if (DatabaseLastRefreshTicks + 5 * 1000 < Environment.TickCount64)
{
DatabaseSize = Plugin.MessageManager.Store.DatabaseSize();
DatabaseLogSize = Plugin.MessageManager.Store.DatabaseLogSize();
DatabaseMessageCount = Plugin.MessageManager.Store.MessageCount();
DatabaseLastRefreshTicks = Environment.TickCount64;
}
// Copy the directory path instead of the file path so people can
// paste it into their file explorer.
ImGuiUtil.HelpText(string.Format(Language.Options_Database_Metadata_Path, MessageManager.DatabasePath()));
if (ImGui.IsItemClicked(ImGuiMouseButton.Left))
{
var path = Path.GetDirectoryName(MessageManager.DatabasePath());
ImGui.SetClipboardText(path);
WrapperUtil.AddNotification(Language.Options_Database_Metadata_CopyConfigPathNotification, NotificationType.Info);
}
if (ImGui.IsItemHovered())
{
ImGui.SetMouseCursor(ImGuiMouseCursor.Hand);
ImGuiUtil.Tooltip(Language.Options_Database_Metadata_CopyConfigPath);
}
ImGuiUtil.HelpText(string.Format(Language.Options_Database_Metadata_Size, StringUtil.BytesToString(DatabaseSize)));
if (ImGui.IsItemHovered())
ImGuiUtil.Tooltip(StringUtil.BytesToString(DatabaseSize));
ImGuiUtil.HelpText(string.Format(Language.Options_Database_Metadata_LogSize, StringUtil.BytesToString(DatabaseLogSize)));
if (ImGui.IsItemHovered())
ImGuiUtil.Tooltip(StringUtil.BytesToString(DatabaseLogSize));
ImGuiUtil.HelpText(string.Format(Language.Options_Database_Metadata_MessageCount, DatabaseMessageCount));
if (ImGuiUtil.CtrlShiftButton(Language.Options_ClearDatabase_Button, Language.Options_ClearDatabase_Tooltip))
{
Plugin.Log.Warning("Clearing messages from database");
Plugin.MessageManager.Store.ClearMessages();
Plugin.MessageManager.ClearAllTabs();
// Refresh on next draw
DatabaseLastRefreshTicks = 0;
WrapperUtil.AddNotification(Language.Options_ClearDatabase_Success, NotificationType.Info);
}
}
}
private void DrawStatsSection()
{
if (!ShowAdvanced)
return;
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Database_Stats_Heading);
if (!tree.Success)
return;
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
using var wrap = ImRaii.TextWrapPos(0.0f);
ImGuiUtil.WarningText(Language.Options_Database_Advanced_Warning);
if (ImGuiUtil.CtrlShiftButton("Perform maintenance", "Ctrl+Shift: MessageManager.Store.PerformMaintenance()"))
Plugin.MessageManager.Store.PerformMaintenance();
if (ImGuiUtil.CtrlShiftButton("Reload messages from database", "Ctrl+Shift: MessageManager.FilterAllTabs()"))
{
Plugin.MessageManager.ClearAllTabs();
Plugin.MessageManager.FilterAllTabsAsync();
}
if (ImGuiUtil.CtrlShiftButton("Inject 10,000 messages", "Ctrl+Shift: creates 10,000 unique messages (async)"))
new Thread(() => InsertMessages(10_000)).Start();
}
}
private void InsertMessages(int count)
{
Plugin.Log.Info($"Inserting {count} messages due to user request");
// Generate
var stopwatch = Stopwatch.StartNew();
var playerName = Plugin.PlayerState.CharacterName;
var worldId = Plugin.PlayerState.HomeWorld.ValueNullable?.RowId ?? 0;
var senderSource = new SeStringBuilder()
.AddText("<")
.Add(new PlayerPayload(playerName, worldId))
.AddText("Random Message")
.Add(RawPayload.LinkTerminator)
.AddText(">: ")
.Build();
var senderChunks = ChunkUtil.ToChunks(senderSource, ChunkSource.Sender, ChatType.Debug).ToList();
var messages = new List<Message>(count);
for (var i = 0; i < count; i++)
{
var contentSource = new SeStringBuilder()
.AddText("Random message payload - ")
.AddItalics(Guid.NewGuid().ToString())
.Build();
var contentChunks = ChunkUtil.ToChunks(contentSource, ChunkSource.Content, ChatType.Debug).ToList();
var chatCode = new ChatCode(XivChatType.Say, 0, 0);
messages.Add(new Message(
Guid.NewGuid(),
Plugin.MessageManager.CurrentContentId,
Plugin.MessageManager.CurrentContentId,
DateTimeOffset.UtcNow,
chatCode,
senderChunks,
contentChunks,
senderSource,
contentSource,
Guid.Empty
));
}
var elapsedTicks = stopwatch.ElapsedTicks;
stopwatch.Stop();
Plugin.Log.Info($"Crafted {count} messages in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)");
// Insert
stopwatch = Stopwatch.StartNew();
foreach (var message in messages)
Plugin.MessageManager.Store.UpsertMessage(message);
elapsedTicks = stopwatch.ElapsedTicks;
stopwatch.Stop();
Plugin.Log.Info($"Upserted {count} messages in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)");
// Clear tabs during framework frame
Plugin.Framework.Run(() =>
{
stopwatch = Stopwatch.StartNew();
Plugin.MessageManager.ClearAllTabs();
elapsedTicks = stopwatch.ElapsedTicks;
stopwatch.Stop();
Plugin.Log.Info($"Cleared {Plugin.Config.Tabs.Count} tabs in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)");
}).Wait();
// Fetch and filter during framework frame
Plugin.Framework.Run(() =>
{
stopwatch = Stopwatch.StartNew();
// Intentionally synchronous
Plugin.MessageManager.FilterAllTabs();
elapsedTicks = stopwatch.ElapsedTicks;
stopwatch.Stop();
Plugin.Log.Info($"Fetched and filtered all tabs in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)");
}).Wait();
}
}
+161
View File
@@ -0,0 +1,161 @@
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Bindings.ImGui;
namespace HellionChat.Ui.SettingsTabs;
internal sealed class General : ISettingsTab
{
private Plugin Plugin { get; }
private Configuration Mutable { get; }
public string Name => HellionStrings.Settings_Tab_General + "###tabs-general";
internal General(Plugin plugin, Configuration mutable)
{
Plugin = plugin;
Mutable = mutable;
}
public void Draw(bool changed)
{
DrawInputSection();
ImGui.Spacing();
DrawAudioSection();
ImGui.Spacing();
DrawPerformanceSection();
ImGui.Spacing();
DrawLanguageSection();
}
private void DrawInputSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_General_Input_Heading);
if (!tree.Success)
{
return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGui.Checkbox(Language.Options_KeepInputFocus_Name, ref Mutable.KeepInputFocus);
ImGuiUtil.HelpMarker(Language.Options_KeepInputFocus_Description);
ImGui.Spacing();
ImGui.TextUnformatted(Language.Options_ChatTabForwardKeybind_Name);
ImGui.SetNextItemWidth(-1);
ImGuiUtil.KeybindInput("ChatTabForwardKeybind", ref Mutable.ChatTabForward);
ImGui.TextUnformatted(Language.Options_ChatTabBackwardKeybind_Name);
ImGui.SetNextItemWidth(-1);
ImGuiUtil.KeybindInput("ChatTabBackwardKeybind", ref Mutable.ChatTabBackward);
}
}
private void DrawAudioSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_General_Audio_Heading);
if (!tree.Success)
{
return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGui.Checkbox(Language.Options_PlaySounds_Name, ref Mutable.PlaySounds);
ImGuiUtil.HelpMarker(Language.Options_PlaySounds_Description);
ImGui.Checkbox(Language.Options_ShowNoviceNetwork_Name, ref Mutable.ShowNoviceNetwork);
ImGuiUtil.HelpMarker(Language.Options_ShowNoviceNetwork_Description);
}
}
private void DrawPerformanceSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_General_Performance_Heading);
if (!tree.Success)
{
return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGui.SetNextItemWidth(200f * ImGuiHelpers.GlobalScale);
if (ImGui.InputInt(Language.Options_MaxLinesToShow_Name, ref Mutable.MaxLinesToRender))
{
Mutable.MaxLinesToRender = Math.Clamp(Mutable.MaxLinesToRender, 1, 10_000);
}
ImGuiUtil.HelpMarker(Language.Options_MaxLinesToShow_Description);
}
}
private void DrawLanguageSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_General_Language_Heading);
if (!tree.Success)
{
return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
using (var combo = ImGuiUtil.BeginComboVertical(Language.Options_Language_Name, Mutable.LanguageOverride.Name()))
{
if (combo.Success)
{
foreach (var language in Enum.GetValues<LanguageOverride>())
{
if (ImGui.Selectable(language.Name()))
{
Mutable.LanguageOverride = language;
}
}
}
}
ImGuiUtil.HelpMarker(string.Format(Language.Options_Language_Description, Plugin.PluginName));
ImGui.Spacing();
using (var combo = ImGuiUtil.BeginComboVertical(Language.Options_CommandHelpSide_Name, Mutable.CommandHelpSide.Name()))
{
if (combo.Success)
{
foreach (var side in Enum.GetValues<CommandHelpSide>())
{
if (ImGui.Selectable(side.Name(), Mutable.CommandHelpSide == side))
{
Mutable.CommandHelpSide = side;
}
}
}
}
ImGuiUtil.HelpMarker(string.Format(Language.Options_CommandHelpSide_Description, Plugin.PluginName));
ImGui.Spacing();
using (var combo = ImGuiUtil.BeginComboVertical(Language.Options_KeybindMode_Name, Mutable.KeybindMode.Name()))
{
if (combo.Success)
{
foreach (var mode in Enum.GetValues<KeybindMode>())
{
if (ImGui.Selectable(mode.Name(), Mutable.KeybindMode == mode))
{
Mutable.KeybindMode = mode;
}
if (ImGui.IsItemHovered())
{
ImGuiUtil.Tooltip(mode.Tooltip() ?? "");
}
}
}
}
ImGuiUtil.HelpMarker(string.Format(Language.Options_KeybindMode_Description, Plugin.PluginName));
ImGui.Spacing();
ImGui.Checkbox(Language.Options_SortAutoTranslate_Name, ref Mutable.SortAutoTranslate);
ImGuiUtil.HelpMarker(Language.Options_SortAutoTranslate_Description);
}
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace HellionChat.Ui.SettingsTabs;
internal interface ISettingsTab
{
string Name { get; }
void Draw(bool changed);
}
+188
View File
@@ -0,0 +1,188 @@
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Bindings.ImGui;
namespace HellionChat.Ui.SettingsTabs;
// Information-Tab vereint die früheren About- und Changelog-Tabs in
// drei kollabierbaren Sektionen. Der About-Inhalt ist 1:1 aus About.cs
// übernommen, die Changelog-Render-Logik aus Changelog.cs.
internal sealed class Information : ISettingsTab
{
private Configuration Mutable { get; }
public string Name => HellionStrings.Settings_Tab_Information + "###tabs-information";
private readonly List<string> Translators =
[
"q673135110", "Akizem", "d0tiKs",
"Moonlight_Everlit", "Dark32", "andreycout",
"Button_", "Cali666", "cassandra308",
"lokinmodar", "jtabox", "AkiraYorumoto",
"MKhayle", "elena.space", "imlisa",
"andrei5125", "ShivaMaheshvara", "aislinn87",
"nishinatsu051", "lichuyuan", "Risu64",
"yummypillow", "witchymary", "Yuzumi",
"zomsakura", "Sirayuki"
];
internal Information(Configuration mutable)
{
Mutable = mutable;
Translators.Sort((a, b) => string.Compare(a.ToLowerInvariant(), b.ToLowerInvariant(), StringComparison.Ordinal));
}
public void Draw(bool changed)
{
using var wrap = ImRaii.TextWrapPos(0.0f);
DrawVersionInfoSection();
ImGui.Spacing();
DrawAboutSection();
ImGui.Spacing();
DrawChangelogSection();
}
private void DrawVersionInfoSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Information_VersionInfo_Heading);
if (!tree.Success)
return;
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGui.TextUnformatted(string.Format(Language.Options_About_Opening, Plugin.PluginName));
ImGuiHelpers.ScaledDummy(10.0f);
ImGui.TextUnformatted(Language.Options_About_Authors);
ImGui.SameLine();
ImGui.TextColored(ImGuiColors.ParsedGold, Plugin.Interface.Manifest.Author);
ImGui.TextUnformatted(Language.Options_About_Discord);
ImGui.SameLine();
ImGui.TextColored(ImGuiColors.ParsedGold, "@j.j_kazama");
ImGui.TextUnformatted(Language.Options_About_Version);
ImGui.SameLine();
ImGui.TextColored(ImGuiColors.ParsedOrange, Plugin.Interface.Manifest.AssemblyVersion.ToString(3));
ImGuiHelpers.ScaledDummy(10.0f);
ImGui.TextUnformatted(Language.Options_About_Github_Issues);
ImGui.SameLine();
if (ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "githubIssues"))
Dalamud.Utility.Util.OpenLink("https://github.com/JonKazama-Hellion/HellionChat/issues");
}
}
private void DrawAboutSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Information_About_Heading);
if (!tree.Success)
return;
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGui.TextColored(ImGuiColors.ParsedGold, HellionStrings.About_Maintainer_Heading);
ImGui.TextUnformatted(HellionStrings.About_Maintainer_Body);
ImGui.TextUnformatted(HellionStrings.About_Maintainer_Website_Label);
ImGui.SameLine();
if (ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "hellionMedia"))
Dalamud.Utility.Util.OpenLink("https://hellion-media.de");
ImGuiHelpers.ScaledDummy(10.0f);
ImGui.TextColored(ImGuiColors.ParsedGold, HellionStrings.About_Mission_Heading);
ImGui.TextUnformatted(HellionStrings.About_Mission_P1);
ImGui.Spacing();
ImGui.TextUnformatted(HellionStrings.About_Mission_P2);
ImGui.Spacing();
ImGui.TextUnformatted(HellionStrings.About_Mission_P3);
ImGuiHelpers.ScaledDummy(10.0f);
ImGui.TextColored(ImGuiColors.ParsedGold, HellionStrings.About_BuiltOn_Heading);
ImGui.TextUnformatted(HellionStrings.About_BuiltOn_P1);
ImGui.Spacing();
ImGui.TextUnformatted(HellionStrings.About_BuiltOn_P2);
ImGui.Spacing();
ImGui.TextUnformatted(HellionStrings.About_BuiltOn_Upstream_Label);
ImGui.SameLine();
if (ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "chatTwoUpstream"))
Dalamud.Utility.Util.OpenLink("https://github.com/Infiziert90/ChatTwo");
ImGuiHelpers.ScaledDummy(10.0f);
ImGui.TextColored(ImGuiColors.ParsedGold, HellionStrings.About_License_Heading);
ImGui.TextUnformatted(HellionStrings.About_License_P1);
ImGui.TextUnformatted(HellionStrings.About_License_P2);
ImGui.TextUnformatted(HellionStrings.About_License_P3);
ImGuiHelpers.ScaledDummy(10.0f);
ImGui.TextColored(ImGuiColors.DalamudOrange, HellionStrings.About_SE_Heading);
ImGui.TextUnformatted(HellionStrings.About_SE_P1);
ImGui.TextUnformatted(HellionStrings.About_SE_P2);
ImGui.Spacing();
ImGui.TextColored(ImGuiColors.ParsedGold, HellionStrings.About_Localization_Heading);
ImGui.TextUnformatted(HellionStrings.About_Localization_P1);
ImGui.TextUnformatted(HellionStrings.About_Localization_P2);
ImGui.Spacing();
using (var translatorTree = ImRaii.TreeNode(HellionStrings.About_Translators_TreeNode))
{
if (translatorTree)
{
using var indent = ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false);
foreach (var translator in Translators)
ImGui.TextUnformatted(translator);
}
}
}
}
private void DrawChangelogSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Information_Changelog_Heading);
if (!tree.Success)
return;
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGui.Checkbox(Language.Options_PrintChangelog_Name, ref Mutable.PrintChangelog);
ImGuiUtil.HelpMarker(Language.Options_PrintChangelog_Description);
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
var changelog = Plugin.Interface.Manifest.Changelog;
if (changelog == null)
return;
ImGui.TextUnformatted(Language.Options_Changelog_Header);
ImGui.TextUnformatted($"Version {Plugin.Interface.Manifest.AssemblyVersion.ToString(3)}");
ImGui.Spacing();
foreach (var sentence in changelog.Split("\n"))
{
if (sentence == string.Empty)
{
ImGui.NewLine();
continue;
}
var indented = sentence.StartsWith('-') || sentence.StartsWith(" -");
using var indent = ImRaii.PushIndent(10.0f, true, indented);
ImGui.TextUnformatted(sentence);
}
}
}
}
+638
View File
@@ -0,0 +1,638 @@
using HellionChat.Code;
using HellionChat.Export;
using HellionChat.Privacy;
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud.Interface.Colors;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Bindings.ImGui;
namespace HellionChat.Ui.SettingsTabs;
internal sealed class Privacy : ISettingsTab
{
private Plugin Plugin { get; }
private Configuration Mutable { get; }
public string Name => HellionStrings.Privacy_Tab_Title + "###tabs-privacy";
internal Privacy(Plugin plugin, Configuration mutable)
{
Plugin = plugin;
Mutable = mutable;
}
// (HeadingKey lookup, ChatType list). Heading is resolved per-frame so
// a runtime LanguageChanged call updates the labels immediately.
private static readonly (Func<string> Heading, ChatType[] Types)[] Groups =
[
(() => HellionStrings.Privacy_Group_DirectMessages, [ChatType.TellIncoming, ChatType.TellOutgoing]),
(() => HellionStrings.Privacy_Group_PartyAlliance, [ChatType.Party, ChatType.CrossParty, ChatType.Alliance, ChatType.PvpTeam]),
(() => HellionStrings.Privacy_Group_FreeCompany, [ChatType.FreeCompany, ChatType.FreeCompanyAnnouncement, ChatType.FreeCompanyLoginLogout]),
(() => HellionStrings.Privacy_Group_Linkshells, [
ChatType.Linkshell1, ChatType.Linkshell2, ChatType.Linkshell3, ChatType.Linkshell4,
ChatType.Linkshell5, ChatType.Linkshell6, ChatType.Linkshell7, ChatType.Linkshell8,
]),
(() => HellionStrings.Privacy_Group_CrossLinkshells, [
ChatType.CrossLinkshell1, ChatType.CrossLinkshell2, ChatType.CrossLinkshell3, ChatType.CrossLinkshell4,
ChatType.CrossLinkshell5, ChatType.CrossLinkshell6, ChatType.CrossLinkshell7, ChatType.CrossLinkshell8,
]),
(() => HellionStrings.Privacy_Group_ExtraChat, [
ChatType.ExtraChatLinkshell1, ChatType.ExtraChatLinkshell2, ChatType.ExtraChatLinkshell3, ChatType.ExtraChatLinkshell4,
ChatType.ExtraChatLinkshell5, ChatType.ExtraChatLinkshell6, ChatType.ExtraChatLinkshell7, ChatType.ExtraChatLinkshell8,
]),
(() => HellionStrings.Privacy_Group_PublicChat, [ChatType.Say, ChatType.Shout, ChatType.Yell, ChatType.NoviceNetwork, ChatType.CustomEmote, ChatType.StandardEmote]),
(() => HellionStrings.Privacy_Group_SystemLogs, [
ChatType.System, ChatType.Notice, ChatType.Urgent, ChatType.Echo,
ChatType.NpcDialogue, ChatType.NpcAnnouncement,
ChatType.LootNotice, ChatType.LootRoll, ChatType.RetainerSale,
ChatType.Crafting, ChatType.Gathering, ChatType.Sign, ChatType.RandomNumber,
]),
];
private Dictionary<int, long>? CleanupCounts;
private long CleanupKeepCount;
private long CleanupDeleteCount;
private bool CleanupRunning;
private bool CleanupPreviewStale;
private HashSet<ChatType>? CleanupPreviewSnapshot;
// The retention-running state lives on Plugin so the auto-sweep and
// this manual button see the same flag. UI reads stay lock-free
// because ImGui is single-threaded and bool reads are atomic in .NET.
private bool RetentionRunning => Plugin.RetentionSweepRunning;
// Export form state
private int ExportRangeDays = 30;
private string ExportSenderSubstring = string.Empty;
private readonly HashSet<ChatType> ExportSelectedChannels = [];
private ExportFormat ExportFormat = ExportFormat.Markdown;
private bool ExportRunning;
public void Draw(bool changed)
{
if (ImGui.Button(HellionStrings.Wizard_Reopen_Button))
Plugin.FirstRunWizard.IsOpen = true;
ImGui.Spacing();
DrawPrivacyFilterSection();
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
DrawRetentionSection();
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
DrawCleanupSection();
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
DrawExportSection();
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
DrawAutoTellTabsPreloadSection();
}
private void DrawAutoTellTabsPreloadSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Privacy_AutoTellTabs_Section_Title);
if (!tree.Success)
return;
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
var preload = Mutable.AutoTellTabsHistoryPreload;
ImGui.SetNextItemWidth(200f * ImGuiHelpers.GlobalScale);
if (ImGui.SliderInt(HellionStrings.Privacy_AutoTellTabs_Preload_Name, ref preload, 0, 100))
{
Mutable.AutoTellTabsHistoryPreload = preload;
}
ImGuiUtil.HelpMarker(HellionStrings.Privacy_AutoTellTabs_Preload_Description);
ImGui.Spacing();
ImGuiUtil.HelpText(HellionStrings.Privacy_AutoTellTabs_Preload_Hint);
}
}
private void DrawPrivacyFilterSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Privacy_Filter_Tree_Heading);
if (!tree.Success)
return;
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGuiUtil.OptionCheckbox(
ref Mutable.PrivacyFilterEnabled,
HellionStrings.Privacy_FilterEnabled_Name,
HellionStrings.Privacy_FilterEnabled_Description);
ImGuiUtil.HelpMarker(HellionStrings.Privacy_FilterEnabled_StorageOnly_Help);
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
using (ImRaii.Disabled(!Mutable.PrivacyFilterEnabled))
{
ImGuiUtil.HelpText(HellionStrings.Privacy_Whitelist_Help);
ImGui.Spacing();
if (ImGui.Button(HellionStrings.Privacy_Preset_PrivacyFirst))
Mutable.PrivacyPersistChannels = [..PrivacyDefaults.PrivacyFirstWhitelist];
ImGui.SameLine();
if (ImGui.Button(HellionStrings.Privacy_Preset_ClearAll))
Mutable.PrivacyPersistChannels.Clear();
ImGui.SameLine();
if (ImGui.Button(HellionStrings.Privacy_Preset_SelectAll))
foreach (var group in Groups)
foreach (var t in group.Types)
Mutable.PrivacyPersistChannels.Add(t);
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
foreach (var (heading, types) in Groups)
{
using var groupTree = ImRaii.TreeNode(heading());
if (!groupTree.Success)
continue;
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
foreach (var type in types)
{
var enabled = Mutable.PrivacyPersistChannels.Contains(type);
var label = type.ToString();
if (ImGui.Checkbox($"{label}##privacy-{(int)type}", ref enabled))
{
if (enabled)
Mutable.PrivacyPersistChannels.Add(type);
else
Mutable.PrivacyPersistChannels.Remove(type);
}
}
}
}
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
ImGuiUtil.OptionCheckbox(
ref Mutable.PrivacyPersistUnknownChannels,
HellionStrings.Privacy_PersistUnknown_Name,
HellionStrings.Privacy_PersistUnknown_Description);
}
}
}
private void DrawExportSection()
{
ImGui.TextUnformatted(HellionStrings.Export_Heading);
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGuiUtil.HelpText(HellionStrings.Export_Help);
ImGui.Spacing();
if (ImGui.InputInt(HellionStrings.Export_Range_Label, ref ExportRangeDays))
ExportRangeDays = Math.Max(0, ExportRangeDays);
ImGui.InputText(HellionStrings.Export_Sender_Label, ref ExportSenderSubstring, 256);
using (var tree = ImRaii.TreeNode(HellionStrings.Export_Channels_Heading))
{
if (tree.Success)
{
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGuiUtil.HelpText(HellionStrings.Export_Channels_AllOff);
foreach (var (heading, types) in Groups)
{
using var subTree = ImRaii.TreeNode($"{heading()}##export-group-{heading()}");
if (!subTree.Success)
continue;
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
foreach (var type in types)
{
var enabled = ExportSelectedChannels.Contains(type);
if (ImGui.Checkbox($"{type}##export-{(int)type}", ref enabled))
{
if (enabled)
ExportSelectedChannels.Add(type);
else
ExportSelectedChannels.Remove(type);
}
}
}
}
}
}
ImGui.Spacing();
ImGui.TextUnformatted(HellionStrings.Export_Format_Label);
ImGui.SameLine();
var fmt = (int)ExportFormat;
if (ImGui.RadioButton(HellionStrings.Export_Format_Markdown, ref fmt, (int)ExportFormat.Markdown))
ExportFormat = ExportFormat.Markdown;
ImGui.SameLine();
if (ImGui.RadioButton(HellionStrings.Export_Format_Json, ref fmt, (int)ExportFormat.Json))
ExportFormat = ExportFormat.Json;
ImGui.SameLine();
if (ImGui.RadioButton(HellionStrings.Export_Format_Csv, ref fmt, (int)ExportFormat.Csv))
ExportFormat = ExportFormat.Csv;
ImGui.Spacing();
using (ImRaii.Disabled(ExportRunning))
{
if (ImGui.Button(HellionStrings.Export_Button))
PromptExport();
}
if (ExportRunning)
ImGuiUtil.HelpText(HellionStrings.Export_Running);
}
}
private void PromptExport()
{
var defaultName = $"hellion-chat-export-{DateTimeOffset.Now:yyyyMMdd-HHmm}";
var ext = ExportFormat.Extension();
Plugin.FileDialogManager.SaveFileDialog(
HellionStrings.Export_Dialog_Title,
ExportFormat.Filter(),
defaultName,
ext,
(success, path) =>
{
if (!success || string.IsNullOrWhiteSpace(path))
return;
StartExport(path);
});
}
private void StartExport(string path)
{
if (ExportRunning)
return;
ExportRunning = true;
var types = ExportSelectedChannels.Count > 0
? ExportSelectedChannels.Select(t => (int)(ushort)t).ToList()
: null;
DateTimeOffset? from = ExportRangeDays > 0
? DateTimeOffset.UtcNow.AddDays(-ExportRangeDays)
: null;
var senderSubstring = string.IsNullOrWhiteSpace(ExportSenderSubstring) ? null : ExportSenderSubstring.Trim();
var format = ExportFormat;
var filterDesc = new MessageExporter.FilterDescription(types, from, null, senderSubstring);
new Thread(() =>
{
try
{
using var enumerator = Plugin.MessageManager.Store.StreamForExport(types, from, null);
var written = MessageExporter.ExportToFile(path, format, enumerator, filterDesc);
if (written > 0)
WrapperUtil.AddNotification(string.Format(HellionStrings.Export_Success, written, path), NotificationType.Success);
else
WrapperUtil.AddNotification(HellionStrings.Export_Empty, NotificationType.Info);
}
catch (Exception e)
{
Plugin.Log.Error(e, "Export failed");
WrapperUtil.AddNotification(HellionStrings.Export_Error, NotificationType.Error);
}
finally
{
ExportRunning = false;
}
}) { IsBackground = true }.Start();
}
private void DrawRetentionSection()
{
ImGui.TextUnformatted(HellionStrings.Retention_Heading);
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGuiUtil.OptionCheckbox(
ref Mutable.RetentionEnabled,
HellionStrings.Retention_Enabled_Name,
HellionStrings.Retention_Enabled_Description);
using (ImRaii.Disabled(!Mutable.RetentionEnabled))
{
ImGui.Spacing();
var defaultDays = Mutable.RetentionDefaultDays;
if (ImGui.InputInt(HellionStrings.Retention_Default_Label, ref defaultDays))
Mutable.RetentionDefaultDays = Math.Max(0, defaultDays);
ImGuiUtil.HelpMarker(HellionStrings.Retention_Default_Help);
ImGui.Spacing();
if (ImGui.Button(HellionStrings.Retention_Reset_Spec))
{
Mutable.RetentionPerChannelDays =
PrivacyDefaults.DefaultRetentionDays.ToDictionary(p => p.Key, p => p.Value);
}
ImGui.SameLine();
if (ImGui.Button(HellionStrings.Retention_Clear_Overrides))
Mutable.RetentionPerChannelDays.Clear();
ImGui.Spacing();
using (var tree = ImRaii.TreeNode(HellionStrings.Retention_Tree_Heading))
{
if (tree.Success)
{
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
foreach (var (heading, types) in Groups)
{
using var subTree = ImRaii.TreeNode(heading());
if (!subTree.Success)
continue;
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
foreach (var type in types)
{
var hasOverride = Mutable.RetentionPerChannelDays.TryGetValue(type, out var days);
var hasSpecDefault = PrivacyDefaults.DefaultRetentionDays.TryGetValue(type, out var specDays);
if (!hasOverride)
days = hasSpecDefault ? specDays : Mutable.RetentionDefaultDays;
var tag = hasOverride
? HellionStrings.Retention_Tag_Override
: hasSpecDefault
? HellionStrings.Retention_Tag_Spec
: HellionStrings.Retention_Tag_Global;
if (ImGui.InputInt($"{type} {tag}##retention-{(int)type}", ref days))
{
days = Math.Max(0, days);
Mutable.RetentionPerChannelDays[type] = days;
}
if (hasOverride)
{
ImGui.SameLine();
if (ImGui.Button($"{HellionStrings.Retention_Reset_Button}##retention-reset-{(int)type}"))
Mutable.RetentionPerChannelDays.Remove(type);
}
}
}
}
}
ImGui.Spacing();
ImGuiUtil.HelpText(HellionStrings.Retention_Help_SavedNote);
ImGui.Spacing();
using (ImRaii.Disabled(RetentionRunning))
{
if (ImGuiUtil.CtrlShiftButton(HellionStrings.Retention_Apply_Label, HellionStrings.Retention_Apply_Tooltip))
StartRetentionRun();
}
if (RetentionRunning)
ImGuiUtil.HelpText(HellionStrings.Retention_Running);
ImGui.Spacing();
var lastRun = Plugin.Config.RetentionLastRunAt;
ImGuiUtil.HelpText(lastRun == DateTimeOffset.MinValue
? HellionStrings.Retention_LastRun_Never
: string.Format(HellionStrings.Retention_LastRun_At, lastRun.ToLocalTime()));
}
}
}
private void StartRetentionRun()
{
// Take the shared retention lock so we cannot fight the auto-sweep
// for the database connection. If the auto-sweep is already in
// flight we just bail — the user can press the button again once
// it finishes.
lock (Plugin.RetentionSweepLock)
{
if (Plugin.RetentionSweepRunning)
return;
Plugin.RetentionSweepRunning = true;
}
var policy = Plugin.Config.RetentionPerChannelDays.ToDictionary(p => (int)(ushort)p.Key, p => p.Value);
var defaultDays = Plugin.Config.RetentionDefaultDays;
new Thread(() =>
{
try
{
var deleted = Plugin.MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays);
Plugin.Config.RetentionLastRunAt = DateTimeOffset.UtcNow;
Plugin.SaveConfig();
Plugin.Log.Information($"Manual retention run deleted {deleted} expired messages.");
if (deleted > 0)
{
Plugin.Framework.Run(() =>
{
Plugin.MessageManager.ClearAllTabs();
Plugin.MessageManager.FilterAllTabsAsync();
}).Wait();
}
WrapperUtil.AddNotification(string.Format(HellionStrings.Retention_Success, deleted), NotificationType.Success);
}
catch (Exception e)
{
Plugin.Log.Error(e, "Manual retention run failed");
WrapperUtil.AddNotification(HellionStrings.Retention_Error, NotificationType.Error);
}
finally
{
lock (Plugin.RetentionSweepLock)
Plugin.RetentionSweepRunning = false;
}
}) { IsBackground = true }.Start();
}
private void DrawCleanupSection()
{
ImGui.TextUnformatted(HellionStrings.Cleanup_Heading);
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGuiUtil.HelpText(HellionStrings.Cleanup_Help_Intro);
ImGuiUtil.HelpText(HellionStrings.Cleanup_Help_SavedNote);
ImGui.Spacing();
// Drift-detection between the snapshot taken at last refresh
// and the current Mutable whitelist. Cleanup itself runs on
// the SAVED policy (Cleanup_Help_SavedNote covers that), but
// the user usually expects "the preview reflects what I just
// ticked" — so we surface the divergence instead of silently
// showing stale numbers.
if (CleanupPreviewSnapshot is not null
&& !CleanupPreviewSnapshot.SetEquals(Mutable.PrivacyPersistChannels))
{
CleanupPreviewStale = true;
}
using (var emphasis = CleanupPreviewStale
? ImRaii.PushColor(ImGuiCol.Button, ImGuiColors.HealerGreen with { W = 0.6f })
: null)
using (ImRaii.Disabled(CleanupRunning))
{
if (ImGui.Button(HellionStrings.Cleanup_RefreshPreview))
RefreshCleanupPreview();
}
if (CleanupCounts is null)
{
ImGuiUtil.HelpText(HellionStrings.Cleanup_NoPreview);
return;
}
if (CleanupPreviewStale)
{
ImGui.Spacing();
ImGuiUtil.HelpText(HellionStrings.Cleanup_Preview_Stale);
}
ImGui.Spacing();
using (var staleColor = CleanupPreviewStale
? ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudGrey)
: null)
{
ImGuiUtil.HelpText(string.Format(HellionStrings.Cleanup_TotalStored, CleanupKeepCount + CleanupDeleteCount));
ImGuiUtil.HelpText(string.Format(HellionStrings.Cleanup_WillKeep, CleanupKeepCount));
ImGuiUtil.HelpText(string.Format(HellionStrings.Cleanup_WillDelete, CleanupDeleteCount));
}
using (var tree = ImRaii.TreeNode(HellionStrings.Cleanup_Breakdown))
{
if (tree.Success)
{
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
foreach (var (chatType, count) in CleanupCounts.OrderByDescending(p => p.Value))
{
var name = Enum.IsDefined(typeof(ChatType), (ushort)chatType)
? ((ChatType)(ushort)chatType).ToString()
: $"Unknown({chatType})";
var keeps = WouldBeKept(chatType);
var marker = keeps ? HellionStrings.Cleanup_Marker_Keep : HellionStrings.Cleanup_Marker_Delete;
ImGuiUtil.HelpText($"{marker} {name} — {count:N0}");
}
}
}
ImGui.Spacing();
using (ImRaii.Disabled(CleanupRunning || CleanupDeleteCount == 0))
{
if (ImGuiUtil.CtrlShiftButton(HellionStrings.Cleanup_Apply_Label,
string.Format(HellionStrings.Cleanup_Apply_Tooltip, CleanupDeleteCount)))
StartCleanup();
}
if (CleanupRunning)
ImGuiUtil.HelpText(HellionStrings.Cleanup_Running);
}
}
private bool WouldBeKept(int chatType)
{
if (!Plugin.Config.PrivacyFilterEnabled)
return true;
if (Plugin.Config.PrivacyPersistChannels.Contains((ChatType)(ushort)chatType))
return true;
return Plugin.Config.PrivacyPersistUnknownChannels;
}
private void RefreshCleanupPreview()
{
try
{
CleanupCounts = Plugin.MessageManager.Store.GetMessageCountsByChatType();
CleanupKeepCount = 0;
CleanupDeleteCount = 0;
foreach (var (chatType, count) in CleanupCounts)
{
if (WouldBeKept(chatType))
CleanupKeepCount += count;
else
CleanupDeleteCount += count;
}
// Snapshot the whitelist as it stood at preview-time so the
// render pass can flag the user about subsequent edits. Only
// updated on success — if the preview throws, the previous
// snapshot stays in place so stale-detection keeps working.
CleanupPreviewSnapshot = new HashSet<ChatType>(Mutable.PrivacyPersistChannels);
CleanupPreviewStale = false;
}
catch (Exception e)
{
Plugin.Log.Error(e, "Failed to compute cleanup preview");
WrapperUtil.AddNotification(HellionStrings.Cleanup_PreviewError, NotificationType.Error);
}
}
private void StartCleanup()
{
if (CleanupRunning)
return;
CleanupRunning = true;
var allowed = Plugin.Config.PrivacyPersistChannels.Select(t => (int)(ushort)t).ToList();
new Thread(() =>
{
try
{
var deleted = Plugin.MessageManager.Store.CleanupRetainOnly(allowed);
Plugin.Log.Information($"Privacy cleanup: deleted {deleted} messages");
Plugin.Framework.Run(() =>
{
Plugin.MessageManager.ClearAllTabs();
Plugin.MessageManager.FilterAllTabsAsync();
}).Wait();
WrapperUtil.AddNotification(string.Format(HellionStrings.Cleanup_Success, deleted), NotificationType.Success);
}
catch (Exception e)
{
Plugin.Log.Error(e, "Privacy cleanup failed");
WrapperUtil.AddNotification(HellionStrings.Cleanup_Error, NotificationType.Error);
}
finally
{
CleanupRunning = false;
CleanupCounts = null;
}
}).Start();
}
}
+233
View File
@@ -0,0 +1,233 @@
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud.Interface;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Bindings.ImGui;
using Dalamud.Game.ClientState.Objects.SubKinds;
namespace HellionChat.Ui.SettingsTabs;
internal sealed class Tabs : ISettingsTab
{
private Plugin Plugin { get; }
private Configuration Mutable { get; }
public string Name => HellionStrings.Settings_Tab_Tabs + "###tabs-tabs";
private int ToOpen = -2;
internal Tabs(Plugin plugin, Configuration mutable)
{
Plugin = plugin;
Mutable = mutable;
}
public void Draw(bool changed)
{
const string addTabPopup = "add-tab-popup";
ImGuiUtil.HelpText(HellionStrings.Tabs_Presets_Linkshell_Hint);
ImGui.Spacing();
if (ImGuiUtil.IconButton(FontAwesomeIcon.Plus, tooltip: Language.Options_Tabs_Add))
ImGui.OpenPopup(addTabPopup);
using (var popup = ImRaii.Popup(addTabPopup))
{
if (popup)
{
if (ImGui.Selectable(Language.Options_Tabs_NewTab))
Mutable.Tabs.Add(new Tab());
ImGui.Separator();
if (ImGui.Selectable(string.Format(Language.Options_Tabs_Preset, Language.Tabs_Presets_General)))
Mutable.Tabs.Add(TabsUtil.VanillaGeneral);
if (ImGui.Selectable(string.Format(Language.Options_Tabs_Preset, Language.Tabs_Presets_Event)))
Mutable.Tabs.Add(TabsUtil.VanillaEvent);
if (ImGui.Selectable(string.Format(Language.Options_Tabs_Preset, Language.Tabs_Presets_Tell)))
Mutable.Tabs.Add(TabsUtil.VanillaTellExclusive);
}
}
var toRemove = -1;
var doOpens = ToOpen > -2;
for (var i = 0; i < Mutable.Tabs.Count; i++)
{
var tab = Mutable.Tabs[i];
if (doOpens)
ImGui.SetNextItemOpen(i == ToOpen);
using var treeNode = ImRaii.TreeNode($"{tab.Name}###tab-{i}");
if (!treeNode.Success)
continue;
using var pushedId = ImRaii.PushId($"tab-{i}");
if (ImGuiUtil.IconButton(FontAwesomeIcon.TrashAlt, tooltip: Language.Options_Tabs_Delete))
{
toRemove = i;
ToOpen = -1;
}
ImGui.SameLine();
if (ImGuiUtil.IconButton(FontAwesomeIcon.ArrowUp, tooltip: Language.Options_Tabs_MoveUp) && i > 0)
{
(Mutable.Tabs[i - 1], Mutable.Tabs[i]) = (Mutable.Tabs[i], Mutable.Tabs[i - 1]);
ToOpen = i - 1;
}
ImGui.SameLine();
if (ImGuiUtil.IconButton(FontAwesomeIcon.ArrowDown, tooltip: Language.Options_Tabs_MoveDown) && i < Mutable.Tabs.Count - 1)
{
(Mutable.Tabs[i + 1], Mutable.Tabs[i]) = (Mutable.Tabs[i], Mutable.Tabs[i + 1]);
ToOpen = i + 1;
}
ImGui.InputText(Language.Options_Tabs_Name, ref tab.Name, 512, ImGuiInputTextFlags.EnterReturnsTrue);
ImGui.Checkbox(Language.Options_Tabs_ShowTimestamps, ref tab.DisplayTimestamp);
ImGui.Checkbox(Language.Options_Tabs_PopOut, ref tab.PopOut);
if (tab.PopOut)
{
using var _ = ImRaii.PushIndent(10.0f);
ImGui.Checkbox(Language.Options_Tabs_IndependentOpacity, ref tab.IndependentOpacity);
if (tab.IndependentOpacity)
ImGuiUtil.DragFloatVertical(Language.Options_Tabs_Opacity, ref tab.Opacity, 0.25f, 0f, 100f, $"{tab.Opacity:N2}%%", ImGuiSliderFlags.AlwaysClamp);
ImGui.Checkbox(Language.Options_Tabs_IndependentHide, ref tab.IndependentHide);
if (tab.IndependentHide)
{
using var __ = ImRaii.PushIndent(10.0f);
ImGuiUtil.OptionCheckbox(ref tab.HideDuringCutscenes, Language.Options_HideDuringCutscenes_Name);
ImGui.Spacing();
ImGuiUtil.OptionCheckbox(ref tab.HideWhenNotLoggedIn, Language.Options_HideWhenNotLoggedIn_Name);
ImGui.Spacing();
ImGuiUtil.OptionCheckbox(ref tab.HideWhenUiHidden, Language.Options_HideWhenUiHidden_Name);
ImGui.Spacing();
ImGuiUtil.OptionCheckbox(ref tab.HideInLoadingScreens, Language.Options_HideInLoadingScreens_Name);
ImGui.Spacing();
ImGuiUtil.OptionCheckbox(ref tab.HideInBattle, Language.Options_HideInBattle_Name);
ImGui.Spacing();
}
ImGuiUtil.OptionCheckbox(ref tab.CanMove, Language.Popout_CanMove_Name);
ImGui.Spacing();
ImGuiUtil.OptionCheckbox(ref tab.CanResize, Language.Popout_CanResize_Name);
ImGui.Spacing();
}
using (var combo = ImGuiUtil.BeginComboVertical(Language.Options_Tabs_UnreadMode, tab.UnreadMode.Name()))
{
if (combo.Success)
{
foreach (var mode in Enum.GetValues<UnreadMode>())
{
if (ImGui.Selectable(mode.Name(), tab.UnreadMode == mode))
tab.UnreadMode = mode;
if (mode.Tooltip() is { } tooltip && ImGui.IsItemHovered())
ImGuiUtil.Tooltip(tooltip);
}
}
}
if (Mutable.HideWhenInactive)
ImGui.Checkbox(Language.Options_Tabs_InactivityBehaviour, ref tab.UnhideOnActivity);
ImGui.Checkbox(Language.Options_Tabs_NoInput, ref tab.InputDisabled);
if (!tab.InputDisabled)
{
var input = tab.Channel?.ToChatType().Name() ?? Language.Options_Tabs_NoInputChannel;
using (var combo = ImGuiUtil.BeginComboVertical(Language.Options_Tabs_InputChannel, input))
{
if (combo.Success)
{
if (ImGui.Selectable(Language.Options_Tabs_NoInputChannel, tab.Channel == null))
tab.Channel = null;
foreach (var channel in Enum.GetValues<InputChannel>())
if (ImGui.Selectable(channel.ToChatType().Name(), tab.Channel == channel))
tab.Channel = channel;
}
}
var player = Plugin.ObjectTable.LocalPlayer;
if (tab.Channel == InputChannel.Tell && player != null)
{
ImGui.Checkbox(Language.Options_Tabs_SenderMessages, ref tab.AllSenderMessages);
ImGuiUtil.HelpText(Language.Options_Help_SenderMessages);
var worlds = Sheets.WorldsOnDatacenter(player).OrderByDescending(world => world.DataCenter.RowId).ThenBy(world => world.Name.ToString()).ToList();
using (ImRaii.ItemWidth(ImGui.GetWindowWidth() / 3f))
{
ImGui.Text(Language.Options_Header_Target);
ImGui.SameLine();
var name = tab.TellTarget.Name;
if (ImGui.InputText("##targetInput", ref name, 21))
tab.TellTarget.Name = name;
ImGui.SameLine();
var selectedWorld = worlds.FindIndex(world => world.RowId == tab.TellTarget.World);
if (selectedWorld == -1)
selectedWorld = 0;
using (var combo = ImRaii.Combo("###player-world", worlds[selectedWorld].Name.ToString()))
{
if (combo.Success)
{
var lastDc = worlds.First().DataCenter.RowId;
foreach (var (idx, world) in worlds.Index())
{
if (ImGui.Selectable(world.Name.ToString(), selectedWorld == idx))
{
selectedWorld = idx;
tab.TellTarget.World = worlds[selectedWorld].RowId;
}
if (lastDc == world.DataCenter.RowId)
continue;
lastDc = world.DataCenter.RowId;
ImGui.Separator();
}
}
}
}
var target = (Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target) as IPlayerCharacter;
using (ImRaii.Disabled(target == null))
{
if (ImGui.Button("Set to target") && target != null)
tab.TellTarget.FromTarget(target);
}
}
}
ImGuiUtil.ChannelSelector(Language.Options_Tabs_Channels, tab.SelectedChannels);
ImGuiUtil.ExtraChatSelector(Language.Options_Tabs_ExtraChatChannels, ref tab.ExtraChatAll, tab.ExtraChatChannels);
}
if (toRemove > -1)
{
Mutable.Tabs.RemoveAt(toRemove);
Plugin.WantedTab = 0;
}
if (doOpens)
ToOpen = -2;
}
}
+167
View File
@@ -0,0 +1,167 @@
using HellionChat.Resources;
using HellionChat.Util;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Bindings.ImGui;
namespace HellionChat.Ui.SettingsTabs;
internal sealed class Window : ISettingsTab
{
private Plugin Plugin { get; }
private Configuration Mutable { get; }
public string Name => HellionStrings.Settings_Tab_Window + "###tabs-window";
internal Window(Plugin plugin, Configuration mutable)
{
Plugin = plugin;
Mutable = mutable;
}
public void Draw(bool changed)
{
DrawHideSection();
ImGui.Spacing();
DrawInactivityHideSection();
ImGui.Spacing();
DrawFrameSection();
ImGui.Spacing();
DrawTooltipsSection();
}
private void DrawHideSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Window_Hide_Heading);
if (!tree.Success)
{
return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGui.Checkbox(Language.Options_HideChat_Name, ref Mutable.HideChat);
ImGuiUtil.HelpMarker(Language.Options_HideChat_Description);
ImGui.Checkbox(Language.Options_HideDuringCutscenes_Name, ref Mutable.HideDuringCutscenes);
ImGuiUtil.HelpMarker(string.Format(Language.Options_HideDuringCutscenes_Description, Plugin.PluginName));
ImGui.Checkbox(Language.Options_HideWhenNotLoggedIn_Name, ref Mutable.HideWhenNotLoggedIn);
ImGuiUtil.HelpMarker(string.Format(Language.Options_HideWhenNotLoggedIn_Description, Plugin.PluginName));
ImGui.Checkbox(Language.Options_HideWhenUiHidden_Name, ref Mutable.HideWhenUiHidden);
ImGuiUtil.HelpMarker(string.Format(Language.Options_HideWhenUiHidden_Description, Plugin.PluginName));
ImGui.Checkbox(Language.Options_HideInLoadingScreens_Name, ref Mutable.HideInLoadingScreens);
ImGuiUtil.HelpMarker(string.Format(Language.Options_HideInLoadingScreens_Description, Plugin.PluginName));
ImGui.Checkbox(Language.Options_HideInBattle_Name, ref Mutable.HideInBattle);
ImGuiUtil.HelpMarker(Language.Options_HideInBattle_Description);
}
}
private void DrawInactivityHideSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Window_InactivityHide_Heading);
if (!tree.Success)
{
return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGui.Checkbox(Language.Options_HideWhenInactive_Name, ref Mutable.HideWhenInactive);
ImGuiUtil.HelpMarker(Language.Options_HideWhenInactive_Description);
if (!Mutable.HideWhenInactive)
{
return;
}
ImGuiUtil.InputIntVertical(Language.Options_InactivityHideTimeout_Name, Language.Options_InactivityHideTimeout_Description, ref Mutable.InactivityHideTimeout, 1, 10);
// Untergrenze von 2 Sekunden gegen Selbst-Soft-Lock.
Mutable.InactivityHideTimeout = Math.Max(2, Mutable.InactivityHideTimeout);
using (ImRaii.Disabled(Mutable.HideInBattle))
{
ImGui.Checkbox(Language.Options_InactivityHideActiveDuringBattle_Name, ref Mutable.InactivityHideActiveDuringBattle);
ImGuiUtil.HelpMarker(Language.Options_InactivityHideActiveDuringBattle_Description);
}
using var channelTree = ImRaii.TreeNode(Language.Options_InactivityHideChannels_Name);
if (!channelTree.Success)
{
return;
}
if (ImGuiUtil.CtrlShiftButton(Language.Options_InactivityHideChannels_All_Label, Language.Options_InactivityHideChannels_Button_Tooltip))
{
Mutable.InactivityHideChannelsV2 = TabsUtil.AllChannels();
Mutable.InactivityHideExtraChatAll = true;
Mutable.InactivityHideExtraChatChannels = [];
}
ImGui.SameLine();
if (ImGuiUtil.CtrlShiftButton(Language.Options_InactivityHideChannels_None_Label, Language.Options_InactivityHideChannels_Button_Tooltip))
{
Mutable.InactivityHideChannelsV2 = [];
Mutable.InactivityHideExtraChatAll = false;
Mutable.InactivityHideExtraChatChannels = [];
}
ImGui.Spacing();
ImGuiUtil.ChannelSelector(Language.Options_Tabs_Channels, Mutable.InactivityHideChannelsV2);
ImGuiUtil.ExtraChatSelector(Language.Options_Tabs_ExtraChatChannels, ref Mutable.InactivityHideExtraChatAll, Mutable.InactivityHideExtraChatChannels);
}
}
private void DrawFrameSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Window_Frame_Heading);
if (!tree.Success)
{
return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGui.Checkbox(Language.Options_CanMove_Name, ref Mutable.CanMove);
ImGui.Checkbox(Language.Options_CanResize_Name, ref Mutable.CanResize);
ImGui.Checkbox(Language.Options_ShowTitleBar_Name, ref Mutable.ShowTitleBar);
ImGui.Checkbox(Language.Options_ShowPopOutTitleBar_Name, ref Mutable.ShowPopOutTitleBar);
// v0.6.0 — global master switch for the pop-out input bar.
ImGui.Checkbox(HellionStrings.Settings_Window_PopOutInputEnabled_Name, ref Mutable.PopOutInputEnabled);
ImGuiUtil.HelpMarker(HellionStrings.Settings_Window_PopOutInputEnabled_Description);
ImGui.Checkbox(Language.Options_ShowHideButton_Name, ref Mutable.ShowHideButton);
ImGuiUtil.HelpMarker(Language.Options_ShowHideButton_Description);
ImGui.Checkbox(Language.Options_SidebarTabView_Name, ref Mutable.SidebarTabView);
ImGuiUtil.HelpMarker(string.Format(Language.Options_SidebarTabView_Description, Plugin.PluginName));
}
}
private void DrawTooltipsSection()
{
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Window_Tooltips_Heading);
if (!tree.Success)
{
return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{
ImGui.Checkbox(Language.Options_NativeItemTooltips_Name, ref Mutable.NativeItemTooltips);
ImGuiUtil.HelpMarker(string.Format(Language.Options_NativeItemTooltips_Description, Plugin.PluginName));
if (Mutable.NativeItemTooltips)
{
ImGuiUtil.DragFloatVertical(Language.Options_TooltipOffset_Name, Language.Options_TooltipOffset_Desc, ref Mutable.TooltipOffset, 1, 0f, 400f, $"{Mutable.TooltipOffset:N0}px", ImGuiSliderFlags.AlwaysClamp);
}
}
}
}
+345
View File
@@ -0,0 +1,345 @@
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using Dalamud.Game;
using Dalamud.Utility;
using Lumina.Excel;
using Lumina.Text.Payloads;
using Lumina.Text.ReadOnly;
using Pidgin;
using static Pidgin.Parser;
using static Pidgin.Parser<char>;
namespace HellionChat.Util;
internal static class AutoTranslate
{
private static readonly Dictionary<ClientLanguage, List<AutoTranslateEntry>> Entries = new();
private static readonly HashSet<(uint, uint)> ValidEntries = [];
private static Parser<char, (string name, Maybe<IEnumerable<ISelectorPart>> selector)> Parser()
{
var sheetName = Any
.AtLeastOnceUntil(Lookahead(Char('[').IgnoreResult().Or(End)))
.Select(string.Concat)
.Labelled("sheetName");
var numPair = Map(ISelectorPart (first, second) =>
new IndexRange(uint.Parse(string.Concat(first)), uint.Parse(string.Concat(second))),
Digit.AtLeastOnce().Before(Char('-')),
Digit.AtLeastOnce())
.Labelled("numPair");
var singleRow = Digit
.AtLeastOnce()
.Select(string.Concat)
.Select(ISelectorPart (num) => new SingleRow(uint.Parse(num)));
var column = String("col-")
.Then(Digit.AtLeastOnce())
.Select(string.Concat)
.Select(ISelectorPart (num) => new ColumnSpecifier(uint.Parse(num)));
var noun = String("noun")
.Select(ISelectorPart (_) => new NounMarker());
var selectorItems = OneOf(Try(numPair), singleRow, column, noun)
.Separated(Char(','))
.Labelled("selectorItems");
var selector = selectorItems
.Between(Char('['), Char(']'))
.Labelled("selector");
return Map((name, sel) => (name, sel), sheetName, selector.Optional());
}
/// <summary>
/// Preloads auto-translate entries into the cache for the current game
/// language. Without this, the first message will take a long time to send
/// (which causes a hitch in the main thread).
///
/// This spawns a new thread.
/// </summary>
internal static void PreloadCache()
{
new Thread(() =>
{
var sw = Stopwatch.StartNew();
AllEntries();
Plugin.Log.Debug($"Warming up auto-translate took {sw.ElapsedMilliseconds}ms");
}).Start();
}
private static List<AutoTranslateEntry> AllEntries()
{
if (Entries.TryGetValue(Plugin.DataManager.Language, out var entries))
return entries;
var shouldAdd = ValidEntries.Count == 0;
var parser = Parser();
var list = new List<AutoTranslateEntry>();
foreach (var row in Sheets.CompletionSheet)
{
var lookup = string.Concat(row.LookupTable.Select(p => p.Type == ReadOnlySePayloadType.Text ? Encoding.UTF8.GetString(p.Body.Span) : p.MacroCode == MacroCode.Num && p.TryGetExpression(out var num) && num.TryGetInt(out var val) ? val.ToString(CultureInfo.InvariantCulture) : ",,,unexpected macro code,,,"));
try
{
if (lookup is not ("" or "@"))
{
// SE added whitespace to the newest additions, but ParseOrThrow doesn't see them as valid
lookup = lookup.Replace(" ", "");
var (sheetName, selector) = parser.ParseOrThrow(lookup);
var sheet = Plugin.DataManager.Excel.GetSheet<RawRow>(name: sheetName);
var columns = new List<int>();
var rows = new List<Range>();
if (selector.HasValue)
{
columns.Clear();
rows.Clear();
foreach (var part in selector.Value)
{
switch (part)
{
case IndexRange range:
{
var start = (int)range.Start;
var end = (int)(range.End + 1);
rows.Add(start..end);
break;
}
case SingleRow single:
{
var idx = (int)single.Row;
rows.Add(idx..(idx + 1));
break;
}
case ColumnSpecifier col:
columns.Add((int)col.Column);
break;
}
}
}
if (columns.Count == 0)
columns.Add(0);
if (rows.Count == 0)
// We can't use an "index from end" (like `^0`) here because
// we're iterating over integers, not an array directly.
// Previously, we were setting `0..^0` which caused these
// sheets to be completely skipped due to this bug.
// See below.
rows.Add(..Index.FromStart((int)sheet.GetRowAt(sheet.Count - 1).RowId + 1));
foreach (var range in rows)
{
// We iterate over the range by numerical values here, so
// we can't use an "index from end" otherwise nothing will
// happen.
// See above.
for (var i = range.Start.Value; i < range.End.Value; i++)
{
if (!sheet.TryGetRow((uint)i, out var rowParser))
continue;
foreach (var col in columns)
{
var rawName = rowParser.ReadStringColumn(col);
if (!rawName.IsEmpty)
{
list.Add(new AutoTranslateEntry(row.Group, (uint)i, rawName.ToString(), string.Empty));
if (shouldAdd)
ValidEntries.Add((row.Group, (uint)i));
}
}
}
}
}
else if (lookup is not "@")
{
if (row.Text.IsEmpty)
continue;
list.Add(new AutoTranslateEntry(row.Group, row.RowId, row.Text.ToString(), row.GroupTitle.ToString()));
if (shouldAdd)
ValidEntries.Add((row.Group, row.RowId));
}
}
catch (Exception ex)
{
Plugin.Log.Error(ex, $"failed to translate: {lookup}");
}
}
Entries[Plugin.DataManager.Language] = list;
return list;
}
internal static List<AutoTranslateEntry> Matching(string prefix, bool sort)
{
var wholeMatches = new List<AutoTranslateEntry>();
var prefixMatches = new List<AutoTranslateEntry>();
var otherMatches = new List<AutoTranslateEntry>();
foreach (var entry in AllEntries())
{
if (entry.Text.Equals(prefix, StringComparison.OrdinalIgnoreCase))
{
wholeMatches.Add(entry);
}
else if (entry.Text.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
prefixMatches.Add(entry);
}
else if (entry.Text.Contains(prefix, StringComparison.OrdinalIgnoreCase))
{
otherMatches.Add(entry);
}
else if (entry.Title.Length > 0)
{
if (entry.Title.Equals(prefix, StringComparison.OrdinalIgnoreCase))
wholeMatches.Add(entry);
else if (entry.Title.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
prefixMatches.Add(entry);
else if (entry.Title.Contains(prefix, StringComparison.OrdinalIgnoreCase))
otherMatches.Add(entry);
}
}
if (sort)
{
return wholeMatches.OrderBy(entry => entry.Text, StringComparer.OrdinalIgnoreCase)
.Concat(prefixMatches.OrderBy(entry => entry.Text, StringComparer.OrdinalIgnoreCase))
.Concat(otherMatches.OrderBy(entry => entry.Text, StringComparer.OrdinalIgnoreCase))
.ToList();
}
return wholeMatches
.Concat(prefixMatches)
.Concat(otherMatches)
.ToList();
}
[DllImport("msvcrt.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern int memcmp(byte[] b1, byte[] b2, nuint count);
internal static void ReplaceWithPayload(ref byte[] bytes)
{
var search = "<at:"u8.ToArray();
if (bytes.Length <= search.Length)
return;
// populate the list of valid entries
if (ValidEntries.Count == 0)
AllEntries();
var start = -1;
for (var i = 0; i < bytes.Length; i++)
{
if (start != -1)
{
if (bytes[i] != '>')
continue;
var tag = Encoding.UTF8.GetString(bytes[start..(i + 1)]);
var parts = tag[4..^1].Split(',', 2);
if (parts.Length == 2 && uint.TryParse(parts[0], out var group) && uint.TryParse(parts[1], out var key))
{
var payload = ValidEntries.Contains((group, key)) ? CreateFixedTranslation(group, key) : [];
var oldBytes = bytes.ToArray();
var lengthDiff = payload.Length - (i - start);
bytes = new byte[oldBytes.Length + lengthDiff];
Array.Copy(oldBytes, bytes, start);
Array.Copy(payload, 0, bytes, start, payload.Length);
Array.Copy(oldBytes, i + 1, bytes, start + payload.Length, oldBytes.Length - (i + 1));
i += lengthDiff;
}
start = -1;
}
if (i + search.Length < bytes.Length && memcmp(bytes[i..], search, (nuint) search.Length) == 0)
start = i;
}
}
public static bool StartsWithCommand(ref byte[] bytes)
{
var search = "<at:"u8;
if (bytes.Length <= search.Length)
return false;
// populate the list of valid entries
if (ValidEntries.Count == 0)
AllEntries();
for (var i = 0; i < search.Length; i++)
{
if (bytes[i] != search[i])
return false;
}
for (var i = 0; i < bytes.Length; i++)
{
if (bytes[i] != '>')
continue;
var tag = Encoding.UTF8.GetString(bytes[..(i + 1)]);
var parts = tag[4..^1].Split(',', 2);
if (parts.Length == 2 && uint.TryParse(parts[0], out var group) && uint.TryParse(parts[1], out var key))
{
if (!ValidEntries.Contains((group, key)))
return false;
var evaluated = Plugin.Evaluator.Evaluate(new ReadOnlySeString(CreateFixedTranslation(group, key))).ToString();
if (!evaluated.StartsWith('/'))
return false;
bytes = Encoding.UTF8.GetBytes(evaluated);
return true;
}
}
return false;
}
private static byte[] CreateFixedTranslation(uint group, uint key)
{
using var rssb = new RentedSeStringBuilder();
return rssb.Builder
.BeginMacro(MacroCode.Fixed)
.AppendUIntExpression(group - 1)
.AppendUIntExpression(key)
.EndMacro()
.ToArray();
}
}
internal interface ISelectorPart { }
internal class SingleRow(uint row) : ISelectorPart
{
public uint Row { get; } = row;
}
internal class IndexRange(uint start, uint end) : ISelectorPart
{
public uint Start { get; } = start;
public uint End { get; } = end;
}
internal class NounMarker : ISelectorPart { }
internal class ColumnSpecifier(uint column) : ISelectorPart
{
public uint Column { get; } = column;
}
internal class AutoTranslateEntry(uint group, uint row, string str, string title)
{
internal uint Group { get; } = group;
internal uint Row { get; } = row;
internal string Text { get; } = str;
internal string Title { get; } = title;
}
+513
View File
@@ -0,0 +1,513 @@
using HellionChat.Code;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using System.Text;
using Lumina.Text.Payloads;
using PayloadType = Dalamud.Game.Text.SeStringHandling.PayloadType;
namespace HellionChat.Util;
internal static class ChunkUtil
{
// internal static IEnumerable<Chunk> ToChunks(ReadOnlySeString msg, ChunkSource source, ChatType? defaultColour)
// {
// var chunks = new List<Chunk>();
//
// var italic = false;
// var foreground = new Stack<uint>();
// var glow = new Stack<uint>();
// Payload? link = null;
//
// void Append(string text)
// {
// chunks.Add(new TextChunk(source, link, text)
// {
// FallbackColour = defaultColour,
// Foreground = foreground.Count > 0 ? foreground.Peek() : null,
// Glow = glow.Count > 0 ? glow.Peek() : null,
// Italic = italic,
// });
// }
//
// foreach (var payload in msg)
// {
// if (payload.Type == ReadOnlySePayloadType.Text)
// {
// // We don't want to parse any null string
// var str = payload.ToString();
// var nulIndex = str.IndexOf('\0');
// if (nulIndex > 0)
// str = str[..nulIndex];
// if (string.IsNullOrEmpty(str))
// continue;
//
// Append(str);
// continue;
// }
//
// switch (payload.MacroCode)
// {
// case MacroCode.Italic:
// var newStatus = payload.TryGetExpression(out var expression) && expression.TryGetUInt(out var value) && value == 1;
// italic = newStatus;
// break;
// case MacroCode.Color:
// if (payload.TryGetExpression(out var eColor))
// {
// if (eColor.TryGetPlaceholderExpression(out var ph) && ph == (int)ExpressionType.StackColor)
// {
// if (foreground.Count > 0)
// foreground.Pop();
// }
// else if (TryResolveUInt(eColor, out var eColorVal))
// {
// var color = ColourUtil.ArgbToRgba(eColorVal);
//
// if (color > 0)
// foreground.Push(color);
// else if (foreground.Count > 0) // Push the previous color as we don't want invisible text
// foreground.Push(foreground.Peek());
// }
// }
// break;
// case MacroCode.EdgeColor:
// if (payload.TryGetExpression(out eColor))
// {
// if (eColor.TryGetPlaceholderExpression(out var ph) && ph == (int)ExpressionType.StackColor)
// {
// if (glow.Count > 0)
// glow.Pop();
// }
// else if (TryResolveUInt(eColor, out var eColorVal))
// {
// glow.Push(ColourUtil.ArgbToRgba(eColorVal));
// }
// }
// break;
// case MacroCode.ColorType:
// if (!payload.TryGetExpression(out var eColorType) || !eColorType.TryGetUInt(out var eColorTypeVal))
// {
// if (foreground.Count > 0)
// foreground.Pop();
// break;
// }
//
// if (eColorTypeVal == 0)
// {
// if (foreground.Count > 0)
// foreground.Pop();
// }
// else if (Sheets.UIColorSheet.TryGetRow(eColorTypeVal, out var row))
// {
// foreground.Push(row.Dark);
// }
// break;
// case MacroCode.EdgeColorType:
// if (!payload.TryGetExpression(out var eEdgeColor) || !eEdgeColor.TryGetUInt(out var eEdgeColorVal))
// {
// if (glow.Count > 0)
// glow.Pop();
// break;
// }
//
// if (eEdgeColorVal == 0)
// {
// if (glow.Count > 0)
// glow.Pop();
// }
// else if (Sheets.UIColorSheet.TryGetRow(eEdgeColorVal, out var row))
// {
// glow.Push(row.Dark);
// }
// break;
// case MacroCode.Fixed:
// if (!payload.TryGetExpression(out var expr1, out var expr2))
// break;
//
// if (expr1.TryGetUInt(out var group) && expr2.TryGetUInt(out var key))
// {
// chunks.Add(new IconChunk(source, null, BitmapFontIcon.AutoTranslateBegin));
// using var rssb = new RentedSeStringBuilder();
// var translatePayload = rssb.Builder
// .BeginMacro(MacroCode.Fixed)
// .AppendUIntExpression(group - 1)
// .AppendUIntExpression(key)
// .EndMacro()
// .ToReadOnlySeString();
//
// Append(Plugin.Evaluator.Evaluate(translatePayload).ToString());
// chunks.Add(new IconChunk(source, null, BitmapFontIcon.AutoTranslateEnd));
// }
// break;
// case MacroCode.Icon:
// if (payload.TryGetExpression(out var eIcon) && TryResolveInt(eIcon, out var iconVal))
// chunks.Add(new IconChunk(source, link, (BitmapFontIcon)iconVal));
// break;
// case MacroCode.Link:
// if (!payload.TryGetExpression(
// out var linkTypeExpr1,
// out var uintExpr2,
// out var intExpr3,
// out var intExpr4,
// out var strExpr5))
// break;
//
// if (!linkTypeExpr1.TryGetUInt(out var linkType))
// break;
//
// switch ((LinkMacroPayloadType)linkType)
// {
// case LinkMacroPayloadType.Terminator:
// link = null;
// break;
// case LinkMacroPayloadType.MapPosition:
// if (!uintExpr2.TryGetUInt(out var ids))
// break;
//
// if (!intExpr3.TryGetInt(out var rawX))
// break;
//
// if (!intExpr4.TryGetInt(out var rawY))
// break;
//
// var mapId = ids & 0xFF;
// var territoryId = (ids >> 16) & 0xFF;
// break;
// case (LinkMacroPayloadType)Payload.EmbeddedInfoType.DalamudLink - 1:
// if (!uintExpr2.TryGetUInt(out var commandId))
// break;
//
// if (!intExpr3.TryGetInt(out var extra1))
// break;
//
// if (!intExpr4.TryGetInt(out var extra2))
// break;
//
// if (!strExpr5.TryGetString(out var extraStr))
// break;
// break;
// case LinkMacroPayloadType.Quest:
// if (!uintExpr2.TryGetUInt(out var questId))
// break;
// break;
// case LinkMacroPayloadType.Status:
// if (!uintExpr2.TryGetUInt(out var statusId))
// break;
// break;
// case LinkMacroPayloadType.Item:
// if (!uintExpr2.TryGetUInt(out var itemId))
// break;
// break;
// case LinkMacroPayloadType.Character:
// if (!uintExpr2.TryGetUInt(out var flags))
// break;
//
// if (!intExpr3.TryGetUInt(out var worldId))
// break;
// break;
// case LinkMacroPayloadType.PartyFinder:
// if (!uintExpr2.TryGetUInt(out var listingId))
// break;
//
// // intExpr3 is unused
//
// if (!intExpr4.TryGetUInt(out worldId))
// break;
// break;
// case LinkMacroPayloadType.PartyFinderNotification:
// // no expr used
// break;
// case LinkMacroPayloadType.Achievement:
// if (!uintExpr2.TryGetUInt(out var achievementId))
// break;
// break;
// }
// break;
// case MacroCode.NonBreakingSpace:
// Append(" ");
// break;
// case PayloadType.Unknown:
// var rawPayload = (RawPayload)payload;
// else if (rawPayload.Data.Length > 1 && rawPayload.Data[1] == 0x14)
// {
// if (glow.Count > 0)
// {
// glow.Pop();
// }
// else if (rawPayload.Data.Length > 6 && rawPayload.Data[2] == 0x05 && rawPayload.Data[3] == 0xF6)
// {
// var (r, g, b) = (rawPayload.Data[4], rawPayload.Data[5], rawPayload.Data[6]);
// glow.Push(ColourUtil.ComponentsToRgba(r, g, b));
// }
// }
// break;
// }
// }
//
// return chunks;
// }
internal static IEnumerable<Chunk> ToChunks(SeString msg, ChunkSource source, ChatType? defaultColour)
{
var chunks = new List<Chunk>();
var italic = false;
var foreground = new Stack<uint>();
var glow = new Stack<uint>();
Payload? link = null;
void Append(string text)
{
chunks.Add(new TextChunk(source, link, text)
{
FallbackColour = defaultColour,
Foreground = foreground.Count > 0 ? foreground.Peek() : null,
Glow = glow.Count > 0 ? glow.Peek() : null,
Italic = italic,
});
}
foreach (var payload in msg.Payloads)
{
switch (payload.Type)
{
case PayloadType.EmphasisItalic:
var newStatus = ((EmphasisItalicPayload) payload).IsEnabled;
italic = newStatus;
break;
case PayloadType.UIForeground:
var foregroundPayload = (UIForegroundPayload) payload;
if (foregroundPayload.IsEnabled)
foreground.Push(foregroundPayload.UIColor.Value.Dark);
else if (foreground.Count > 0)
foreground.Pop();
break;
case PayloadType.UIGlow:
var glowPayload = (UIGlowPayload) payload;
if (glowPayload.IsEnabled)
glow.Push(glowPayload.UIColor.Value.Light);
else if (glow.Count > 0)
glow.Pop();
break;
case PayloadType.AutoTranslateText:
chunks.Add(new IconChunk(source, payload, BitmapFontIcon.AutoTranslateBegin));
var autoText = ((AutoTranslatePayload) payload).Text;
Append(autoText.Substring(2, autoText.Length - 4));
chunks.Add(new IconChunk(source, link, BitmapFontIcon.AutoTranslateEnd));
break;
case PayloadType.Icon:
chunks.Add(new IconChunk(source, link, ((IconPayload) payload).Icon));
break;
case PayloadType.MapLink:
case PayloadType.Quest:
case PayloadType.DalamudLink:
case PayloadType.Status:
case PayloadType.Item:
case PayloadType.Player:
link = payload;
break;
case PayloadType.PartyFinder:
link = payload;
break;
case PayloadType.Unknown:
var rawPayload = (RawPayload) payload;
var colorPayload = ColorPayload.From(rawPayload.Data);
if (colorPayload != null)
{
if (colorPayload.Enabled)
{
if (colorPayload.Color > 0)
foreground.Push(colorPayload.Color);
else if (foreground.Count > 0) // Push the previous color as we don't want invisible text
foreground.Push(foreground.Peek());
}
else if (foreground.Count > 0)
{
foreground.Pop();
}
}
else if (rawPayload.Data.Length > 1 && rawPayload.Data[1] == 0x14)
{
if (glow.Count > 0)
{
glow.Pop();
}
else if (rawPayload.Data.Length > 6 && rawPayload.Data[2] == 0x05 && rawPayload.Data[3] == 0xF6)
{
var (r, g, b) = (rawPayload.Data[4], rawPayload.Data[5], rawPayload.Data[6]);
glow.Push(ColourUtil.ComponentsToRgba(r, g, b));
}
}
else if (rawPayload.Data.Length > 7 && rawPayload.Data[1] == 0x27 && rawPayload.Data[3] == 0x0A)
{
// pf payload
var reader = new BinaryReader(new MemoryStream(rawPayload.Data[4..]));
var id = GetInteger(reader);
link = new PartyFinderPayload(id);
}
else if (rawPayload.Data.Length > 5 && rawPayload.Data[1] == 0x27 && rawPayload.Data[3] == 0x06)
{
// achievement payload
var reader = new BinaryReader(new MemoryStream(rawPayload.Data[4..]));
var id = GetInteger(reader);
link = new AchievementPayload(id);
}
else if (rawPayload.Data is [_, (byte)MacroCode.NonBreakingSpace, _, _])
{
// NonBreakingSpace payload
Append(" ");
}
// NOTE: no URIPayload because it originates solely from
// new Message(). The game doesn't have a URI payload type.
else if (Equals(rawPayload, RawPayload.LinkTerminator))
{
link = null;
}
break;
default:
if (payload is ITextProvider textProvider)
{
// We don't want to parse any null string
var str = textProvider.Text;
var nulIndex = str.IndexOf('\0');
if (nulIndex > 0)
str = str[..nulIndex];
if (string.IsNullOrEmpty(str))
break;
Append(str);
}
break;
}
}
return chunks;
}
internal static string ToRawString(List<Chunk> chunks)
{
if (chunks.Count == 0)
return string.Empty;
var builder = new StringBuilder();
foreach (var chunk in chunks)
if (chunk is TextChunk text)
builder.Append(text.Content);
return builder.ToString();
}
// Hellion Chat — shared helper for Auto-Tell-Tabs and the MessageStore
// history-preload query. Walks the chunk list once and returns the
// first PlayerPayload it finds, or null when the message has no
// resolved player link (e.g. system messages, GM tells we already
// skipped earlier in the pipeline).
internal static PlayerPayload? TryGetPlayerPayload(IReadOnlyList<Chunk> chunks)
{
foreach (var chunk in chunks)
{
if (chunk.Link is PlayerPayload pp)
{
return pp;
}
}
return null;
}
// Fallback for tells where the PlayerPayload lives in the raw SeString
// payload list rather than on a chunk's Link slot. Same semantics as
// the chunk-walking variant above: returns the first PlayerPayload or
// null if the SeString has none.
internal static PlayerPayload? TryGetPlayerPayload(SeString? seString)
{
if (seString == null)
{
return null;
}
foreach (var payload in seString.Payloads)
{
if (payload is PlayerPayload pp)
{
return pp;
}
}
return null;
}
// True when the message's sender (or, as a fallback, content) carries a
// PlayerPayload that matches the given identity. Used by both the
// Tab.Matches sender filter and the MessageStore tell-history scan.
internal static bool MatchesSender(Message message, string senderName, uint senderWorld)
{
var payload = TryGetPlayerPayload(message.Sender) ?? TryGetPlayerPayload(message.Content);
if (payload == null)
{
return false;
}
if (!string.Equals(payload.PlayerName, senderName, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return payload.World.RowId == senderWorld;
}
internal static readonly RawPayload PeriodicRecruitmentLink = new([0x02, 0x27, 0x07, 0x08, 0x01, 0x01, 0x01, 0xFF, 0x01, 0x03]);
private static uint GetInteger(BinaryReader input)
{
var num1 = (uint) input.ReadByte();
if (num1 < 208U)
return num1 - 1U;
var num2 = (uint) ((int) num1 + 1 & 15);
var numArray = new byte[4];
for (var index = 3; index >= 0; --index)
numArray[index] = (num2 & 1 << index) == 0L ? (byte) 0 : input.ReadByte();
return BitConverter.ToUInt32(numArray, 0);
}
// private static bool TryResolveUInt(in ReadOnlySeExpressionSpan expression, out uint value)
// {
// if (expression.TryGetUInt(out value))
// return true;
//
// if (expression.TryGetParameterExpression(out var exprType, out var operand1))
// {
// if (!TryResolveUInt(operand1, out var paramIndex))
// return false;
//
// if (paramIndex == 0)
// return false;
//
// paramIndex--;
// if ((ExpressionType)exprType == ExpressionType.GlobalNumber)
// {
// value = (uint) GlobalParametersCache.GetValue((int)paramIndex);
// return true;
// }
// // return (ExpressionType)exprType switch
// // {
// // // ExpressionType.LocalNumber => context.TryGetLNum((int)paramIndex, out value), // lnum
// // ExpressionType.GlobalNumber => (uint) GlobalParametersCache.GetValue((int)paramIndex), // gnum
// // _ => false, // gstr, lstr
// // };
// }
//
// return false;
// }
// [MethodImpl(MethodImplOptions.AggressiveInlining)]
// private static bool TryResolveInt(in ReadOnlySeExpressionSpan expression, out int value)
// {
// if (TryResolveUInt(expression, out var u32))
// {
// value = (int)u32;
// return true;
// }
//
// value = 0;
// return false;
// }
}
+51
View File
@@ -0,0 +1,51 @@
using System.Buffers.Binary;
using System.Numerics;
namespace HellionChat.Util;
internal static class ColourUtil {
private static (byte r, byte g, byte b) RgbaToRgbComponents(uint rgba)
{
var r = (byte) ((rgba & 0xFF000000) >> 24);
var g = (byte) ((rgba & 0xFF0000) >> 16);
var b = (byte) ((rgba & 0xFF00) >> 8);
return (r, g, b);
}
internal static uint RgbaToAbgr(uint rgba) => BinaryPrimitives.ReverseEndianness(rgba);
internal static Vector3 RgbaToVector3(uint rgba)
{
var (r, g, b) = RgbaToRgbComponents(rgba);
return new Vector3((float) r / 255, (float) g / 255, (float) b / 255);
}
internal static uint Vector3ToRgba(Vector3 col)
{
return ComponentsToRgba(
(byte) Math.Round(col.X * 255),
(byte) Math.Round(col.Y * 255),
(byte) Math.Round(col.Z * 255)
);
}
internal static uint Vector4ToAbgr(Vector4 col)
{
return RgbaToAbgr(ComponentsToRgba(
(byte) Math.Round(col.X * 255),
(byte) Math.Round(col.Y * 255),
(byte) Math.Round(col.Z * 255),
(byte) Math.Round(col.W * 255)
));
}
public static unsafe uint ArgbToRgba(uint x)
{
var buf = (byte*)&x;
(buf[1], buf[2], buf[3], buf[0]) = (buf[0], buf[1], buf[2], buf[3]);
return x;
}
internal static uint ComponentsToRgba(byte red, byte green, byte blue, byte alpha = 0xFF)
=> alpha | (uint) (red << 24) | (uint) (green << 16) | (uint) (blue << 8);
}
+273
View File
@@ -0,0 +1,273 @@
using System.Globalization;
using System.Numerics;
using HellionChat.Resources;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Utility;
using Dalamud.Bindings.ImGui;
namespace HellionChat.Util;
// From https://github.com/Flix01/imgui/blob/imgui_with_addons/addons/imguidatechooser/imguidatechooser.cpp
public static class DateWidget
{
private const int HeightInItems = 1 + 1 + 1 + 4;
private static readonly DateTime Sample = DateTime.UnixEpoch;
private static readonly Vector4 Transparent = new(1, 1, 1, 0);
private static readonly string[] DayNames = [Language.DateWidget_Day_Sun, Language.DateWidget_Day_Mon, Language.DateWidget_Day_Tue, Language.DateWidget_Day_Wed, Language.DateWidget_Day_Thu, Language.DateWidget_Day_Fri, Language.DateWidget_Day_Sat];
private static readonly string[] MonthNames = [Language.DateWidget_Month_January, Language.DateWidget_Month_February, Language.DateWidget_Month_March, Language.DateWidget_Month_April, Language.DateWidget_Month_May, Language.DateWidget_Month_June, Language.DateWidget_Month_July, Language.DateWidget_Month_August, Language.DateWidget_Month_September, Language.DateWidget_Month_October, Language.DateWidget_Month_November, Language.DateWidget_Month_December];
private static readonly int[] NumDaysPerMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
private static float LongestMonthWidth;
private static readonly float[] MonthWidths = new float[12];
private static uint LastOpenComboId;
public static bool Validate(DateTime minimal, ref DateTime currentMin, ref DateTime currentMax)
{
var needsRefresh = false;
if (minimal > currentMin)
{
currentMin = minimal;
Plugin.Notification.AddNotification(new Notification
{
Content = Language.DateWidget_InvalidDate.Format(minimal.ToShortDateString()),
Type = NotificationType.Warning,
Minimized = false,
});
needsRefresh = true;
}
else if (currentMin > currentMax)
{
currentMax = currentMin;
needsRefresh = true;
}
return needsRefresh;
}
public static void DatePickerWithInput(string label, int id, ref string dateString, ref DateTime date, string format, bool sameLine = false, bool closeWhenMouseLeavesIt = true)
{
if (sameLine)
ImGui.SameLine();
ImGui.SetNextItemWidth(ImGui.CalcTextSize(Sample.ToString(format)).X + ImGui.GetStyle().ItemInnerSpacing.X * 2);
if (ImGui.InputTextWithHint($"##{label}Input", format.ToUpper(), ref dateString, 32, ImGuiInputTextFlags.CallbackCompletion))
{
if (DateTime.TryParseExact(dateString, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var tmp))
date = tmp;
}
ImGui.SameLine(0, 3.0f * ImGuiHelpers.GlobalScale);
ImGuiUtil.IconButton(FontAwesomeIcon.Calendar, id.ToString());
if (ImGui.IsItemHovered())
ImGui.SetTooltip(Language.DatePicker_Tooltip);
if (DatePicker(label, ref date, closeWhenMouseLeavesIt))
dateString = date.ToString(format);
}
private static bool DatePicker(string label, ref DateTime dateOut, bool closeWhenMouseLeavesIt, string leftArrow = "", string rightArrow = "")
{
using var mono = ImRaii.PushFont(UiBuilder.MonoFont);
if (LongestMonthWidth == 0.0f)
{
for (var i = 0; i < 12; i++)
{
var mw = ImGui.CalcTextSize(MonthNames[i]).X;
MonthWidths[i] = mw;
LongestMonthWidth = Math.Max(LongestMonthWidth, mw);
}
}
var id = ImGui.GetID(label);
var style = ImGui.GetStyle();
var arrowLeft = leftArrow.Length > 0 ? leftArrow : "<";
var arrowRight = rightArrow.Length > 0 ? rightArrow : ">";
var arrowLeftWidth = ImGui.CalcTextSize(arrowLeft).X;
var arrowRightWidth = ImGui.CalcTextSize(arrowRight).X;
var labelSize = ImGui.CalcTextSize(label, true, 0);
var widthRequiredByCalendar = (2.0f * arrowLeftWidth) + (2.0f * arrowRightWidth) + LongestMonthWidth + ImGui.CalcTextSize("9999").X + (120.0f * ImGuiHelpers.GlobalScale);
var popupHeight = ((labelSize.Y + (2 * style.ItemSpacing.Y)) * HeightInItems) + (style.FramePadding.Y * 3);
var valueChanged = false;
ImGui.SetNextWindowSize(new Vector2(widthRequiredByCalendar, widthRequiredByCalendar));
ImGui.SetNextWindowSizeConstraints(new Vector2(widthRequiredByCalendar, popupHeight + 40), new Vector2(widthRequiredByCalendar, popupHeight + 40));
using var popupItem = ImRaii.ContextPopupItem(label, ImGuiPopupFlags.None);
if (!popupItem.Success)
return valueChanged;
if (ImGui.GetIO().MouseClicked[1])
{
// reset date when user right-clicks the date chooser header when the dialog is open
dateOut = DateTime.Now;
}
else if (LastOpenComboId != id)
{
LastOpenComboId = id;
if (dateOut.Year == 1)
dateOut = DateTime.Now;
}
using var windowPadding = ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, style.FramePadding);
using var buttonColor = ImRaii.PushColor(ImGuiCol.Button, Transparent);
ImGui.Spacing();
var yearString = $"{dateOut.Year}";
var yearPartWidth = arrowLeftWidth + arrowRightWidth + ImGui.CalcTextSize(yearString).X;
var oldWindowRounding = style.WindowRounding;
style.WindowRounding = 0;
using (ImRaii.PushId(1234))
{
if (ImGui.SmallButton(arrowLeft))
{
valueChanged = true;
dateOut = dateOut.AddMonths(-1);
}
ImGui.SameLine();
var color = ImGui.GetColorU32(style.Colors[(int)ImGuiCol.Text]);
var monthWidth = MonthWidths[dateOut.Month - 1];
var pos = ImGui.GetCursorScreenPos();
pos = pos with { X = pos.X + ((LongestMonthWidth - monthWidth) * 0.5f) };
ImGui.GetForegroundDrawList().AddText(pos, color, MonthNames[dateOut.Month - 1]);
ImGui.SameLine(0, LongestMonthWidth + style.ItemSpacing.X * 2);
if (ImGui.SmallButton(arrowRight))
{
valueChanged = true;
dateOut = dateOut.AddMonths(1);
}
}
ImGui.SameLine(ImGui.GetWindowWidth() - yearPartWidth - style.WindowPadding.X - style.ItemSpacing.X * 4.0f);
using (ImRaii.PushId(1235))
{
if (ImGui.SmallButton(arrowLeft))
{
valueChanged = true;
dateOut = dateOut.AddYears(-1);
}
ImGui.SameLine();
ImGui.Text($"{dateOut.Year}");
ImGui.SameLine();
if (ImGui.SmallButton(arrowRight))
{
valueChanged = true;
dateOut = dateOut.AddYears(1);
}
}
ImGui.Spacing();
// This could be calculated only when needed (but I guess it's fast in any case...)
var maxDayOfCurMonth = NumDaysPerMonth[dateOut.Month - 1];
if (maxDayOfCurMonth == 28)
{
var year = dateOut.Year;
var bis = ((year % 4) == 0) && ((year % 100) != 0 || (year % 400) == 0);
if (bis)
maxDayOfCurMonth = 29;
}
using var buttonHovered = ImRaii.PushColor(ImGuiCol.ButtonHovered, ImGuiColors.DalamudOrange);
using var buttonActive = ImRaii.PushColor(ImGuiCol.ButtonActive, ImGuiColors.DalamudYellow);
ImGui.Separator();
// Display items
var dayClicked = false;
var dayOfWeek = (int)new DateTime(dateOut.Year, dateOut.Month, 1).DayOfWeek;
for (var dw = 0; dw < 7; dw++)
{
using (ImRaii.Group())
{
using var textColor = ImRaii.PushColor(ImGuiCol.Text, CalculateTextColor(), dw == 0);
ImGui.Text($"{(dw == 0 ? "" : " ")}{DayNames[dw]}");
if (dw == 0)
ImGui.Separator();
else
ImGui.Spacing();
// Use dayOfWeek for spacing
var curDay = dw - dayOfWeek;
for (var row = 0; row < 7; row++)
{
var cday = curDay + (7 * row);
if (cday >= 0 && cday < maxDayOfCurMonth)
{
using var rowId = ImRaii.PushId(row * 10 + dw);
if (ImGui.SmallButton(string.Format(cday < 9 ? " {0}" : "{0}", cday + 1)))
{
ImGui.SetItemDefaultFocus();
dayClicked = true;
valueChanged = true;
dateOut = new DateTime(dateOut.Year, dateOut.Month, cday + 1);
}
}
else
{
ImGui.TextUnformatted(" ");
}
}
if (dw == 0)
ImGui.Separator();
}
if (dw != 6)
ImGui.SameLine(ImGui.GetWindowWidth() - (6 - dw) * (ImGui.GetWindowWidth() / 7.0f));
}
style.WindowRounding = oldWindowRounding;
var mustCloseCombo = dayClicked;
if (closeWhenMouseLeavesIt && !mustCloseCombo)
{
var distance = ImGui.GetFontSize() * 1.75f; //1.3334f; //24;
var pos = ImGui.GetWindowPos();
pos.X -= distance;
pos.Y -= distance;
var size = ImGui.GetWindowSize();
size.X += 2.0f * distance;
size.Y += 2.0f * distance;
var mousePos = ImGui.GetIO().MousePos;
if (mousePos.X < pos.X || mousePos.Y < pos.Y || mousePos.X > pos.X + size.X || mousePos.Y > pos.Y + size.Y)
mustCloseCombo = true;
}
// ImGui issue #273849, children keep popups from closing automatically
if (mustCloseCombo)
ImGui.CloseCurrentPopup();
return valueChanged;
}
private static Vector4 CalculateTextColor()
{
var textColor = ImGuiColors.DalamudGrey;
var l = (textColor.X + textColor.Y + textColor.Z) * 0.33334f;
return new Vector4(l * 2.0f > 1 ? 1 : l * 2.0f, l * .5f, l * .5f, textColor.W);
}
}
+67
View File
@@ -0,0 +1,67 @@
namespace HellionChat.Util;
public class ColorPayload
{
private const byte StartByte = 2;
public bool Enabled;
public uint Color;
public uint UnshiftedColor;
public static ColorPayload? From(byte[] data)
{
using var stream = new MemoryStream(data);
if (stream.ReadByte() != StartByte || stream.ReadByte() != 0x13)
return null;
stream.ReadByte(); // skip the length byte;
var typeByte = stream.ReadByte();
var payload = new ColorPayload();
switch (typeByte)
{
case 0xEC:
payload.Enabled = false;
return payload;
case 0xE9:
var param = stream.ReadByte();
var globalValue = (uint) GlobalParametersCache.GetValue(param - 2);
payload.Enabled = true;
payload.UnshiftedColor = globalValue;
payload.Color = ColourUtil.ArgbToRgba(globalValue);
return payload;
case >= 0xF0 and <= 0xFE:
// From: https://github.com/NotAdam/Lumina/blob/master/src/Lumina/Text/Expressions/IntegerExpression.cs#L119-L128
uint ShiftAndThrowIfZero(int v, int shift)
{
return v switch
{
// ReSharper disable once LocalizableElement
-1 => throw new ArgumentException("Encountered premature end of input (unexpected EOF).", nameof(v)),
// ReSharper disable once LocalizableElement
0 => throw new ArgumentException("Encountered premature end of input (unexpected null character).", nameof(v)),
_ => (uint)v << shift
};
}
typeByte += 1;
var argbValue = 0u;
if ((typeByte & 8) != 0)
argbValue |= ShiftAndThrowIfZero(stream.ReadByte(), 24);
else
argbValue |= 0xff000000u;
if( (typeByte & 4) != 0 ) argbValue |= ShiftAndThrowIfZero( stream.ReadByte(), 16 );
if( (typeByte & 2) != 0 ) argbValue |= ShiftAndThrowIfZero( stream.ReadByte(), 8 );
if( (typeByte & 1) != 0 ) argbValue |= ShiftAndThrowIfZero( stream.ReadByte(), 0 );
payload.Enabled = true;
payload.Color = ColourUtil.ArgbToRgba(argbValue);
return payload;
default:
return null;
}
}
}
+47
View File
@@ -0,0 +1,47 @@
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using FFXIVClientStructs.FFXIV.Component.Text;
namespace HellionChat.Util;
public static class GlobalParametersCache
{
private static int[] Cache = [];
public static int GetValue(int index)
{
if (index < 0 || index >= Cache.Length)
return 0;
return Cache[index];
}
/// <summary>
/// Refresh the cache of global parameters from RaptureTextModule.
/// </summary>
/// <remarks>
/// This should be called in the main thread when updates are necessary.
/// </remarks>
public static unsafe void Refresh()
{
if (!ThreadSafety.IsMainThread)
throw new InvalidOperationException("GlobalParametersCache.Refresh must be called on the main thread.");
var rtm = RaptureTextModule.Instance();
if (rtm is null)
return;
ref var gp = ref rtm->TextModule.MacroDecoder.GlobalParameters;
if (Cache.Length != (int)gp.MySize)
Cache = new int[gp.MySize];
for (ulong i = 0; i < gp.MySize; i++)
{
var p = gp[(long)i];
if (p.Type == TextParameterType.Integer)
Cache[(int)i] = p.IntValue;
else
Cache[(int)i] = 0;
}
}
}
+158
View File
@@ -0,0 +1,158 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace HellionChat.Util;
// From Kizer: https://github.com/Soreepeong/Dalamud/blob/feature/log-wordwrap/Dalamud/Interface/Spannables/Internal/GfdFileView.cs
public readonly unsafe ref struct GfdFileView
{
private readonly ReadOnlySpan<byte> Span;
private readonly bool DirectLookup;
/// <summary>Initializes a new instance of the <see cref="GfdFileView"/> struct.</summary>
/// <param name="span">The data.</param>
public GfdFileView(ReadOnlySpan<byte> span)
{
Span = span;
if (span.Length < sizeof(GfdHeader))
throw new InvalidDataException($"Not enough space for a {nameof(GfdHeader)}");
if (span.Length < sizeof(GfdHeader) + (Header.Count * sizeof(GfdEntry)))
throw new InvalidDataException($"Not enough space for all the {nameof(GfdEntry)}");
var entries = Entries;
DirectLookup = true;
for (var i = 0; i < entries.Length && DirectLookup; i++)
DirectLookup &= i + 1 == entries[i].Id;
}
/// <summary>Gets the header.</summary>
private ref readonly GfdHeader Header => ref MemoryMarshal.AsRef<GfdHeader>(Span);
/// <summary>Gets the entries.</summary>
private ReadOnlySpan<GfdEntry> Entries => MemoryMarshal.Cast<byte, GfdEntry>(Span[sizeof(GfdHeader)..]);
/// <summary>Attempts to get an entry.</summary>
/// <param name="iconId">The icon ID.</param>
/// <param name="entry">The entry.</param>
/// <param name="followRedirect">Whether to follow redirects.</param>
/// <returns><c>true</c> if found.</returns>
public bool TryGetEntry(uint iconId, out GfdEntry entry, bool followRedirect = true)
{
if (iconId == 0)
{
entry = default;
return false;
}
var entries = Entries;
if (DirectLookup)
{
if (iconId <= entries.Length)
{
entry = entries[(int)(iconId - 1)];
return !entry.IsEmpty;
}
entry = default;
return false;
}
var lo = 0;
var hi = entries.Length;
while (lo <= hi)
{
var i = lo + ((hi - lo) >> 1);
if (entries[i].Id == iconId)
{
if (followRedirect && entries[i].Redirect != 0)
{
iconId = entries[i].Redirect;
lo = 0;
hi = entries.Length;
continue;
}
entry = entries[i];
return !entry.IsEmpty;
}
if (entries[i].Id < iconId)
lo = i + 1;
else
hi = i - 1;
}
entry = default;
return false;
}
/// <summary>Header of a .gfd file.</summary>
[StructLayout(LayoutKind.Sequential)]
public struct GfdHeader
{
/// <summary>Signature: "gftd0100".</summary>
public fixed byte Signature[8];
/// <summary>Number of entries.</summary>
public int Count;
/// <summary>Unused/unknown.</summary>
public fixed byte Padding[4];
}
/// <summary>An entry of a .gfd file.</summary>
[StructLayout(LayoutKind.Sequential, Size = 0x10)]
public struct GfdEntry
{
/// <summary>ID of the entry.</summary>
public ushort Id;
/// <summary>The left offset of the entry.</summary>
public ushort Left;
/// <summary>The top offset of the entry.</summary>
public ushort Top;
/// <summary>The width of the entry.</summary>
public ushort Width;
/// <summary>The height of the entry.</summary>
public ushort Height;
/// <summary>Unknown/unused.</summary>
public ushort Unk0A;
/// <summary>The redirected entry, maybe.</summary>
public ushort Redirect;
/// <summary>Unknown/unused.</summary>
public ushort Unk0E;
/// <summary>Gets a value indicating whether this entry is effectively empty.</summary>
public bool IsEmpty => Width == 0 || Height == 0;
}
}
internal static class IconUtil
{
private static byte[]? GfdFile;
public static unsafe GfdFileView GfdFileView
{
get
{
GfdFile ??= Plugin.DataManager.GetFile("common/font/gfdata.gfd")!.Data;
return new GfdFileView(new ReadOnlySpan<byte>(Unsafe.AsPointer(ref GfdFile[0]), GfdFile.Length));
}
}
public static byte[] ImageToRaw(this Image<Rgba32> image)
{
var data = new byte[4 * image.Width * image.Height];
image.CopyPixelDataTo(data);
return data;
}
}
+738
View File
@@ -0,0 +1,738 @@
using System.Buffers;
using System.Numerics;
using System.Text;
using HellionChat.Code;
using HellionChat.GameFunctions.Types;
using HellionChat.Resources;
using Dalamud.Game.ClientState.Keys;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Interface;
using Dalamud.Interface.Components;
using Dalamud.Interface.FontIdentifier;
using Dalamud.Interface.ImGuiFontChooserDialog;
using Dalamud.Interface.Style;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Bindings.ImGui;
namespace HellionChat.Util;
internal static class ImGuiUtil
{
private static Plugin Plugin = null!;
public static void Initialize(Plugin plugin)
{
Plugin = plugin;
}
private static readonly ImGuiMouseButton[] Buttons =
[
ImGuiMouseButton.Left,
ImGuiMouseButton.Middle,
ImGuiMouseButton.Right
];
private static Payload? Hovered;
private static Payload? LastLink;
private static readonly List<(Vector2, Vector2)> PayloadBounds = [];
internal static void PostPayload(Chunk chunk, PayloadHandler? handler)
{
var payload = chunk.Link;
if (payload != null && ImGui.IsItemHovered())
{
Hovered = payload;
ImGui.SetMouseCursor(ImGuiMouseCursor.Hand);
handler?.Hover(payload);
}
else if (!ReferenceEquals(Hovered, payload))
{
Hovered = null;
}
if (handler == null)
return;
foreach (var button in Buttons)
if (ImGui.IsItemClicked(button))
handler.Click(chunk, payload, button);
}
// Ceiling on the byte buffer for a single rendered line. UTF-8 takes at
// most 4 bytes per char; ImGui's internal ImString limit is well below
// this and FFXIV's chat lines top out around a few hundred chars in
// practice. The cap prevents an unbounded ArrayPool rent if a caller
// ever feeds in a degenerate input.
private const int MaxLineByteCount = 16 * 1024;
internal static void WrapText(string csText, Chunk chunk, PayloadHandler? handler, Vector4 defaultText, float lineWidth)
{
if (csText.Length == 0)
return;
foreach (var part in csText.Split(["\r\n", "\r", "\n"], StringSplitOptions.None))
{
if (part.Length == 0)
{
ImGui.TextUnformatted("");
continue;
}
// Allocate against the encoder's own MaxByteCount so the buffer
// we hand to ImGui is sized by us. The actual byte count
// returned by GetBytes is then validated against that ceiling
// before any pointer arithmetic touches it; CodeQL recognises
// that comparison as a sanitiser for the
// cs/unvalidated-local-pointer-arithmetic taint flow.
var maxBytes = Encoding.UTF8.GetMaxByteCount(part.Length);
if (maxBytes <= 0 || maxBytes > MaxLineByteCount)
{
ImGui.TextUnformatted("");
continue;
}
var buffer = ArrayPool<byte>.Shared.Rent(maxBytes);
try
{
var written = Encoding.UTF8.GetBytes(part, 0, part.Length, buffer, 0);
if (written <= 0 || written > maxBytes)
{
ImGui.TextUnformatted("");
continue;
}
WrapEncodedLine(buffer.AsSpan(0, written), chunk, handler, defaultText, lineWidth);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
private static unsafe void WrapEncodedLine(ReadOnlySpan<byte> bytes, Chunk chunk, PayloadHandler? handler, Vector4 defaultText, float lineWidth)
{
var byteCount = bytes.Length;
if (byteCount == 0)
{
ImGui.TextUnformatted("");
return;
}
fixed (byte* basePtr = bytes)
{
var widthLeft = ImGui.GetContentRegionAvail().X;
var endPrev = CalcWordWrap(basePtr, 0, byteCount, widthLeft);
if (endPrev < 0)
return;
var firstSpace = FindFirstSpace(bytes, 0, byteCount);
var properBreak = firstSpace <= endPrev;
if (properBreak)
{
DrawText(basePtr, 0, endPrev, chunk, handler, defaultText);
}
else if (lineWidth == 0f)
{
ImGui.TextUnformatted("");
}
else
{
// Check whether the next chunk would wrap at or past the
// first space. If yes, force a line break.
var wrapPos = CalcWordWrap(basePtr, 0, firstSpace, lineWidth);
if (wrapPos >= firstSpace)
ImGui.TextUnformatted("");
}
widthLeft = ImGui.GetContentRegionAvail().X;
var lineStart = 0;
while (endPrev < byteCount)
{
if (properBreak)
lineStart = endPrev;
// Skip a leading space at the start of a wrapped line.
if (lineStart < byteCount && bytes[lineStart] == (byte)' ')
lineStart++;
var newEnd = CalcWordWrap(basePtr, lineStart, byteCount, widthLeft);
if (properBreak && newEnd == endPrev)
break;
if (newEnd < 0)
{
ImGui.TextUnformatted("");
ImGui.TextUnformatted("");
break;
}
endPrev = newEnd;
DrawText(basePtr, lineStart, endPrev, chunk, handler, defaultText);
if (!properBreak)
{
properBreak = true;
widthLeft = ImGui.GetContentRegionAvail().X;
}
}
}
}
private static unsafe int CalcWordWrap(byte* basePtr, int start, int end, float width)
{
var result = ImGuiNative.CalcWordWrapPositionA(
ImGui.GetFont().Handle,
ImGuiHelpers.GlobalScale,
basePtr + start,
basePtr + end,
width);
if (result == null)
return -1;
return (int)(result - basePtr);
}
private static unsafe void DrawText(byte* basePtr, int start, int end, Chunk chunk, PayloadHandler? handler, Vector4 defaultText)
{
var oldPos = ImGui.GetCursorScreenPos();
ImGuiNative.TextUnformatted(basePtr + start, basePtr + end);
PostPayload(chunk, handler);
if (!ReferenceEquals(LastLink, chunk.Link))
PayloadBounds.Clear();
LastLink = chunk.Link;
if (Hovered != null && ReferenceEquals(Hovered, chunk.Link))
{
defaultText.W = 0.25f;
var actualCol = ColourUtil.Vector4ToAbgr(defaultText);
ImGui.GetWindowDrawList().AddRectFilled(oldPos, oldPos + ImGui.GetItemRectSize(), actualCol);
foreach (var (boundsStart, boundsSize) in PayloadBounds)
ImGui.GetWindowDrawList().AddRectFilled(boundsStart, boundsStart + boundsSize, actualCol);
PayloadBounds.Clear();
}
if (Hovered == null && chunk.Link != null)
PayloadBounds.Add((oldPos, ImGui.GetItemRectSize()));
}
private static int FindFirstSpace(ReadOnlySpan<byte> bytes, int start, int end)
{
for (var i = start; i < end; i++)
if (char.IsWhiteSpace((char)bytes[i]))
return i;
return end;
}
internal static bool IconButton(FontAwesomeIcon icon, string? id = null, string? tooltip = null, int width = 0)
{
var label = icon.ToIconString();
if (id != null)
label += $"##{id}";
bool ret;
using (Plugin.FontManager.FontAwesome.Push())
{
var size = Vector2.Zero;
if (width > 0)
size.X = width - 2 * ImGui.GetStyle().CellPadding.X;
ret = ImGui.Button(label, size);
}
if (!string.IsNullOrEmpty(tooltip) && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
Tooltip(tooltip);
return ret;
}
internal static bool OptionCheckbox(ref bool value, string label, string? description = null)
{
var ret = ImGui.Checkbox(label, ref value);
if (!string.IsNullOrEmpty(description))
HelpText(description);
return ret;
}
internal static void HelpText(string text)
{
using (ImRaii.TextWrapPos(0.0f))
using (ImRaii.PushColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int) ImGuiCol.TextDisabled]))
ImGui.TextUnformatted(text);
}
// Hellion Chat — compact help affordance: a dimmed "(?)" glyph rendered
// on the same line as the previous item, with the long-form description
// tucked into a hover tooltip. Lets us keep the settings panes scannable
// instead of stacking a wall of HelpText paragraphs under every option.
internal static void HelpMarker(string description)
{
ImGui.SameLine();
using (ImRaii.PushColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int) ImGuiCol.TextDisabled]))
ImGui.TextUnformatted("(?)");
// AllowWhenDisabled — ohne das Flag liefert IsItemHovered bei
// ausgegrauten Settings false, der User könnte nicht mehr lesen
// warum eine Option nicht aktiv ist. Genau dann braucht er den
// Hover-Tooltip aber am dringendsten.
if (!ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
return;
using var tooltip = ImRaii.Tooltip();
using (ImRaii.TextWrapPos(35.0f * ImGui.GetFontSize()))
ImGui.TextUnformatted(description);
}
internal static void WarningText(string text, bool wrap = true)
{
var style = StyleModel.GetConfiguredStyle() ?? StyleModel.GetFromCurrent();
var dalamudOrange = style.BuiltInColors?.DalamudOrange;
using (ImRaii.TextWrapPos(wrap ? 0.0f : ImGui.GetFontSize() * 35.0f))
using (ImRaii.PushColor(ImGuiCol.Text, dalamudOrange ?? Vector4.Zero, dalamudOrange != null))
ImGui.TextUnformatted(text);
}
internal static ImRaii.ComboDisposable BeginComboVertical(string label, string previewValue, ImGuiComboFlags flags = ImGuiComboFlags.None)
{
ImGui.TextUnformatted(label);
ImGui.SetNextItemWidth(-1);
return ImRaii.Combo($"##{label}", previewValue, flags);
}
internal static bool DragFloatVertical(string label, ref float value, float vSpeed = 1.0f, float vMin = float.MinValue, float vMax = float.MaxValue, string? format = null, ImGuiSliderFlags flags = ImGuiSliderFlags.None)
{
ImGui.TextUnformatted(label);
ImGui.SetNextItemWidth(-1);
return ImGui.DragFloat($"##{label}", ref value, vSpeed, vMin, vMax, format, flags);
}
internal static bool DragFloatVertical(string label, string description, ref float value, float vSpeed = 1.0f, float vMin = float.MinValue, float vMax = float.MaxValue, string? format = null, ImGuiSliderFlags flags = ImGuiSliderFlags.None)
{
ImGui.TextUnformatted(label);
ImGui.SetNextItemWidth(-1);
var r = ImGui.DragFloat($"##{label}", ref value, vSpeed, vMin, vMax, format, flags);
HelpText(description);
return r;
}
internal static bool InputIntVertical(string label, string description, ref int value, int step = 1, int stepFast = 100, ImGuiInputTextFlags flags = ImGuiInputTextFlags.None)
{
ImGui.TextUnformatted(label);
ImGui.SetNextItemWidth(-1);
var r = ImGui.InputInt($"##{label}", ref value, step, stepFast, flags: flags);
HelpText(description);
return r;
}
internal static void Tooltip(string tooltip)
{
using (ImRaii.Tooltip())
using (ImRaii.TextWrapPos(ImGui.GetFontSize() * 35.0f))
ImGui.TextUnformatted(tooltip);
}
public static SingleFontChooserDialog? FontChooser(string label, SingleFontSpec font, bool checkbox, ref bool checkboxValue, Predicate<IFontFamilyId>? exclusion = null, string? preview = null)
{
using var id = ImRaii.PushId(label);
ImGui.TextUnformatted(label);
if (checkbox)
{
ImGui.Checkbox("##enabled", ref checkboxValue);
ImGui.SameLine();
}
var fontFamily = font.FontId.Family.EnglishName;
var fontStyle = font.FontId.EnglishName;
fontStyle = fontStyle.Equals(fontFamily) ? "" : $" - {fontStyle}";
var buttonText = $"{fontFamily}{fontStyle} ({font.SizePt}pt)";
if (!ImGui.Button($"{buttonText}##{label}"))
return null;
var chooser = SingleFontChooserDialog.CreateAuto((UiBuilder) Plugin.Interface.UiBuilder);
chooser.SelectedFont = font;
if (exclusion is not null)
chooser.FontFamilyExcludeFilter = exclusion;
if (preview is not null)
chooser.PreviewText = preview;
return chooser;
}
public static void FontSizeCombo(string label, ref float currentSize)
{
ImGui.TextUnformatted(label);
ImGui.SetNextItemWidth(-1);
using var combo = ImRaii.Combo($"##{label}", $"{currentSize:###.##}pt");
if (!combo.Success)
return;
foreach (var size in FontManager.AxisFontSizeList)
if (ImGui.Selectable($"{size:###.##}pt", currentSize.Equals(size)))
currentSize = size;
}
public static bool Button(string id, FontAwesomeIcon icon, bool disabled)
{
using (ImRaii.Disabled(disabled))
return ImGuiComponents.IconButton(id, icon);
}
internal static bool CtrlShiftButton(string label, string tooltip = "")
{
var ctrlShiftHeld = ImGui.GetIO() is { KeyCtrl: true, KeyShift: true };
bool ret;
using (ImRaii.Disabled(!ctrlShiftHeld))
ret = ImGui.Button(label) && ctrlShiftHeld;
if (tooltip.Length != 0 && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
Tooltip(tooltip);
return ret;
}
internal static void KeybindInput(string id, ref ConfigKeyBind? keybind)
{
var idUint = ImGui.GetID(id);
using var pushedId = ImRaii.PushId(id);
if (ImGui.GetStateStorage().GetBool(idUint))
{
var io = ImGui.GetIO();
var currentMods = ModifierFlag.None;
var modString = "";
if (io.KeyCtrl)
{
currentMods |= ModifierFlag.Ctrl;
modString += Language.Keybind_Modifier_Ctrl + " + ";
}
if (io.KeyShift)
{
currentMods |= ModifierFlag.Shift;
modString += Language.Keybind_Modifier_Shift + " + ";
}
if (io.KeyAlt)
{
currentMods |= ModifierFlag.Alt;
modString += Language.Keybind_Modifier_Alt + " + ";
}
var text = $"{modString}... ({Language.Keybind_EscToClear})";
using (ImRaii.PushColor(ImGuiCol.TextSelectedBg, Vector4.Zero))
{
ImGui.SetKeyboardFocusHere();
ImGui.InputText(id + "##keybind", ref text, 0, ImGuiInputTextFlags.ReadOnly);
}
if (ImGui.IsKeyPressed(ImGuiKey.Escape))
{
keybind = null;
ImGui.GetStateStorage().SetBool(idUint, false);
return;
}
foreach (var vk in Enum.GetValues<VirtualKey>())
{
if (vk is VirtualKey.NO_KEY or VirtualKey.CONTROL or VirtualKey.LCONTROL or VirtualKey.RCONTROL or VirtualKey.SHIFT or VirtualKey.LSHIFT or VirtualKey.RSHIFT or VirtualKey.MENU or VirtualKey.LMENU or VirtualKey.RMENU)
continue;
if (!vk.TryToImGui(out var imKey) || !ImGui.IsKeyPressed(imKey))
continue;
keybind = new ConfigKeyBind { Modifier = currentMods, Key = vk };
ImGui.GetStateStorage().SetBool(idUint, false);
return;
}
}
else
{
var text = $"({Language.Keybind_None})";
if (keybind != null)
text = keybind.ToString();
if (ImGui.Button(text, new Vector2(-1, 0)))
ImGui.GetStateStorage().SetBool(idUint, true);
}
}
public static void DrawArrows(ref int selected, int min, int max, float spacing, int id = 0, string? tooltipLeft = null, string? tooltipRight = null)
{
// Prevents changing values from triggering EndDisable
var isMin = selected == min;
var isMax = selected == max;
ImGui.SameLine(0, spacing);
using (ImRaii.Disabled(isMin))
{
if (IconButton(FontAwesomeIcon.ArrowLeft, id.ToString()))
selected--;
}
if (tooltipLeft != null && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
ImGui.SetTooltip(tooltipLeft);
ImGui.SameLine(0, spacing);
using (ImRaii.Disabled(isMax))
{
if (IconButton(FontAwesomeIcon.ArrowRight, id+1.ToString()))
selected++;
}
if (tooltipRight != null && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
ImGui.SetTooltip(tooltipRight);
}
public static void WrappedTextWithColor(Vector4 color, string text)
{
using (ImRaii.PushColor(ImGuiCol.Text, color))
ImGui.TextWrapped(text);
}
public static void CenterText(string text, float indent = 0.0f)
{
indent *= ImGuiHelpers.GlobalScale;
ImGui.SameLine(((ImGui.GetContentRegionAvail().X - ImGui.CalcTextSize(text).X) * 0.5f) + indent);
ImGui.TextUnformatted(text);
}
internal static bool TryToImGui(this VirtualKey key, out ImGuiKey result)
{
result = key switch
{
VirtualKey.NO_KEY => ImGuiKey.None,
VirtualKey.BACK => ImGuiKey.Backspace,
VirtualKey.TAB => ImGuiKey.Tab,
VirtualKey.RETURN => ImGuiKey.Enter,
VirtualKey.SHIFT => ImGuiKey.ModShift,
VirtualKey.CONTROL => ImGuiKey.ModCtrl,
VirtualKey.MENU => ImGuiKey.ModAlt,
VirtualKey.PAUSE => ImGuiKey.Pause,
VirtualKey.CAPITAL => ImGuiKey.CapsLock,
VirtualKey.ESCAPE => ImGuiKey.Escape,
VirtualKey.SPACE => ImGuiKey.Space,
VirtualKey.PRIOR => ImGuiKey.PageUp,
VirtualKey.NEXT => ImGuiKey.PageDown,
VirtualKey.END => ImGuiKey.End,
VirtualKey.HOME => ImGuiKey.Home,
VirtualKey.LEFT => ImGuiKey.LeftArrow,
VirtualKey.UP => ImGuiKey.UpArrow,
VirtualKey.RIGHT => ImGuiKey.RightArrow,
VirtualKey.DOWN => ImGuiKey.DownArrow,
VirtualKey.SNAPSHOT => ImGuiKey.PrintScreen,
VirtualKey.INSERT => ImGuiKey.Insert,
VirtualKey.DELETE => ImGuiKey.Delete,
VirtualKey.KEY_0 => ImGuiKey.Key0,
VirtualKey.KEY_1 => ImGuiKey.Key1,
VirtualKey.KEY_2 => ImGuiKey.Key2,
VirtualKey.KEY_3 => ImGuiKey.Key3,
VirtualKey.KEY_4 => ImGuiKey.Key4,
VirtualKey.KEY_5 => ImGuiKey.Key5,
VirtualKey.KEY_6 => ImGuiKey.Key6,
VirtualKey.KEY_7 => ImGuiKey.Key7,
VirtualKey.KEY_8 => ImGuiKey.Key8,
VirtualKey.KEY_9 => ImGuiKey.Key9,
VirtualKey.A => ImGuiKey.A,
VirtualKey.B => ImGuiKey.B,
VirtualKey.C => ImGuiKey.C,
VirtualKey.D => ImGuiKey.D,
VirtualKey.E => ImGuiKey.E,
VirtualKey.F => ImGuiKey.F,
VirtualKey.G => ImGuiKey.G,
VirtualKey.H => ImGuiKey.H,
VirtualKey.I => ImGuiKey.I,
VirtualKey.J => ImGuiKey.J,
VirtualKey.K => ImGuiKey.K,
VirtualKey.L => ImGuiKey.L,
VirtualKey.M => ImGuiKey.M,
VirtualKey.N => ImGuiKey.N,
VirtualKey.O => ImGuiKey.O,
VirtualKey.P => ImGuiKey.P,
VirtualKey.Q => ImGuiKey.Q,
VirtualKey.R => ImGuiKey.R,
VirtualKey.S => ImGuiKey.S,
VirtualKey.T => ImGuiKey.T,
VirtualKey.U => ImGuiKey.U,
VirtualKey.V => ImGuiKey.V,
VirtualKey.W => ImGuiKey.W,
VirtualKey.X => ImGuiKey.X,
VirtualKey.Y => ImGuiKey.Y,
VirtualKey.Z => ImGuiKey.Z,
VirtualKey.LWIN => ImGuiKey.LeftSuper,
VirtualKey.RWIN => ImGuiKey.RightSuper,
VirtualKey.NUMPAD0 => ImGuiKey.Keypad0,
VirtualKey.NUMPAD1 => ImGuiKey.Keypad1,
VirtualKey.NUMPAD2 => ImGuiKey.Keypad2,
VirtualKey.NUMPAD3 => ImGuiKey.Keypad3,
VirtualKey.NUMPAD4 => ImGuiKey.Keypad4,
VirtualKey.NUMPAD5 => ImGuiKey.Keypad5,
VirtualKey.NUMPAD6 => ImGuiKey.Keypad6,
VirtualKey.NUMPAD7 => ImGuiKey.Keypad7,
VirtualKey.NUMPAD8 => ImGuiKey.Keypad8,
VirtualKey.NUMPAD9 => ImGuiKey.Keypad9,
VirtualKey.MULTIPLY => ImGuiKey.KeypadMultiply,
VirtualKey.ADD => ImGuiKey.KeypadAdd,
VirtualKey.SUBTRACT => ImGuiKey.KeypadSubtract,
VirtualKey.DECIMAL => ImGuiKey.KeypadDecimal,
VirtualKey.DIVIDE => ImGuiKey.KeypadDivide,
VirtualKey.F1 => ImGuiKey.F1,
VirtualKey.F2 => ImGuiKey.F2,
VirtualKey.F3 => ImGuiKey.F3,
VirtualKey.F4 => ImGuiKey.F4,
VirtualKey.F5 => ImGuiKey.F5,
VirtualKey.F6 => ImGuiKey.F6,
VirtualKey.F7 => ImGuiKey.F7,
VirtualKey.F8 => ImGuiKey.F8,
VirtualKey.F9 => ImGuiKey.F9,
VirtualKey.F10 => ImGuiKey.F10,
VirtualKey.F11 => ImGuiKey.F11,
VirtualKey.F12 => ImGuiKey.F12,
VirtualKey.NUMLOCK => ImGuiKey.NumLock,
VirtualKey.SCROLL => ImGuiKey.ScrollLock,
VirtualKey.OEM_NEC_EQUAL => ImGuiKey.KeypadEqual,
VirtualKey.LSHIFT => ImGuiKey.LeftShift,
VirtualKey.RSHIFT => ImGuiKey.RightShift,
VirtualKey.LCONTROL => ImGuiKey.LeftCtrl,
VirtualKey.RCONTROL => ImGuiKey.RightCtrl,
VirtualKey.LMENU => ImGuiKey.LeftAlt,
VirtualKey.RMENU => ImGuiKey.RightAlt,
VirtualKey.OEM_1 => ImGuiKey.Semicolon,
VirtualKey.OEM_PLUS => ImGuiKey.Equal,
VirtualKey.OEM_COMMA => ImGuiKey.Comma,
VirtualKey.OEM_MINUS => ImGuiKey.Minus,
VirtualKey.OEM_PERIOD => ImGuiKey.Period,
VirtualKey.OEM_2 => ImGuiKey.Slash,
VirtualKey.OEM_3 => ImGuiKey.GraveAccent,
VirtualKey.OEM_4 => ImGuiKey.LeftBracket,
VirtualKey.OEM_5 => ImGuiKey.Backslash,
VirtualKey.OEM_6 => ImGuiKey.RightBracket,
VirtualKey.OEM_7 => ImGuiKey.Apostrophe,
_ => 0,
};
return result != 0 || key == VirtualKey.NO_KEY;
}
public static void ChannelSelector(string headerText, Dictionary<ChatType, (ChatSource Source, ChatSource Target)> chatCodes)
{
var spacing = 3.0f * ImGuiHelpers.GlobalScale;
using var channelNode = ImRaii.TreeNode(headerText);
if (!channelNode.Success)
return;
foreach (var (header, types) in ChatTypeExt.SortOrder)
{
using var pushedId = ImRaii.PushId(header);
if (ImGuiComponents.IconButton(FontAwesomeIcon.Check))
{
foreach (var type in types)
chatCodes.TryAdd(type, (ChatSourceExt.All, ChatSourceExt.All));
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip(Language.ChannelSelector_Select);
ImGui.SameLine(0, spacing);
if (ImGuiComponents.IconButton(FontAwesomeIcon.Times))
{
foreach (var type in types)
chatCodes.Remove(type);
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip(Language.ChannelSelector_Unselect);
ImGui.SameLine(0, spacing);
using var headerNode = ImRaii.TreeNode(header);
if (!headerNode.Success)
continue;
foreach (var type in types)
{
if (type.IsGm())
continue;
var enabled = chatCodes.ContainsKey(type);
if (ImGui.Checkbox($"##{type.Name()}", ref enabled))
{
if (enabled)
chatCodes[type] = (ChatSourceExt.All, ChatSourceExt.All);
else
chatCodes.Remove(type);
}
ImGui.SameLine();
if (!type.HasSource())
{
ImGui.TextUnformatted(type.Name());
continue;
}
using var typeNode = ImRaii.TreeNode($"{type.Name()}");
if (!typeNode.Success)
continue;
ImGui.Text(Language.ImGuiUtil_ChannelSelector_Source);
ImGui.SameLine(400.0f * ImGuiHelpers.GlobalScale);
ImGui.Text(Language.ImGuiUtil_ChannelSelector_Target);
chatCodes.TryGetValue(type, out var sourcesEnum);
var sources = (uint)sourcesEnum.Source;
var targets = (uint)sourcesEnum.Target;
foreach (var kind in Enum.GetValues<ChatSource>().Where(s => s != ChatSource.None))
{
if (ImGui.CheckboxFlags($"{kind.Name()}##source", ref sources, (uint)kind))
chatCodes[type] = ((ChatSource)sources, sourcesEnum.Target);
ImGui.SameLine(400.0f * ImGuiHelpers.GlobalScale);
if (ImGui.CheckboxFlags($"{kind.Name()}##target", ref targets, (uint)kind))
chatCodes[type] = (sourcesEnum.Source, (ChatSource)targets);
}
}
}
}
public static void ExtraChatSelector(string headerText, ref bool all, HashSet<Guid> extraChatChannels)
{
if (Plugin.ExtraChat.ChannelNames.Count <= 0)
return;
using var extraTree = ImRaii.TreeNode(headerText);
if (!extraTree.Success)
return;
ImGui.Checkbox(Language.Options_Tabs_ExtraChatAll, ref all);
ImGui.Separator();
using var _ = ImRaii.Disabled(all);
foreach (var (id, name) in Plugin.ExtraChat.ChannelNames)
{
var enabled = extraChatChannels.Contains(id);
if (!ImGui.Checkbox($"{name}##ec-{id}", ref enabled))
continue;
if (enabled)
extraChatChannels.Add(id);
else
extraChatChannels.Remove(id);
}
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace HellionChat.Util;
internal class Lender<T>
{
private readonly Func<T> Ctor;
private readonly List<T> Items = [];
private int Counter;
internal Lender(Func<T> ctor)
{
Ctor = ctor;
}
internal void ResetCounter()
{
Counter = 0;
}
internal T Borrow()
{
if (Items.Count <= Counter)
Items.Add(Ctor());
return Items[Counter++];
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.Numerics;
namespace HellionChat.Util;
public static class MathUtil
{
public record Rectangle
{
public int X;
public int Y;
public int Width;
public int Height;
public int SizeX;
public int SizeY;
public Rectangle(int x, int y, int width, int height)
{
X = x;
Y = y;
Width = width;
Height = height;
SizeX = X + Width;
SizeY = Y + Height;
}
public Rectangle(Vector2 pos, Vector2 size) : this((int) pos.X, (int) pos.Y, (int) size.X, (int) size.Y) { }
public override string ToString()
=> $"X: {X} Y: {Y} Width: {Width} Height: {Height}";
}
// From: https://stackoverflow.com/a/306379
/// <summary>
/// Checks if two rectangles overlap at any point.
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <returns>True if overlapping</returns>
public static bool HasOverlap(this Rectangle a, Rectangle b)
{
bool ValueInRange(int value, int min, int max)
=> value > min && value < max;
var xOverlap = ValueInRange(a.X, b.X, b.X + b.Width) || ValueInRange(b.X, a.X, a.X + a.Width);
var yOverlap = ValueInRange(a.Y, b.Y, b.Y + b.Height) || ValueInRange(b.Y, a.Y, a.Y + a.Height);
return xOverlap && yOverlap;
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.Text;
namespace HellionChat.Util;
public static class MemoryUtil
{
public static unsafe void PrintMemoryArea(nint address, int length)
{
var ptr = (byte*)address;
var str = new StringBuilder("\n");
for(var i = 0; i < length; i++)
{
str.Append($"{ptr![i]:X02}");
if (i == 0)
continue;
if ((i+1) % 16 == 0)
str.Append('\n');
else if ((i+1) % 4 == 0)
str.Append(' ');
}
Plugin.Log.Information(str.ToString());
}
}
+111
View File
@@ -0,0 +1,111 @@
using Dalamud.Game.Text.SeStringHandling;
namespace HellionChat.Util;
internal class PartyFinderPayload : Payload
{
public override PayloadType Type => (PayloadType) 0x50;
internal uint Id { get; }
internal PartyFinderPayload(uint id)
{
Id = id;
}
protected override byte[] EncodeImpl()
{
throw new NotImplementedException();
}
protected override void DecodeImpl(BinaryReader reader, long endOfStream)
{
throw new NotImplementedException();
}
}
internal class AchievementPayload : Payload
{
public override PayloadType Type => (PayloadType) 0x51;
internal uint Id { get; }
internal AchievementPayload(uint id)
{
Id = id;
}
protected override byte[] EncodeImpl()
{
throw new NotImplementedException();
}
protected override void DecodeImpl(BinaryReader reader, long endOfStream)
{
throw new NotImplementedException();
}
}
internal class UriPayload(Uri uri) : Payload
{
public override PayloadType Type => (PayloadType) 0x52;
public Uri Uri { get; } = uri;
private const string DefaultScheme = "https";
private static readonly string[] ExpectedSchemes = ["http", "https"];
/// <summary>
/// Create a URIPayload from a raw URI string. If the URI does not have a
/// scheme, it will default to https://.
/// </summary>
/// <exception cref="UriFormatException">
/// If the URI is invalid, or if the scheme is not supported.
/// </exception>
public static UriPayload ResolveUri(string rawUri)
{
ArgumentNullException.ThrowIfNull(rawUri);
// Check for an expected scheme '://', if not add 'https://'
if (ExpectedSchemes.Any(scheme => rawUri.StartsWith($"{scheme}://")))
return new UriPayload(new Uri(rawUri));
if (rawUri.Contains("://"))
throw new UriFormatException($"Unsupported scheme in URL: {rawUri}");
return new UriPayload(new Uri($"{DefaultScheme}://{rawUri}"));
}
protected override void DecodeImpl(BinaryReader reader, long endOfStream)
{
throw new NotImplementedException();
}
protected override byte[] EncodeImpl()
{
throw new NotImplementedException();
}
}
internal class EmotePayload : Payload
{
public override PayloadType Type => (PayloadType) 0x53;
public string Code = string.Empty;
public static EmotePayload ResolveEmote(string code)
{
return new EmotePayload { Code = code };
}
protected override void DecodeImpl(BinaryReader reader, long endOfStream)
{
throw new NotImplementedException();
}
protected override byte[] EncodeImpl()
{
throw new NotImplementedException();
}
}
+163
View File
@@ -0,0 +1,163 @@
using System.Numerics;
using Dalamud.Interface.Utility;
using Dalamud.Bindings.ImGui;
using System.Collections;
using Dalamud.Interface.Utility.Raii;
namespace HellionChat.Util;
// Modified from: https://github.com/UnknownX7/Hypostasis/blob/master/ImGui/ExcelSheet.cs
public static class SearchSelector
{
private static string[]? FilteredSearchSheet;
private static string SheetSearchText = null!;
private static string PrevSearchId = null!;
private static Type PrevSearchType = null!;
public record SelectorOptions
{
public Func<string, string> FormatRow { get; init; } = row => row.ToString();
public Func<string, string, bool>? SearchPredicate { get; init; } = null;
public Func<string, bool, bool>? DrawSelectable { get; init; } = null;
public string[] FilteredSheet { get; init; } = [];
public Vector2? Size { get; init; } = null;
}
public record SelectorPopupOptions: SelectorOptions
{
public ImGuiPopupFlags PopupFlags { get; init; } = ImGuiPopupFlags.None;
public bool CloseOnSelection { get; init; } = false;
public Func<string, bool> IsSelected { get; init; } = _ => false;
}
private static void SearchInput(string id, IEnumerable<string> filteredSheet, Func<string, string, bool> searchPredicate)
{
if (ImGui.IsWindowAppearing() && ImGui.IsWindowFocused() && !ImGui.IsAnyItemActive())
{
if (id != PrevSearchId)
{
if (typeof(string) != PrevSearchType)
{
SheetSearchText = string.Empty;
PrevSearchType = typeof(string);
}
FilteredSearchSheet = null;
PrevSearchId = id;
}
ImGui.SetKeyboardFocusHere(0);
}
if (ImGui.InputTextWithHint("##ExcelSheetSearch", "Search", ref SheetSearchText, 128, ImGuiInputTextFlags.AutoSelectAll))
FilteredSearchSheet = null;
FilteredSearchSheet ??= filteredSheet.Where(s => searchPredicate(s, SheetSearchText)).ToArray();
}
public static bool SelectorPopup(string id, out string selected, SelectorPopupOptions? options = null, bool close = false)
{
options ??= new SelectorPopupOptions();
var sheet = options.FilteredSheet;
selected = string.Empty;
if (close)
return false;
ImGui.SetNextWindowSize(options.Size ?? new Vector2(0, 250 * ImGuiHelpers.GlobalScale));
using var popup = ImRaii.ContextPopupItem(id, options.PopupFlags);
if (!popup.Success)
return false;
SearchInput(id, sheet, options.SearchPredicate ?? ((row, s) => options.FormatRow(row).Contains(s, StringComparison.CurrentCultureIgnoreCase)));
using var child = ImRaii.Child("SearchList", Vector2.Zero, true);
if (!child.Success)
return false;
var ret = false;
var drawSelectable = options.DrawSelectable ?? ((row, selected) => ImGui.Selectable(options.FormatRow(row), selected));
using (var clipper = new ListClipper(FilteredSearchSheet!.Length))
{
foreach (var i in clipper.Rows)
{
var searched = FilteredSearchSheet[i];
using var pushedId = ImRaii.PushId(id);
if (!drawSelectable(searched, options.IsSelected(searched)))
continue;
selected = searched;
ret = true;
}
}
// ImGui issue #273849, children keep popups from closing automatically
if (ret && options.CloseOnSelection)
ImGui.CloseCurrentPopup();
return ret;
}
}
public unsafe class ListClipper : IEnumerable<(int, int)>, IDisposable
{
private ImGuiListClipperPtr Clipper;
private readonly int CurrentRows;
private readonly int CurrentColumns;
private readonly bool TwoDimensional;
private readonly int ItemRemainder;
public int FirstRow { get; private set; } = -1;
public int CurrentRow { get; private set; }
public int DisplayEnd => Clipper.DisplayEnd;
public IEnumerable<int> Rows
{
get
{
while (Clipper.Step()) // Supposedly this calls End()
{
if (Clipper.ItemsHeight > 0 && FirstRow < 0)
FirstRow = (int)(ImGui.GetScrollY() / Clipper.ItemsHeight);
for (var i = Clipper.DisplayStart; i < Clipper.DisplayEnd; i++)
{
CurrentRow = i;
yield return TwoDimensional ? i : i * CurrentColumns;
}
}
}
}
private IEnumerable<int> Columns
{
get
{
var cols = (ItemRemainder == 0 || CurrentRows != DisplayEnd || CurrentRow != DisplayEnd - 1) ? CurrentColumns : ItemRemainder;
for (var j = 0; j < cols; j++)
yield return j;
}
}
public ListClipper(int items, int cols = 1, bool twoD = false, float itemHeight = 0)
{
TwoDimensional = twoD;
CurrentColumns = cols;
CurrentRows = TwoDimensional ? items : (int)MathF.Ceiling((float)items / CurrentColumns);
ItemRemainder = !TwoDimensional ? items % CurrentColumns : 0;
Clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper());
Clipper.Begin(CurrentRows, itemHeight);
}
public IEnumerator<(int, int)> GetEnumerator() => (from i in Rows from j in Columns select (i, j)).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public void Dispose()
{
Clipper.Destroy(); // This also calls End() but I'm calling it anyway just in case
GC.SuppressFinalize(this);
}
}
+28
View File
@@ -0,0 +1,28 @@
using System.Text;
namespace HellionChat.Util;
internal static class StringUtil
{
internal static byte[] ToTerminatedBytes(this string s)
{
var utf8 = Encoding.UTF8;
var bytes = new byte[utf8.GetByteCount(s) + 1];
utf8.GetBytes(s, 0, s.Length, bytes, 0);
bytes[^1] = 0;
return bytes;
}
// Taken from https://stackoverflow.com/a/4975942
internal static string BytesToString(long byteCount)
{
string[] suf = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; // Longs run out around EB
if (byteCount == 0)
return "0" + suf[0];
var bytes = Math.Abs(byteCount);
var place = Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024)));
var num = Math.Round(bytes / Math.Pow(1024, place), 1);
return (Math.Sign(byteCount) * num).ToString("N0") + suf[place];
}
}

Some files were not shown because too many files have changed in this diff Show More