docs: unify documentation and streamline code comments

- Translated project documentation (LEARNING-JOURNEY, CONTRIBUTORS, AI_DISCLOSURE) to English for better accessibility.
- Standardized internal code documentation by converting XML-doc blocks to standard comment format.
- Cleaned up inline comments and removed redundant versioning metadata across the codebase.
- Refactored non-functional text elements to improve readability and maintain a consistent style.
This commit is contained in:
2026-05-11 00:52:15 +02:00
parent a37882893e
commit c4c85cf4b8
33 changed files with 666 additions and 1364 deletions
+5 -9
View File
@@ -32,12 +32,8 @@ internal static class ExportFormatExt
}; };
} }
/// <summary> // Serializes message snapshots to Markdown, JSON, or CSV.
/// Serializes message snapshots into Markdown, JSON, or CSV. The caller is // Caller handles pre-filtering except sender substring, which requires deserialized SeString.TextValue.
/// 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 static class MessageExporter
{ {
internal record FilterDescription( internal record FilterDescription(
@@ -100,6 +96,7 @@ internal static class MessageExporter
var chatType = (ChatType)(ushort)m.Code.Type; var chatType = (ChatType)(ushort)m.Code.Type;
var sender = m.SenderSource.TextValue.Trim().Trim('<', '>', '[', ']', ':').Trim(); var sender = m.SenderSource.TextValue.Trim().Trim('<', '>', '[', ']', ':').Trim();
var content = m.ContentSource.TextValue; var content = m.ContentSource.TextValue;
if (string.IsNullOrEmpty(sender)) if (string.IsNullOrEmpty(sender))
w.WriteLine($"**[{localDate:HH:mm}] {chatType}:** {content}"); w.WriteLine($"**[{localDate:HH:mm}] {chatType}:** {content}");
else else
@@ -132,8 +129,7 @@ internal static class MessageExporter
FilterDescription filter FilterDescription filter
) )
{ {
// Manual JSON to avoid pulling in System.Text.Json policy choices. // Manual JSON to avoid System.Text.Json policy coupling.
// Output is a single object with metadata and an array of messages.
w.Write("{\n \"exported_at\": \""); w.Write("{\n \"exported_at\": \"");
w.Write(DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)); w.Write(DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
w.Write("\",\n \"plugin\": \"Hellion Chat\",\n"); w.Write("\",\n \"plugin\": \"Hellion Chat\",\n");
@@ -194,7 +190,7 @@ internal static class MessageExporter
FilterDescription filter FilterDescription filter
) )
{ {
// Header line always written so empty exports are still importable. // Header always written so empty exports remain importable.
w.WriteLine("Date,ChatType,ChatTypeName,Sender,Content,Receiver,ContentId"); w.WriteLine("Date,ChatType,ChatTypeName,Sender,Content,Receiver,ContentId");
var count = 0; var count = 0;
foreach (var m in messages) foreach (var m in messages)
+3 -12
View File
@@ -15,17 +15,10 @@ public unsafe class ChatBox
mes->Dtor(true); mes->Dtor(true);
} }
public static void SendMessage(string message) public static void SendMessage(string message) => SendMessageUnsafe(ValidateMessage(message));
{
var bytes = ValidateMessage(message);
SendMessageUnsafe(bytes);
}
// Validation split out so the deterministic checks (UTF-8 length, sanitise // sanitiserOverride allows xUnit to bypass Utf8String->SanitizeString (game memory only).
// round-trip) can run in xUnit without ClientStructs game memory. The // Returns encoded bytes so SendMessage avoids a second GetBytes call.
// sanitiser is injectable so tests can pin throw behaviour without invoking
// Utf8String->SanitizeString, which only resolves in-process. Returns the
// already-encoded bytes so SendMessage doesn't pay GetBytes twice.
// TEST-MIRROR: ../../../Hellion Build test/GameFunctions/ChatBoxTests.cs // TEST-MIRROR: ../../../Hellion Build test/GameFunctions/ChatBoxTests.cs
internal static byte[] ValidateMessage( internal static byte[] ValidateMessage(
string message, string message,
@@ -49,11 +42,9 @@ public unsafe class ChatBox
private static string SanitiseText(string text) private static string SanitiseText(string text)
{ {
var uText = Utf8String.FromString(text); var uText = Utf8String.FromString(text);
uText->SanitizeString((AllowedEntities)0x27F); uText->SanitizeString((AllowedEntities)0x27F);
var sanitised = uText->ToString(); var sanitised = uText->ToString();
uText->Dtor(true); uText->Dtor(true);
return sanitised; return sanitised;
} }
} }
+18 -55
View File
@@ -47,7 +47,6 @@ internal unsafe class GameFunctions : IDisposable
Chat = new Chat(Plugin); Chat = new Chat(Plugin);
Plugin.GameInteropProvider.InitializeFromAttributes(this); Plugin.GameInteropProvider.InitializeFromAttributes(this);
ResolveTextCommandPlaceholderHook?.Enable(); ResolveTextCommandPlaceholderHook?.Enable();
} }
@@ -55,36 +54,24 @@ internal unsafe class GameFunctions : IDisposable
{ {
Chat.Dispose(); Chat.Dispose();
KeybindManager.Dispose(); KeybindManager.Dispose();
ResolveTextCommandPlaceholderHook?.Dispose(); ResolveTextCommandPlaceholderHook?.Dispose();
Marshal.FreeHGlobal(PlaceholderNamePtr); Marshal.FreeHGlobal(PlaceholderNamePtr);
} }
internal void SendFriendRequest(string name, ushort world) internal void SendFriendRequest(string name, ushort world) =>
{
ListCommand(name, world, "friendlist"); ListCommand(name, world, "friendlist");
}
internal void AddToBlacklist(string name, ushort world) internal void AddToBlacklist(string name, ushort world) => ListCommand(name, world, "blist");
{
ListCommand(name, world, "blist");
}
internal void AddToMuteList(ulong accountId, ulong contentId, string name, short worldId) internal void AddToMuteList(ulong accountId, ulong contentId, string name, short worldId) =>
{
AgentMutelist.Instance()->Add(accountId, contentId, name, worldId); AgentMutelist.Instance()->Add(accountId, contentId, name, worldId);
}
internal void AddToTermsList(SeString content) internal void AddToTermsList(SeString content) =>
{
AgentTermFilter.Instance()->OpenNewFilterWindow(content.EncodeWithNullTerminator()); AgentTermFilter.Instance()->OpenNewFilterWindow(content.EncodeWithNullTerminator());
}
private void ListCommand(string name, ushort world, string commandName) private void ListCommand(string name, ushort world, string commandName)
{ {
var worldRow = Sheets.WorldSheet.GetRow(world); var worldRow = Sheets.WorldSheet.GetRow(world);
ReplacementName = $"{name}@{worldRow.Name.ToString()}"; ReplacementName = $"{name}@{worldRow.Name.ToString()}";
ChatBox.SendMessage($"/{commandName} add {Placeholder}"); ChatBox.SendMessage($"/{commandName} add {Placeholder}");
} }
@@ -108,7 +95,6 @@ internal unsafe class GameFunctions : IDisposable
{ {
for (var i = 0; i < 4; i++) for (var i = 0; i < 4; i++)
SetAddonInteractable($"ChatLogPanel_{i}", interactable); SetAddonInteractable($"ChatLogPanel_{i}", interactable);
SetAddonInteractable("ChatLog", interactable); SetAddonInteractable("ChatLog", interactable);
} }
@@ -124,7 +110,6 @@ internal unsafe class GameFunctions : IDisposable
var agent = AgentItemDetail.Instance(); var agent = AgentItemDetail.Instance();
var addon = GetAddon<AtkUnitBase>("ItemDetail"); var addon = GetAddon<AtkUnitBase>("ItemDetail");
// atkStage ain't gonna be null or we have bigger problems
if (agent == null || addon == null) if (agent == null || addon == null)
return; return;
@@ -133,23 +118,19 @@ internal unsafe class GameFunctions : IDisposable
agent->Index = 0; agent->Index = 0;
agent->Flag1 &= 0xEF; agent->Flag1 &= 0xEF;
agent->ItemId = id; agent->ItemId = id;
// agent->Flag2 = 1;
// agent->Flag3 = 0; // TODO: Revert when CS offset lands in a release build.
// TODO: Revert whenever CS is merged
*(byte*)((nint)agent + 0x21A) = 1; *(byte*)((nint)agent + 0x21A) = 1;
*(byte*)((nint)agent + 0x21E) = 0; *(byte*)((nint)agent + 0x21E) = 0;
// This just probably needs to be set
agent->AddonId = addon->Id; agent->AddonId = addon->Id;
// Skips early return
atkStage->TooltipManager.TooltipType |= 2; atkStage->TooltipManager.TooltipType |= 2;
addon->Show(false, 15); addon->Show(false, 15);
} }
internal static void CloseItemTooltip() internal static void CloseItemTooltip()
{ {
// hide addon first to prevent the "addon close" sound // Hide addon first to suppress the "addon close" sound.
var addon = GetAddon<AtkUnitBase>("ItemDetail"); var addon = GetAddon<AtkUnitBase>("ItemDetail");
if (addon != null) if (addon != null)
addon->Hide(true, false, 0); addon->Hide(true, false, 0);
@@ -167,7 +148,7 @@ internal unsafe class GameFunctions : IDisposable
internal static void OpenPartyFinder() internal static void OpenPartyFinder()
{ {
// this whole method: 6.05: 84433A (FF 97 ?? ?? ?? ?? 41 B4 01) // 6.05: 84433A (FF 97 ?? ?? ?? ?? 41 B4 01)
var lfg = AgentLookingForGroup.Instance(); var lfg = AgentLookingForGroup.Instance();
if (lfg->IsAgentActive()) if (lfg->IsAgentActive())
{ {
@@ -188,15 +169,10 @@ internal unsafe class GameFunctions : IDisposable
} }
} }
internal static bool IsMentor() internal static bool IsMentor() => PlayerState.Instance()->IsMentor();
{
return PlayerState.Instance()->IsMentor();
}
internal static InfoProxyCommonList.CharacterData[] GetFriends() internal static InfoProxyCommonList.CharacterData[] GetFriends() =>
{ InfoProxyFriendList.Instance()->CharDataSpan.ToArray();
return InfoProxyFriendList.Instance()->CharDataSpan.ToArray();
}
internal static void OpenQuestLog(RowRef<Quest> quest) internal static void OpenQuestLog(RowRef<Quest> quest)
{ {
@@ -223,20 +199,12 @@ internal unsafe class GameFunctions : IDisposable
AgentQuestJournal.Instance()->OpenForQuest(questId, 1); AgentQuestJournal.Instance()->OpenForQuest(questId, 1);
} }
internal static void OpenPartyFinder(uint id) internal static void OpenPartyFinder(uint id) =>
{
AgentLookingForGroup.Instance()->OpenListing(id); AgentLookingForGroup.Instance()->OpenListing(id);
}
internal static void OpenAchievement(uint id) internal static void OpenAchievement(uint id) => AgentAchievement.Instance()->OpenById(id);
{
AgentAchievement.Instance()->OpenById(id);
}
internal static bool IsInInstance() internal static bool IsInInstance() => Plugin.Condition[ConditionFlag.BoundByDuty56];
{
return Plugin.Condition[ConditionFlag.BoundByDuty56];
}
internal static bool TryOpenAdventurerPlate(ulong playerId) internal static bool TryOpenAdventurerPlate(ulong playerId)
{ {
@@ -255,8 +223,7 @@ internal unsafe class GameFunctions : IDisposable
internal static void ClickNoviceNetworkButton() internal static void ClickNoviceNetworkButton()
{ {
var agent = AgentChatLog.Instance(); var agent = AgentChatLog.Instance();
// case 3 var value = new AtkValue { Type = ValueType.Int, Int = 3 }; // case 3
var value = new AtkValue { Type = ValueType.Int, Int = 3 };
var result = 0; var result = 0;
var vf0 = *(delegate* unmanaged<AgentChatLog*, int*, AtkValue*, ulong, ulong, int*>*) var vf0 = *(delegate* unmanaged<AgentChatLog*, int*, AtkValue*, ulong, ulong, int*>*)
agent->VirtualTable; agent->VirtualTable;
@@ -275,9 +242,8 @@ internal unsafe class GameFunctions : IDisposable
byte a4 byte a4
) )
{ {
// The detour is only invoked through the hook, so the hook should // Hook field is nullable due to the Signature attribute, but will never
// never be null here, but the nullable field declaration forces us // be null during normal execution; guard covers the teardown race only.
// to handle the theoretical race during teardown.
if (ResolveTextCommandPlaceholderHook is null) if (ResolveTextCommandPlaceholderHook is null)
return nint.Zero; return nint.Zero;
@@ -285,9 +251,7 @@ internal unsafe class GameFunctions : IDisposable
if (ReplacementName == null || placeholder != Placeholder) if (ReplacementName == null || placeholder != Placeholder)
return ResolveTextCommandPlaceholderHook.Original(a1, placeholderText, a3, a4); return ResolveTextCommandPlaceholderHook.Original(a1, placeholderText, a3, a4);
// The fixed buffer is 128 bytes; UTF-8 + null-terminator must fit. // Guard against a malformed ReplacementName overflowing the 128-byte buffer.
// FFXIV player names plus an @World suffix should never approach this
// limit, but a malformed ReplacementName must not overflow the buffer.
var byteCount = System.Text.Encoding.UTF8.GetByteCount(ReplacementName); var byteCount = System.Text.Encoding.UTF8.GetByteCount(ReplacementName);
if (byteCount >= PlaceholderBufferSize) if (byteCount >= PlaceholderBufferSize)
{ {
@@ -300,7 +264,6 @@ internal unsafe class GameFunctions : IDisposable
MemoryHelper.WriteString(PlaceholderNamePtr, ReplacementName); MemoryHelper.WriteString(PlaceholderNamePtr, ReplacementName);
ReplacementName = null; ReplacementName = null;
return PlaceholderNamePtr; return PlaceholderNamePtr;
} }
} }
+37 -142
View File
@@ -1,57 +1,26 @@
name: Hellion Chat name: Hellion Chat
author: JonKazama-Hellion author: Jon Kazama (Hellion Forge)
punchline: Chat replacement with privacy controls aligned to EU, US and JP rules — based on Chat 2 (EUPL-1.2) punchline: A Hellion Forge plugin — privacy-focused chat replacement for FFXIV, built for EU, US and JP data rules.
description: |- description: |-
Hellion Chat is a privacy-focused chat replacement for FINAL FANTASY XIV Chat replacement for FINAL FANTASY XIV with privacy controls built around
based on the Chat 2 codebase (EUPL-1.2). One feature is intentionally EU, US and JP data-protection rules.
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 By default only your own conversations are stored. Public chat, NPC
designed to align with the modern data protection rules that apply dialogue and system messages stay out of the database unless you opt in.
across the EU, the United States and Japan. By default only your own Retention windows are configurable per channel, history can be wiped
conversations are stored; messages from strangers, NPCs and system retroactively, and everything can be exported on demand.
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:
Features:
- Channel whitelist with a Privacy-First default - Channel whitelist with a Privacy-First default
- Per-channel retention with a daily background sweep - Per-channel retention with a daily background sweep
- Retroactive cleanup with a Ctrl+Shift confirm - Retroactive cleanup (Ctrl+Shift confirm)
- Export to Markdown, JSON or CSV - Export to Markdown, JSON or CSV
- First-run wizard with three preset profiles (Privacy-First, Casual, - First-run wizard with three preset profiles
Full History) - Bilingual UI (EN/DE) with live language switching
- Bilingual UI (English and German) with live language switching - Own config and database — no shared state with other plugins
- Independent plugin state — own config file and database directory,
so Hellion Chat does not share state with upstream Chat 2
v1.4.2 — ChatLog Frame-Hot-Path. Three per-frame allocation Based on Chat 2 by Infi and Anna (EUPL-1.2).
patterns gone from the chat-log render path: card-mode borders Support: https://discord.gg/X9V7Kcv5gR
hoist invariants out of the per-message loop, auto-tell tab
tint and icon get a per-tab cache, and the status bar gates
its tab aggregation behind the same one-second cache it uses
for the format strings.
v1.4.3 — Plugin-Load Async-Init plus Repo-Cutover. Plugin
migrated to Dalamud's IAsyncDalamudPlugin so the heavy work
(migrations, service allocations, window construction, hook
subscription) runs in LoadAsync without blocking Dalamud's
UI. Schema-gate replaces the v9 → v16 migration chain;
configs on schema v16+ load directly. Custom-repo URL moves
to gitea.hellion-forge.cloud, the GitHub repo stays as a
frozen v1.4.2 snapshot.
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://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat repo_url: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat
accepts_feedback: true accepts_feedback: true
icon_url: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/icon.png icon_url: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/icon.png
@@ -66,104 +35,30 @@ tags:
- Replacement - Replacement
- Privacy - Privacy
changelog: |- changelog: |-
**Hellion Chat 1.4.3 — Plugin-Load Async-Init + Repo-Cutover (2026-05-08)** **v1.4.3 — Faster plugin load + new repo (2026-05-08)**
Plugin lifecycle migrated to Dalamud's `IAsyncDalamudPlugin` Heavy startup work (migrations, hooks, windows) now runs async so
API. The constructor now does only the bootstrap-essentials Dalamud's UI stays responsive during load. Load time is comparable
(config load, language init, conflict detection); migrations, to v1.4.2 — this is the foundation for v1.4.4 optimisations.
service allocations, window construction and hook subscription
move to LoadAsync. Dalamud can keep its UI responsive while the
heavy work runs.
- IAsyncDalamudPlugin two-phase load with per-line CaptureFailure - Two-phase async load via IAsyncDalamudPlugin
in DisposeAsync (mirrors LightlessSync's pattern); idempotency - Schema-gate replaces the v9→v16 migration chain; old configs
guard protects against reload races require a v1.4.2 install first
- Schema-gate replaces the v9 → v16 migration chain. Configs - AutoTranslate cache loads on first use instead of every startup
on schema v16+ load directly; older configs trigger an - Custom font (Hellion-Exo2) appears with a brief pop after load
"install v1.4.2 first" error so the historic migration - Repo moved to gitea.hellion-forge.cloud — update your custom-repo URL
path stays intact
- AutoTranslate.PreloadCache moved off the load path. First
use may have a sub-second hitch instead of every-load; the
upstream chose differently, we accept first-use latency
- FontManager.BuildFonts is called sync at the start of
LoadAsync; Dalamud rebuilds the font atlas on its own
pipeline so the custom Hellion-Exo2 font appears with a
brief font-pop after load (matches ChatTwo's behaviour)
- Custom-repo URL moved to gitea.hellion-forge.cloud/
JonKazama-Hellion/HellionChat. GitHub repo stays as a
frozen v1.4.2 snapshot; new releases ship from Gitea.
Existing testers need to update the custom-repo URL once
- Plugin-load time in this release sits at ~3.7 s median
(5 reloads), comparable to v1.4.2. Async migration is
foundational for v1.4.4 Lazy-Init optimisations rather
than an immediate user-perceived win
Modding & support: join Hellion Forge — https://discord.gg/X9V7Kcv5gR
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
**Hellion Chat 1.4.2 — ChatLog Frame-Hot-Path**
Third sub-patch of the v1.4.x Polish Sweep series. Per-frame
allocations from the chat-log render path eliminated.
- DrawMessages card-mode hoists theme/drawList/winLeft/winRight/
borderColorAbgr out of the per-message loop. About 500
redundant calls per frame at 100 visible messages, multiplied
by every pop-out window
- Auto-tell tab tint and icon use a per-tab cache. Hash
computation and string allocation only happen when the tell
target name or world drifts. AutoTellTabTint stays a pure
hash helper; cache lives in a thin TabTintCache wrapper
- Status bar gates its tab aggregation behind the same
one-second cache it already used for the format strings.
LINQ Sum and Count replaced with a single foreach pass
that runs on roughly 1% of frames
Realistic frame-time recovery: 2-5% in typical scenes, more
on pop-out-heavy setups because the card-border hoist scales
per window.
Modding & support: join Hellion Forge — https://discord.gg/X9V7Kcv5gR
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
**Hellion Chat 1.4.1 — Theme Engine Performance**
Second sub-patch of the v1.4.x Polish Sweep series. Heap
pressure from the theme engine's per-frame render path
removed, plus a tenth built-in theme and hardening for
the custom-theme hot-reload.
- Theme records carry a pre-computed ABGR-packed cache
for every color slot; cache is filled when the theme
is registered and refreshed defensively on every
Switch()
- HellionStyle.PushGlobal reads ABGR values from the
cache instead of calling ColourUtil.RgbaToAbgr per
slot per frame; ~13 % render-time recovery measured
in typical scenes (plan estimate was 26 %, real
~1015 %)
- ThemeRegistry custom-theme reload distinguishes a
recoverable file lock (editor mid-save) from a
permanent IO failure; locked themes keep their
last-known-good snapshot and retry on the next
lookup instead of dropping out of the picker
- New built-in: Synthwave Sunset — Hot Magenta + Cyan
on midnight violet, 80s neon-grid vibes; tenth theme
in the picker
- Author credits refreshed: brand themes are credited
as "Hellion Forge"; Mint Grove and Forge Merchantman
now credited to Carla Beleandis as a community thanks
No schema bump, no user-visible behaviour change other
than smoother frames on GC-sensitive setups and one
additional colour option.
Modding & support: join Hellion Forge — https://discord.gg/X9V7Kcv5gR
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
--- ---
Earlier history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases **v1.4.2 — Smoother frames in the chat log**
Per-frame allocations in the chat-log render path eliminated.
25% frame-time recovery in typical scenes, more on pop-out-heavy setups.
- Card-mode: theme/border invariants hoisted out of the per-message loop
- Auto-tell tab tint and icon cached per tab
- Status bar aggregation runs on ~1% of frames instead of every frame
---
Full history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases
+8 -19
View File
@@ -26,10 +26,9 @@ public sealed class ExtraChat : IDisposable
internal (string, uint)? ChannelOverride { get; set; } internal (string, uint)? ChannelOverride { get; set; }
// Volatile reference: IPC callbacks (OnChannelCommandColours/OnChannelNames) fire on a // volatile: IPC callbacks fire on a Dalamud thread while ImGui reads these.
// Dalamud-dispatcher thread while the ImGui thread reads the IReadOnlyDictionary projections. // Reference assignment is atomic on x64, but the barrier ensures visibility
// Reference assignment is atomic on x64, but the JIT (especially Mono on Wine/Linux) needs // across threads (especially Mono/Wine). See AUDIT-2026-05-05 [SEC-01].
// the volatile barrier to guarantee visibility across threads. See AUDIT-2026-05-05 [SEC-01].
private volatile Dictionary<string, uint> ChannelCommandColoursInternal = new(); private volatile Dictionary<string, uint> ChannelCommandColoursInternal = new();
internal IReadOnlyDictionary<string, uint> ChannelCommandColours => internal IReadOnlyDictionary<string, uint> ChannelCommandColours =>
ChannelCommandColoursInternal; ChannelCommandColoursInternal;
@@ -54,6 +53,7 @@ public sealed class ExtraChat : IDisposable
OverrideChannelGate.Subscribe(OnOverrideChannel); OverrideChannelGate.Subscribe(OnOverrideChannel);
ChannelCommandColoursGate.Subscribe(OnChannelCommandColours); ChannelCommandColoursGate.Subscribe(OnChannelCommandColours);
ChannelNamesGate.Subscribe(OnChannelNames); ChannelNamesGate.Subscribe(OnChannelNames);
try try
{ {
ChannelCommandColoursInternal = ChannelCommandColoursGate.InvokeFunc(null!); ChannelCommandColoursInternal = ChannelCommandColoursGate.InvokeFunc(null!);
@@ -61,7 +61,7 @@ public sealed class ExtraChat : IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
// ExtraChat is optional; missing IPC peer is normal when the plugin isn't loaded. // ExtraChat is optional; IPC failure is normal when the plugin isn't loaded.
Plugin.Log.Verbose(ex, "ExtraChat IPC initial state query failed (peer not loaded?)"); Plugin.Log.Verbose(ex, "ExtraChat IPC initial state query failed (peer not loaded?)");
} }
} }
@@ -75,22 +75,11 @@ public sealed class ExtraChat : IDisposable
private void OnOverrideChannel(OverrideInfo info) private void OnOverrideChannel(OverrideInfo info)
{ {
if (info.Channel == null) ChannelOverride = info.Channel == null ? null : (info.Channel, info.Rgba);
{
ChannelOverride = null;
return;
} }
ChannelOverride = (info.Channel, info.Rgba); private void OnChannelCommandColours(Dictionary<string, uint> obj) =>
}
private void OnChannelCommandColours(Dictionary<string, uint> obj)
{
ChannelCommandColoursInternal = obj; ChannelCommandColoursInternal = obj;
}
private void OnChannelNames(Dictionary<Guid, string> obj) private void OnChannelNames(Dictionary<Guid, string> obj) => ChannelNamesInternal = obj;
{
ChannelNamesInternal = obj;
}
} }
+52 -204
View File
@@ -127,7 +127,6 @@ internal class MessageStore : IDisposable
private const int MessageQueryLimit = 10_000; private const int MessageQueryLimit = 10_000;
private string DbPath { get; } private string DbPath { get; }
private SqliteConnection Connection { get; set; } private SqliteConnection Connection { get; set; }
internal static readonly MessagePackSerializerOptions MsgPackOptions = internal static readonly MessagePackSerializerOptions MsgPackOptions =
@@ -147,10 +146,8 @@ internal class MessageStore : IDisposable
public void Dispose() public void Dispose()
{ {
// Pooling=false (set in Connect) avoids ClearAllPools, which is // Pooling=false avoids ClearAllPools which is provider-wide and
// provider-wide and would touch other plugins' SQLite connections. // would touch other plugins' SQLite connections.
// GC.Collect was here as a defensive flush; removed because explicit
// Close already releases everything we hold.
Connection.Close(); Connection.Close();
Connection.Dispose(); Connection.Dispose();
} }
@@ -176,7 +173,6 @@ internal class MessageStore : IDisposable
private void Migrate() private void Migrate()
{ {
// Get current user_version.
using var cmd = Connection.CreateCommand(); using var cmd = Connection.CreateCommand();
cmd.CommandText = "PRAGMA user_version;"; cmd.CommandText = "PRAGMA user_version;";
var userVersion = Convert.ToInt32(cmd.ExecuteScalar()); var userVersion = Convert.ToInt32(cmd.ExecuteScalar());
@@ -186,9 +182,7 @@ internal class MessageStore : IDisposable
{ {
case <= 0: case <= 0:
migrationsToDo.Add(Migrate0); migrationsToDo.Add(Migrate0);
// Migration support was only added in version 1. Migrate0 is idempotent.
// Migration support was only added in version 1. Migrate 0 is
// idempotent.
migrationsToDo.Add(Migrate1); migrationsToDo.Add(Migrate1);
migrationsToDo.Add(Migrate2); migrationsToDo.Add(Migrate2);
migrationsToDo.Add(Migrate3); migrationsToDo.Add(Migrate3);
@@ -238,7 +232,6 @@ internal class MessageStore : IDisposable
Plugin.Log.Information("Running migration 1: Adding Deleted column"); Plugin.Log.Information("Running migration 1: Adding Deleted column");
Connection.Execute( Connection.Execute(
@" @"
-- Migration 1: Add Deleted column
ALTER TABLE messages ADD COLUMN Deleted BOOLEAN NOT NULL DEFAULT false; ALTER TABLE messages ADD COLUMN Deleted BOOLEAN NOT NULL DEFAULT false;
" "
); );
@@ -251,7 +244,6 @@ internal class MessageStore : IDisposable
Plugin.Log.Information("Running migration 2: Adding Channel generated column"); Plugin.Log.Information("Running migration 2: Adding Channel generated column");
Connection.Execute( Connection.Execute(
@" @"
-- Migration 2: Add Channel generated column
ALTER TABLE messages ADD COLUMN Channel INTEGER GENERATED ALWAYS AS (Code & 0x7f) VIRTUAL; ALTER TABLE messages ADD COLUMN Channel INTEGER GENERATED ALWAYS AS (Code & 0x7f) VIRTUAL;
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages (Channel); CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages (Channel);
" "
@@ -262,9 +254,8 @@ internal class MessageStore : IDisposable
private bool ColumnExists(string table, string column) private bool ColumnExists(string table, string column)
{ {
// PRAGMA does not accept SQLite parameter bindings. The table name is // PRAGMA does not accept SQLite parameter bindings. Table name is a
// a compile-time constant fed in from internal call sites, so the // compile-time constant from internal call sites only.
// interpolation cannot be reached from any user-controlled path.
using var cmd = Connection.CreateCommand(); using var cmd = Connection.CreateCommand();
cmd.CommandText = $"PRAGMA table_info({table});"; cmd.CommandText = $"PRAGMA table_info({table});";
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();
@@ -280,9 +271,8 @@ internal class MessageStore : IDisposable
{ {
Plugin.Log.Information("Running migration 3: Fix log kinds to fit the new format"); Plugin.Log.Information("Running migration 3: Fix log kinds to fit the new format");
// Recovery for partially-applied Migrate3: if the schema is already // Recovery for partially-applied Migrate3: schema already in target
// in its target shape (new columns exist, old Code column gone) but // shape but user_version was never bumped -- just record and exit.
// user_version was never bumped, just record the version and exit.
if (ColumnExists("messages", "ChatType") && !ColumnExists("messages", "Code")) if (ColumnExists("messages", "ChatType") && !ColumnExists("messages", "Code"))
{ {
Plugin.Log.Information( Plugin.Log.Information(
@@ -294,15 +284,6 @@ internal class MessageStore : IDisposable
Connection.Execute( 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; ALTER TABLE messages ADD COLUMN ChatType INTEGER;
CREATE INDEX IF NOT EXISTS idx_messages_chat_type ON messages (ChatType); CREATE INDEX IF NOT EXISTS idx_messages_chat_type ON messages (ChatType);
ALTER TABLE messages ADD COLUMN SourceKind INTEGER; ALTER TABLE messages ADD COLUMN SourceKind INTEGER;
@@ -328,10 +309,8 @@ internal class MessageStore : IDisposable
{ {
Plugin.Log.Information($"Setting version {version}"); Plugin.Log.Information($"Setting version {version}");
using var cmd = Connection.CreateCommand(); using var cmd = Connection.CreateCommand();
// PRAGMA does not accept SQLite parameter bindings, and there is no // PRAGMA does not accept SQLite parameter bindings; version is a
// pragma_ function variant that can set the version either. The // compile-time int from the migration sequence, never user input.
// version is a compile-time int from the migration sequence, never
// user input.
cmd.CommandText = $"PRAGMA user_version = {version};"; cmd.CommandText = $"PRAGMA user_version = {version};";
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
@@ -342,11 +321,8 @@ internal class MessageStore : IDisposable
PerformMaintenance(); PerformMaintenance();
} }
/// <summary> // Returns a (ChatType, count) snapshot over non-deleted messages.
/// Returns a (ChatType, count) snapshot over non-deleted messages. // Used by the Privacy tab to preview retroactive cleanup impact.
/// Used by the Privacy tab to preview the impact of a retroactive
/// cleanup before the user confirms.
/// </summary>
internal Dictionary<int, long> GetMessageCountsByChatType() internal Dictionary<int, long> GetMessageCountsByChatType()
{ {
var result = new Dictionary<int, long>(); var result = new Dictionary<int, long>();
@@ -364,12 +340,9 @@ internal class MessageStore : IDisposable
return result; return result;
} }
/// <summary> // Deletes messages older than the per-channel retention window, with a global
/// Deletes messages older than the per-channel retention window, with a // default for unmapped channels. Runs VACUUM only if rows were removed.
/// global default for channels not listed explicitly. Cutoffs are // Returns the number of rows deleted.
/// computed from "now" at call time. Runs VACUUM only if anything was
/// removed. Returns the number of rows deleted.
/// </summary>
internal long DeleteByRetentionPolicy( internal long DeleteByRetentionPolicy(
IReadOnlyDictionary<int, int> chatTypeDaysMap, IReadOnlyDictionary<int, int> chatTypeDaysMap,
int defaultDays int defaultDays
@@ -408,10 +381,7 @@ internal class MessageStore : IDisposable
index++; index++;
} }
// Catch-all for channels without an explicit override. "0" is // defaultDays=0 means "keep forever" for unmapped channels.
// treated as "do not delete by default" — without an explicit
// user override, unmapped channels stay forever instead of
// getting wiped immediately.
if (defaultDays > 0) if (defaultDays > 0)
{ {
var defaultCutoff = nowMs - defaultDays * 86400000L; var defaultCutoff = nowMs - defaultDays * 86400000L;
@@ -439,21 +409,14 @@ internal class MessageStore : IDisposable
return deleted; return deleted;
} }
/// <summary> // Hard-deletes every message whose ChatType is not in the allowlist,
/// Hard-deletes every message whose ChatType is not in the supplied // then VACUUMs. Returns the number of rows deleted.
/// allowlist, then VACUUMs the database to reclaim disk space.
/// Returns the number of rows deleted.
/// </summary>
internal long CleanupRetainOnly(IReadOnlyCollection<int> allowedTypes) internal long CleanupRetainOnly(IReadOnlyCollection<int> allowedTypes)
{ {
if (allowedTypes.Count == 0) 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( throw new InvalidOperationException(
"CleanupRetainOnly requires at least one allowed ChatType. Use ClearMessages for a full wipe." "CleanupRetainOnly requires at least one allowed ChatType. Use ClearMessages for a full wipe."
); );
}
long deleted; long deleted;
using (var cmd = Connection.CreateCommand()) using (var cmd = Connection.CreateCommand())
@@ -493,14 +456,9 @@ internal class MessageStore : IDisposable
internal void UpsertMessage(Message message) internal void UpsertMessage(Message message)
{ {
// Hellion Chat privacy filter drop disallowed ChatTypes before // Privacy filter -- drop disallowed ChatTypes before they reach storage.
// 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)) if (!Plugin.Config.IsAllowedForStorage(message.Code.Type))
{ {
// Verbose-only: this fires for every dropped message, which is
// the common case for users with a tight privacy whitelist. Keep
// it for diagnostics but stay out of the default xllog stream.
Plugin.Log.Verbose($"Privacy filter dropped message: ChatType={message.Code.Type}"); Plugin.Log.Verbose($"Privacy filter dropped message: ChatType={message.Code.Type}");
return; return;
} }
@@ -509,33 +467,11 @@ internal class MessageStore : IDisposable
cmd.CommandText = cmd.CommandText =
@" @"
INSERT INTO messages ( INSERT INTO messages (
Id, Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind,
Receiver, Sender, Content, SenderSource, ContentSource, ExtraChatChannel, Deleted
ContentId,
Date,
ChatType,
SourceKind,
TargetKind,
Sender,
Content,
SenderSource,
ContentSource,
ExtraChatChannel,
Deleted
) VALUES ( ) VALUES (
$Id, $Id, $Receiver, $ContentId, $Date, $ChatType, $SourceKind, $TargetKind,
$Receiver, $Sender, $Content, $SenderSource, $ContentSource, $ExtraChatChannel, false
$ContentId,
$Date,
$ChatType,
$SourceKind,
$TargetKind,
$Sender,
$Content,
$SenderSource,
$ContentSource,
$ExtraChatChannel,
false
) )
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
Receiver = excluded.Receiver, Receiver = excluded.Receiver,
@@ -580,13 +516,9 @@ internal class MessageStore : IDisposable
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
/// <summary> // Streams messages for export, sorted ascending by Date, excluding soft-deleted rows.
/// Streams messages for export. Optional filters: // Optional filters: chatTypes, from/to inclusive date range.
/// - <paramref name="chatTypes"/>: limit to these ChatTypes // Caller is responsible for disposing the enumerator.
/// - <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( internal MessageEnumerator StreamForExport(
IReadOnlyCollection<int>? chatTypes, IReadOnlyCollection<int>? chatTypes,
DateTimeOffset? from, DateTimeOffset? from,
@@ -606,18 +538,8 @@ internal class MessageStore : IDisposable
cmd.CommandText = cmd.CommandText =
@" @"
SELECT SELECT
Id, Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind,
Receiver, Sender, Content, SenderSource, ContentSource, ExtraChatChannel
ContentId,
Date,
ChatType,
SourceKind,
TargetKind,
Sender,
Content,
SenderSource,
ContentSource,
ExtraChatChannel
FROM messages FROM messages
WHERE " WHERE "
+ string.Join(" AND ", clauses) + string.Join(" AND ", clauses)
@@ -633,12 +555,10 @@ internal class MessageStore : IDisposable
return new MessageEnumerator(cmd.ExecuteReader()); return new MessageEnumerator(cmd.ExecuteReader());
} }
/// <summary> // Returns the most recent messages, oldest-first.
/// Get the most recent messages. // receiver: filter by receiver ContentId (null = no filter)
/// </summary> // since: only include messages after this date (null = no filter)
/// <param name="receiver">The receiver content ID to filter by. If null, no filtering is performed.</param> // count: max rows to return, defaults to 10,000
/// <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( internal MessageEnumerator GetMostRecentMessages(
ulong? receiver = null, ulong? receiver = null,
DateTimeOffset? since = null, DateTimeOffset? since = null,
@@ -654,25 +574,14 @@ internal class MessageStore : IDisposable
var whereClause = "WHERE " + string.Join(" AND ", whereClauses); var whereClause = "WHERE " + string.Join(" AND ", whereClauses);
var cmd = Connection.CreateCommand(); var cmd = Connection.CreateCommand();
// Select last N messages by date DESC, but reverse the order to get // Select last N by date DESC, then reverse to ascending order.
// them in ascending order.
cmd.CommandText = cmd.CommandText =
@" @"
SELECT * SELECT *
FROM ( FROM (
SELECT SELECT
Id, Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind,
Receiver, Sender, Content, SenderSource, ContentSource, ExtraChatChannel
ContentId,
Date,
ChatType,
SourceKind,
TargetKind,
Sender,
Content,
SenderSource,
ContentSource,
ExtraChatChannel
FROM messages FROM messages
" "
+ whereClause + whereClause
@@ -682,7 +591,7 @@ internal class MessageStore : IDisposable
) )
ORDER BY Date ASC; ORDER BY Date ASC;
"; ";
cmd.CommandTimeout = 120; // this could take a while on slow computers cmd.CommandTimeout = 120;
if (receiver != null) if (receiver != null)
cmd.Parameters.AddWithValue("$Receiver", receiver); cmd.Parameters.AddWithValue("$Receiver", receiver);
@@ -694,21 +603,10 @@ internal class MessageStore : IDisposable
return new MessageEnumerator(cmd.ExecuteReader()); return new MessageEnumerator(cmd.ExecuteReader());
} }
/// <summary> // Returns up to limit tells exchanged with the named player, oldest-first.
/// Hellion Chat — Auto-Tell-Tabs history preload. // SQL narrows by Receiver + ChatType (indexed); client does the final
/// // PlayerPayload comparison. sqlScanLimit caps the scan to stay within
/// Returns up to <paramref name="limit"/> tells exchanged with the named // the message-processing worker thread budget.
/// 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( internal IReadOnlyList<Message> GetTellHistoryWithSender(
ulong receiver, ulong receiver,
string senderName, string senderName,
@@ -718,26 +616,14 @@ internal class MessageStore : IDisposable
) )
{ {
if (limit <= 0) if (limit <= 0)
{
return []; return [];
}
using var cmd = Connection.CreateCommand(); using var cmd = Connection.CreateCommand();
cmd.CommandText = cmd.CommandText =
@" @"
SELECT SELECT
Id, Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind,
Receiver, Sender, Content, SenderSource, ContentSource, ExtraChatChannel
ContentId,
Date,
ChatType,
SourceKind,
TargetKind,
Sender,
Content,
SenderSource,
ContentSource,
ExtraChatChannel
FROM messages FROM messages
WHERE deleted = false WHERE deleted = false
AND Receiver = $Receiver AND Receiver = $Receiver
@@ -756,27 +642,19 @@ internal class MessageStore : IDisposable
foreach (var message in enumerator) foreach (var message in enumerator)
{ {
if (!ChunkUtil.MatchesSender(message, senderName, senderWorld)) if (!ChunkUtil.MatchesSender(message, senderName, senderWorld))
{
continue; continue;
}
collected.Add(message); collected.Add(message);
if (collected.Count >= limit) if (collected.Count >= limit)
{
break; break;
} }
}
// SQL was DESC (newest-first) so we hit the limit on the most // SQL was DESC (newest-first); reverse to oldest-first for tab display.
// recent matching tells. Reverse to oldest-first for chronological
// display in the tab.
collected.Reverse(); collected.Reverse();
return collected; return collected;
} }
/// <summary> // Soft-deletes a message so it won't appear in queries.
/// Marks a message as deleted so it won't get returned in queries.
/// </summary>
internal void DeleteMessage(Guid id) internal void DeleteMessage(Guid id)
{ {
using var cmd = Connection.CreateCommand(); using var cmd = Connection.CreateCommand();
@@ -803,8 +681,6 @@ internal class MessageStore : IDisposable
var whereClause = "WHERE " + string.Join(" AND ", whereClauses); 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 = cmd.CommandText =
@" @"
SELECT COUNT(*) SELECT COUNT(*)
@@ -816,7 +692,7 @@ internal class MessageStore : IDisposable
cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds()); cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset)before).ToUnixTimeMilliseconds()); cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset)before).ToUnixTimeMilliseconds());
cmd.CommandTimeout = 120; // this could take a while on slow computers cmd.CommandTimeout = 120;
return (long)cmd.ExecuteScalar()!; return (long)cmd.ExecuteScalar()!;
} }
@@ -839,26 +715,14 @@ internal class MessageStore : IDisposable
var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}"; 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 = cmd.CommandText =
@" @"
SELECT SELECT
Id, Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind,
Receiver, Sender, Content, SenderSource, ContentSource, ExtraChatChannel
ContentId,
Date,
ChatType,
SourceKind,
TargetKind,
Sender,
Content,
SenderSource,
ContentSource,
ExtraChatChannel
FROM messages FROM messages
" + whereClause; " + whereClause;
cmd.CommandTimeout = 120; // this could take a while on slow computers cmd.CommandTimeout = 120;
if (receiver != null) if (receiver != null)
cmd.Parameters.AddWithValue("$Receiver", receiver); cmd.Parameters.AddWithValue("$Receiver", receiver);
@@ -888,23 +752,11 @@ internal class MessageStore : IDisposable
var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}"; 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 = cmd.CommandText =
@" @"
SELECT SELECT
Id, Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind,
Receiver, Sender, Content, SenderSource, ContentSource, ExtraChatChannel
ContentId,
Date,
ChatType,
SourceKind,
TargetKind,
Sender,
Content,
SenderSource,
ContentSource,
ExtraChatChannel
FROM messages FROM messages
" "
+ whereClause + whereClause
@@ -912,7 +764,7 @@ internal class MessageStore : IDisposable
ORDER BY Date ORDER BY Date
LIMIT $Offset, $OffsetCount; LIMIT $Offset, $OffsetCount;
"; ";
cmd.CommandTimeout = 120; // this could take a while on slow computers cmd.CommandTimeout = 120;
if (receiver != null) if (receiver != null)
cmd.Parameters.AddWithValue("$Receiver", receiver); cmd.Parameters.AddWithValue("$Receiver", receiver);
@@ -925,10 +777,8 @@ internal class MessageStore : IDisposable
return new MessageEnumerator(cmd.ExecuteReader()); return new MessageEnumerator(cmd.ExecuteReader());
} }
// Build "$prefix0,$prefix1,..." placeholder list and bind values to // Builds a "$prefix0,$prefix1,..." placeholder list and binds values to the command.
// the command. SQLite has no native array parameter, so we generate // SQLite has no native array parameter, so placeholders are generated per entry.
// 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) private static string BindIntList(SqliteCommand cmd, string prefix, IEnumerable<int> values)
{ {
var names = new List<string>(); var names = new List<string>();
@@ -951,8 +801,6 @@ internal class MessageEnumerator(DbDataReader reader)
{ {
private const int MaxErrorLogs = 10; 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 readonly List<Guid> FailedIds = [];
private int FailedCount; private int FailedCount;
public bool DidError => FailedCount > 0; public bool DidError => FailedCount > 0;
+8 -12
View File
@@ -4,10 +4,9 @@ namespace HellionChat.Privacy;
internal static class PrivacyDefaults internal static class PrivacyDefaults
{ {
// Privacy-First default whitelist (DSGVO Art. 25 - Privacy by Default). // DSGVO Art. 25 (Privacy by Default): only the player's own conversations
// Only the player's own conversations are persisted out-of-the-box. // are persisted out-of-the-box. Public chat, NPC dialogue, system logs and
// Public chat (Say/Shout/Yell), Novice Network, NPC dialogue, system // battle messages require explicit opt-in.
// logs and battle messages are NOT persisted unless the user opts in.
internal static readonly IReadOnlySet<ChatType> PrivacyFirstWhitelist = new HashSet<ChatType> internal static readonly IReadOnlySet<ChatType> PrivacyFirstWhitelist = new HashSet<ChatType>
{ {
ChatType.TellIncoming, ChatType.TellIncoming,
@@ -42,10 +41,8 @@ internal static class PrivacyDefaults
ChatType.ExtraChatLinkshell8, ChatType.ExtraChatLinkshell8,
}; };
// Default retention windows per channel (in days). Channels not listed // Per-channel retention in days. Unlisted channels fall back to
// here fall back to Configuration.RetentionDefaultDays. Reflects the // Configuration.RetentionDefaultDays. Tells: 365, everything else: 90.
// design spec: Tells 365, own-conversation channels 90, everything else
// shorter via the global default.
internal static readonly IReadOnlyDictionary<ChatType, int> DefaultRetentionDays = internal static readonly IReadOnlyDictionary<ChatType, int> DefaultRetentionDays =
new Dictionary<ChatType, int> new Dictionary<ChatType, int>
{ {
@@ -86,10 +83,9 @@ internal static class PrivacyDefaults
[ChatType.ExtraChatLinkshell8] = 90, [ChatType.ExtraChatLinkshell8] = 90,
}; };
// Casual profile = Privacy-First plus public chat (Say/Shout/Yell, both // Casual: Privacy-First + public chat (Say/Shout/Yell, emotes, Novice
// emote types, Novice Network), kept for a short 24-hour window so the // Network) with a 1-day window so recent RP/trade is searchable but
// last RP scene or shout trade is still searchable but third-party data // third-party data doesn't accumulate.
// doesn't accumulate forever.
internal static readonly IReadOnlySet<ChatType> CasualWhitelist = new HashSet<ChatType>( internal static readonly IReadOnlySet<ChatType> CasualWhitelist = new HashSet<ChatType>(
PrivacyFirstWhitelist PrivacyFirstWhitelist
) )
@@ -2,12 +2,7 @@ using HellionChat.Util;
namespace HellionChat.Themes.Builtin; namespace HellionChat.Themes.Builtin;
// Hellion Spectrum: Deuteran/Protan-safe channel colours. // Deuteran/Protan-safe palette with preserved channel identity.
// Palette derived from Bang Wong, "Points of view: Color blindness",
// Nature Methods 8, 441 (2011). Channel identity (Tell pink, Yell yellow,
// Shout orange, Party blue, FC green) is preserved per Channel-Identity-
// Rule in docs/THEME-AUTHORING.md; tones are chosen so every channel
// stays distinguishable under red-green colour-vision deficiency.
internal static class HellionSpectrum internal static class HellionSpectrum
{ {
public const string Slug = "hellion-spectrum"; public const string Slug = "hellion-spectrum";
@@ -57,9 +52,6 @@ internal static class HellionSpectrum
ChatColors: new ThemeChatColors( ChatColors: new ThemeChatColors(
new Dictionary<HellionChat.Code.ChatType, uint> new Dictionary<HellionChat.Code.ChatType, uint>
{ {
// Hellion Spectrum — Wong/Okabe-Ito tones within FFXIV channel
// identity. FC pulled slightly greener than vanilla cyan-teal so
// Party-blue and FC-green stay separable under deuteran sim.
[HellionChat.Code.ChatType.Say] = ColourUtil.HexToRgba("#FFFFFF"), [HellionChat.Code.ChatType.Say] = ColourUtil.HexToRgba("#FFFFFF"),
[HellionChat.Code.ChatType.Yell] = ColourUtil.HexToRgba("#F0E442"), [HellionChat.Code.ChatType.Yell] = ColourUtil.HexToRgba("#F0E442"),
[HellionChat.Code.ChatType.Shout] = ColourUtil.HexToRgba("#D55E00"), [HellionChat.Code.ChatType.Shout] = ColourUtil.HexToRgba("#D55E00"),
+15 -52
View File
@@ -1,34 +1,17 @@
namespace HellionChat.Ui; namespace HellionChat.Ui;
/// <summary> // Deterministic hash-based color and icon tinting for Auto-Tell sidebar tabs.
/// Hash-Color-Tinting für Auto-Tell-Tabs in der Sidebar (v1.2.0). // Same tell partner (name+world) always produces the same color and icon across
/// Differenziert Tells visuell ohne dass User pro Tab manuell ein // sessions. Pure string logic, no Dalamud dependency — testable without game refs.
/// Custom-Icon setzen muss. Gleicher Tell-Partner (Name+World) liefert
/// konsistent dieselbe Farbe über Sessions hinweg.
///
/// Kuratierte 12-Farb-Palette aus dem Hellion-Theme-Pool: alle saturiert
/// mid-bright, lesbar gegen Dark-Theme-Backgrounds. Bei realistischen
/// 1-5 parallelen Tells ist Kollisions-Wahrscheinlichkeit gering.
///
/// Reine String-Logik (kein Dalamud-Dep) — testbar im HellionChat.Tests-
/// Projekt das ohne Dalamud-Reference baut.
/// </summary>
internal static class AutoTellTabTint internal static class AutoTellTabTint
{ {
/// <summary> // Fallback for invalid input (empty name or world=0). White matches
/// Fallback bei ungültigem Input (leerer Name, World=0). Standard- // TextPrimary default so the sidebar stays visually consistent.
/// Text-Color (weiß) — passt mit existierendem TextPrimary-Default
/// zusammen, sodass die Sidebar visuell konsistent bleibt.
/// </summary>
public const uint Fallback = 0xFFFFFFFFu; public const uint Fallback = 0xFFFFFFFFu;
/// <summary> // 12 saturated mid-bright colors from the built-in theme pool, readable
/// 12 saturierte mid-bright Farben aus den 5 Built-In-Themes // on dark backgrounds. Collision risk is low at realistic 1-5 active tells.
/// (Hellion-Arctic, Chat2-Klassik, Event-Horizon, Moonlit-Bloom, // RGBA format, matching ColourUtil.RgbaToAbgr convention.
/// Mint-Grove). Reihenfolge ist deterministisch — Hash-Index wählt
/// Farbe per Modulo. RGBA-Format (passt zu ColourUtil.RgbaToAbgr-
/// Konvention im restlichen Code).
/// </summary>
public static readonly IReadOnlyList<uint> Palette = new uint[] public static readonly IReadOnlyList<uint> Palette = new uint[]
{ {
0x00BED2FFu, // Arctic Cyan 0x00BED2FFu, // Arctic Cyan
@@ -45,30 +28,19 @@ internal static class AutoTellTabTint
0xE85D04FFu, // Deep Ember 0xE85D04FFu, // Deep Ember
}; };
/// <summary>
/// Liefert eine konsistente Tint-Color für einen Tell-Partner.
/// Hash basiert auf "Name@World" — Cross-World-Namen kollidieren
/// nur bei Hash-Bucket-Kollision, nicht durch Identitäts-Annahme.
/// </summary>
public static uint For(string name, uint world) public static uint For(string name, uint world)
{ {
if (string.IsNullOrEmpty(name) || world == 0) if (string.IsNullOrEmpty(name) || world == 0)
return Fallback; return Fallback;
// GetHashCode kann negativ sein; Bitmaske auf positive Range // Mask to positive range so modulo always yields a valid index.
// damit Modulo-Division immer einen validen Index liefert.
var key = $"{name}@{world}"; var key = $"{name}@{world}";
var hash = (uint)(key.GetHashCode() & 0x7FFFFFFF); var hash = (uint)(key.GetHashCode() & 0x7FFFFFFF);
return Palette[(int)(hash % Palette.Count)]; return Palette[(int)(hash % Palette.Count)];
} }
/// <summary> // 7 visually distinct FA glyphs that make sense in a tell context.
/// Tell-spezifischer Icon-Pool. 7 visuell distinkte FontAwesome-Glyphen // Excludes cog/comment/users — those read as system or group tabs.
/// die im Tell-Kontext sinnvoll wirken (envelope = Tell-Default, star/
/// heart/bell = personalisiert, bookmark/flag/fire = markiert/wichtig).
/// Bewusst kein cog/comment/users — die wären für System-/Group-Tabs
/// reserviert und würden im Tell-Bereich verwirrend wirken.
/// </summary>
public static readonly IReadOnlyList<string> IconPool = new[] public static readonly IReadOnlyList<string> IconPool = new[]
{ {
"envelope", "envelope",
@@ -80,26 +52,17 @@ internal static class AutoTellTabTint
"fire", "fire",
}; };
/// <summary> // "envelope" matches the tell context better than the old hardcoded "clock".
/// Fallback-Icon bei ungültigem Input. "envelope" passt semantisch zum
/// Tell-Kontext besser als das alte hardcoded "clock".
/// </summary>
public const string IconFallback = "envelope"; public const string IconFallback = "envelope";
/// <summary>
/// Liefert ein konsistentes Icon-Glyph für einen Tell-Partner.
/// Nutzt einen anderen Hash-Bias als For() (Color), damit Icon und
/// Color unabhängig variieren — gibt 7 × 12 = 84 distinct Combinations.
/// </summary>
public static string IconFor(string name, uint world) public static string IconFor(string name, uint world)
{ {
if (string.IsNullOrEmpty(name) || world == 0) if (string.IsNullOrEmpty(name) || world == 0)
return IconFallback; return IconFallback;
// Anderer Hash-Bias als For() (verschiedene Modulo-Basis): wir // Reversed key ("world@name") gives icon and color independent variation
// nutzen "world@name" statt "name@world" damit Icon und Color // so the same tell partner doesn't always get the same color+icon pair.
// nicht synchron variieren. Ohne Bias-Trennung würden alle Tells // 7 icons x 12 colors = 84 distinct combinations.
// mit derselben Color auch dasselbe Icon haben.
var key = $"{world}@{name}"; var key = $"{world}@{name}";
var hash = (uint)(key.GetHashCode() & 0x7FFFFFFF); var hash = (uint)(key.GetHashCode() & 0x7FFFFFFF);
return IconPool[(int)(hash % IconPool.Count)]; return IconPool[(int)(hash % IconPool.Count)];
+23 -48
View File
@@ -8,16 +8,10 @@ using HellionChat.Util;
namespace HellionChat.Ui; namespace HellionChat.Ui;
// Hellion Chat — v0.6.0 input bar component for pop-out windows. // Input bar component for pop-out windows. Render() is a stub — the main
// // window input layer stays in ChatLogWindow to avoid a high-risk extract.
// Pragmatischer Refactor-Scope: Render() ist ein leerer Marker-Stub für // RenderCompact() is the only v0.6.0 deliverable; Render() can be filled
// das Hauptfenster — der bestehende Input-Layer in ChatLogWindow bleibt // in a later cycle if needed.
// 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 public sealed class ChatInputBar
{ {
private readonly Plugin _plugin; private readonly Plugin _plugin;
@@ -35,22 +29,17 @@ public sealed class ChatInputBar
public InputState State => _state; public InputState State => _state;
public bool IsFocused { get; private set; } public bool IsFocused { get; private set; }
// Stub. v0.6.0 belässt den Hauptfenster-Input wie er ist. // Stub — main window input is handled in ChatLogWindow.
public void Render() { } public void Render() { }
// Compact rendering for pop-out windows. // Compact layout for pop-out windows: channel icon button left, text
// input right. Auto-translate is intentionally excluded — the upstream
// popup isn't instanciable per window without a larger refactor, and
// typical pop-out use cases rarely need it. Can be added later if
// tester feedback warrants it.
// //
// v0.6.0 Compact-Layout: Channel-Icon-Button links (Background-Farbe // Channel switching is global via Plugin.Functions.Chat (FFXIV API).
// aus ChatColours), Text-Input rechts daneben. Auto-Translate-Picker // Text buffer and history cursor are independent per pop-out.
// 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() public void RenderCompact()
{ {
var tab = _activeTabAccessor(); var tab = _activeTabAccessor();
@@ -64,18 +53,15 @@ public sealed class ChatInputBar
private void DrawCompactInput(Tab 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; var inputWidth = ImGui.GetContentRegionAvail().X;
if (inputWidth < 60f) if (inputWidth < 60f)
inputWidth = 60f; inputWidth = 60f;
ImGui.SetNextItemWidth(inputWidth); ImGui.SetNextItemWidth(inputWidth);
// CallbackHistory wires up Up/Down navigation against the shared // CallbackHistory wires Up/Down navigation to InputHistoryService.
// InputHistoryService. Submit is detected the same way the main // Submit detected via IsItemDeactivated + Enter, not EnterReturnsTrue
// window does it: via IsItemDeactivated + Enter, NOT EnterReturnsTrue // (matches ChatLogWindow behavior).
// (matching v0.5.x ChatLogWindow.cs behavior).
const ImGuiInputTextFlags flags = ImGuiInputTextFlags.CallbackHistory; const ImGuiInputTextFlags flags = ImGuiInputTextFlags.CallbackHistory;
ImGui.InputText( ImGui.InputText(
$"##chat-compact-input-{tab.Identifier}", $"##chat-compact-input-{tab.Identifier}",
@@ -100,9 +86,8 @@ public sealed class ChatInputBar
private void SubmitCompact(Tab tab) => private void SubmitCompact(Tab tab) =>
CompactInputSubmitter.TrySubmit(_state, tab, _host.SendChatBoxFromExternal); CompactInputSubmitter.TrySubmit(_state, tab, _host.SendChatBoxFromExternal);
// History-navigation callback for the compact input. Cursor math is // History navigation callback. Cursor math delegated to
// delegated to CompactInputHistoryNavigator; only the ImGui buffer // CompactInputHistoryNavigator; ImGui buffer splice stays here.
// splice stays here because it needs the live callback data.
// TEST-MIRROR: ../_Helpers/CompactInputHistoryNavigator.cs // TEST-MIRROR: ../_Helpers/CompactInputHistoryNavigator.cs
private int CompactCallback(scoped ref ImGuiInputTextCallbackData data) private int CompactCallback(scoped ref ImGuiInputTextCallbackData data)
{ {
@@ -148,7 +133,7 @@ public sealed class ChatInputBar
var v3 = ColourUtil.RgbaToVector3(rgba); var v3 = ColourUtil.RgbaToVector3(rgba);
var bg = new Vector4(v3.X, v3.Y, v3.Z, 1f); var bg = new Vector4(v3.X, v3.Y, v3.Z, 1f);
// Compute readable foreground — black on bright, white on dark // Black foreground on bright backgrounds, white on dark.
var luminance = 0.2126f * v3.X + 0.7152f * v3.Y + 0.0722f * v3.Z; 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); var fg = luminance > 0.55f ? new Vector4(0f, 0f, 0f, 1f) : new Vector4(1f, 1f, 1f, 1f);
@@ -160,8 +145,7 @@ public sealed class ChatInputBar
using (ImRaii.PushColor(ImGuiCol.ButtonActive, bg)) using (ImRaii.PushColor(ImGuiCol.ButtonActive, bg))
using (ImRaii.PushColor(ImGuiCol.Text, fg)) using (ImRaii.PushColor(ImGuiCol.Text, fg))
{ {
// Single-letter glyph derived from the channel — quick visual cue // Single-letter glyph as a quick visual cue until a proper icon font lands.
// until we have a proper icon font available in the compact bar.
var label = ChannelGlyph(inputType); var label = ChannelGlyph(inputType);
if ( if (
ImGui.Button($"{label}##chan-compact", new Vector2(buttonSize, buttonSize)) ImGui.Button($"{label}##chan-compact", new Vector2(buttonSize, buttonSize))
@@ -171,13 +155,9 @@ public sealed class ChatInputBar
} }
if (tab.Channel is not null && ImGui.IsItemHovered()) if (tab.Channel is not null && ImGui.IsItemHovered())
{
ImGui.SetTooltip(Resources.Language.ChatLog_SwitcherDisabled); ImGui.SetTooltip(Resources.Language.ChatLog_SwitcherDisabled);
}
else if (ImGui.IsItemHovered()) else if (ImGui.IsItemHovered())
{
ImGui.SetTooltip(inputType.Name()); ImGui.SetTooltip(inputType.Name());
}
using (var popup = ImRaii.Popup(popupId)) using (var popup = ImRaii.Popup(popupId))
{ {
@@ -221,17 +201,12 @@ public sealed class ChatInputBar
_ => "?", _ => "?",
}; };
// Forwards a tab-cycle keybind delta to the host so all windows // Forwards a tab-cycle keybind delta to the host (single source of truth).
// navigate the same active-tab pointer (single source of truth). public void HandleKeybindForward(int delta) => _host.ChangeTabDelta(delta);
public void HandleKeybindForward(int delta)
{
_host.ChangeTabDelta(delta);
}
} }
// Per-window input state. Each ChatInputBar instance owns one of these // Per-window input state. Each ChatInputBar owns one so pop-outs and the
// so pop-outs and the main window keep independent buffers and channels // main window keep independent buffers and history cursors.
// (State-Sync-Entscheidung A in the v0.6.0 spec).
public sealed class InputState public sealed class InputState
{ {
public string Buffer = string.Empty; public string Buffer = string.Empty;
+66 -141
View File
@@ -52,10 +52,8 @@ public sealed class ChatLogWindow : Window
private int ActivatePos = -1; private int ActivatePos = -1;
internal string Chat = string.Empty; internal string Chat = string.Empty;
// Hellion Chat — v0.6.0 input history was extracted into // Input history extracted into InputHistoryService so pop-out windows share
// InputHistoryService so pop-out windows with their own ChatInputBar // the same Up/Down history. Cursor stays window-local (independent navigation).
// share the same Up/Down history with the main window. The cursor
// stays window-local because each window navigates independently.
private int InputBacklogIdx = -1; private int InputBacklogIdx = -1;
public bool TellSpecial; public bool TellSpecial;
private readonly Stopwatch LastResize = new(); private readonly Stopwatch LastResize = new();
@@ -74,11 +72,8 @@ public sealed class ChatLogWindow : Window
public Vector2 LastWindowPos { get; private set; } = Vector2.Zero; public Vector2 LastWindowPos { get; private set; } = Vector2.Zero;
public Vector2 LastWindowSize { get; private set; } = Vector2.Zero; public Vector2 LastWindowSize { get; private set; } = Vector2.Zero;
// Window position recovery: guards against off-screen positions after a // Guards against off-screen positions after a display layout change.
// display layout change (monitor disconnected, resolution changed). On // One-shot bounds check on first draw; manual reset button bypasses it.
// the first draw after plugin load we run a one-shot bounds check to see
// whether the stored position still overlaps any visible viewport area.
// The manual reset button in the settings forces the position regardless.
private bool DidOnLoadBoundsCheck; private bool DidOnLoadBoundsCheck;
internal bool RequestPositionReset { get; set; } internal bool RequestPositionReset { get; set; }
@@ -112,9 +107,7 @@ public sealed class ChatLogWindow : Window
IsOpen = true; IsOpen = true;
RespectCloseHotkey = false; RespectCloseHotkey = false;
DisableWindowSounds = true; DisableWindowSounds = true;
// AllowBackgroundBlur wird nach AddWindow zentral in Plugin.Setup // AllowBackgroundBlur is set centrally in Plugin.Setup after AddWindow.
// für alle registrierten Windows gesetzt — keine Per-Window-Logik
// hier nötig.
PayloadHandler = new PayloadHandler(this); PayloadHandler = new PayloadHandler(this);
HandlerLender = new Lender<PayloadHandler>(() => new PayloadHandler(this)); HandlerLender = new Lender<PayloadHandler>(() => new PayloadHandler(this));
@@ -122,10 +115,8 @@ public sealed class ChatLogWindow : Window
SetUpTextCommandChannels(); SetUpTextCommandChannels();
SetUpAllCommands(); SetUpAllCommands();
// Cache the registered wrapper instances so Dispose can detach the same // Cache wrapper instances so Dispose can detach the same event objects
// event objects the constructor attached to, without going through // without going through Register() again.
// Register() again (which would re-create the wrapper if the command
// happened to be missing from the dictionary).
_clearHellionCommand = Plugin.Commands.Register( _clearHellionCommand = Plugin.Commands.Register(
"/clearhellion", "/clearhellion",
"Clear the Hellion Chat log" "Clear the Hellion Chat log"
@@ -397,11 +388,10 @@ public sealed class ChatLogWindow : Window
} }
} }
// Delegates to InputHistoryService so pop-out ChatInputBar instances share
// history. Deduplication lives inside the service.
private void AddBacklog(string message) private void AddBacklog(string message)
{ {
// v0.6.0 — delegates to the shared InputHistoryService so pop-out
// ChatInputBar instances see the same history. Move-to-newest
// deduplication lives inside the service.
InputHistoryService.Push(message); InputHistoryService.Push(message);
} }
@@ -417,15 +407,12 @@ public sealed class ChatLogWindow : Window
if (Plugin.Config.PreviewPosition is PreviewPosition.Inside) if (Plugin.Config.PreviewPosition is PreviewPosition.Inside)
height -= Plugin.InputPreview.PreviewHeight; height -= Plugin.InputPreview.PreviewHeight;
// Hellion Chat v0.6.1 — Header-Toolbar rendert auf Window-Ebene über // Header toolbar height is not subtracted by GetContentRegionAvail automatically
// einem horizontalen Layout-Pfad und wird von GetContentRegionAvail // (it renders outside the normal layout path), so we subtract it explicitly.
// hier drin NICHT automatisch berücksichtigt, daher expliziter Abzug. // The hint banner renders before this block so ImGui already accounts for it.
// Banner dagegen rendert in DrawChatLog VOR diesem ganzen Block und
// ImGui zieht seine Höhe automatisch von GetContentRegionAvail ab,
// weil der Cursor schon weiter unten steht — kein eigener Abzug.
height -= ImGui.GetFrameHeightWithSpacing(); height -= ImGui.GetFrameHeightWithSpacing();
// v1.2.0 — Status-Bar am Window-Boden reserviert 22 px + 2 px Spacing. // Status bar at the window bottom reserves 22px + 2px spacing.
height -= StatusBar.Height + 2; height -= StatusBar.Height + 2;
return height; return height;
@@ -659,10 +646,8 @@ public sealed class ChatLogWindow : Window
LastWindowSize = currentSize; LastWindowSize = currentSize;
LastWindowPos = ImGui.GetWindowPos(); LastWindowPos = ImGui.GetWindowPos();
// Window position recovery. Manual reset takes precedence and snaps // Manual reset snaps unconditionally; on-load check only fires when the
// the window to the safe default unconditionally; the one-shot // stored position has no overlap with any visible viewport.
// on-load check only fires when the persisted position has no
// overlap with any visible viewport area.
if (RequestPositionReset) if (RequestPositionReset)
{ {
RequestPositionReset = false; RequestPositionReset = false;
@@ -684,11 +669,8 @@ public sealed class ChatLogWindow : Window
if (IsChatMode && Plugin.InputPreview.IsDrawable) if (IsChatMode && Plugin.InputPreview.IsDrawable)
Plugin.InputPreview.CalculatePreview(); Plugin.InputPreview.CalculatePreview();
// Hellion Chat v0.6.1 — render the one-time hint banner first so it // Render the hint banner first so it sits above the tab area at full
// sits above the tab area / sidebar in full window width. ImGui's // window width. ImGui accounts for its height automatically.
// GetContentRegionAvail subtracts its height automatically because the
// cursor advances past it before the message log calls
// GetRemainingHeightForMessageLog, so we don't track the height here.
DrawV061HintBannerIfNeeded(); DrawV061HintBannerIfNeeded();
if (Plugin.Config.SidebarTabView) if (Plugin.Config.SidebarTabView)
@@ -713,8 +695,7 @@ public sealed class ChatLogWindow : Window
DrawChannelName(activeTab); DrawChannelName(activeTab);
} }
// v1.0.2 — compute inputColour up front so the channel selector button // inputColour computed up front so the channel selector button can share it.
// can also tint with it (existing input-text push remains below).
var inputType = activeTab.CurrentChannel.UseTempChannel var inputType = activeTab.CurrentChannel.UseTempChannel
? activeTab.CurrentChannel.TempChannel.ToChatType() ? activeTab.CurrentChannel.TempChannel.ToChatType()
: activeTab.CurrentChannel.Channel.ToChatType(); : activeTab.CurrentChannel.Channel.ToChatType();
@@ -1032,11 +1013,8 @@ public sealed class ChatLogWindow : Window
} }
else else
{ {
// We cannot lookup ExtraChat channel names from index over // ExtraChat channel names aren't available over IPC by index,
// IPC so we just don't show the name if it's the tabs channel. // so we skip the name lookup and show the short form instead.
//
// We don't call channel.ToChatType().Name() as it has the
// long name as used in the settings window.
channelNameChunks = channelNameChunks =
[ [
new TextChunk( new TextChunk(
@@ -1122,8 +1100,8 @@ public sealed class ChatLogWindow : Window
Plugin.CurrentTab.CurrentChannel.TempTellTarget = null; Plugin.CurrentTab.CurrentChannel.TempTellTarget = null;
} }
// Instead of calling SetChannel(), we ask the ExtraChat plugin to set a // ExtraChat linkshell channel switch: call the prefix command through the
// channel override by just calling the command directly. // game chat because ExtraChat only registers stub handlers in Dalamud.
if (channel.Value.IsExtraChatLinkshell()) if (channel.Value.IsExtraChatLinkshell())
{ {
// Check that the command is registered in Dalamud so the game code // Check that the command is registered in Dalamud so the game code
@@ -1169,10 +1147,8 @@ public sealed class ChatLogWindow : Window
]; ];
} }
// v0.6.0 — pop-out windows route submission through this wrapper. // Pop-out windows route submission here. The main Chat buffer is briefly
// The main-window Chat buffer is briefly used as a vehicle for // used as a vehicle for SendChatBox and restored afterwards.
// SendChatBox (which reads it directly) and restored afterwards so
// the main window does not visibly lose any half-typed input.
internal void SendChatBoxFromExternal(Tab tab, string text) internal void SendChatBoxFromExternal(Tab tab, string text)
{ {
var saved = Chat; var saved = Chat;
@@ -1217,7 +1193,7 @@ public sealed class ChatLogWindow : Window
?? activeTab.CurrentChannel.TellTarget; ?? activeTab.CurrentChannel.TellTarget;
if (target != null) if (target != null)
{ {
// ContentId 0 is a case where we can't directly send messages, so we send a /tell formatted message and let the game handle it // ContentId 0: can't send directly, so format as /tell and let the game handle it.
if (target.ContentId == 0) if (target.ContentId == 0)
{ {
trimmed = $"/tell {target.ToTargetString()} {trimmed}"; trimmed = $"/tell {target.ToTargetString()} {trimmed}";
@@ -1383,8 +1359,8 @@ public sealed class ChatLogWindow : Window
var maxLines = Plugin.Config.MaxLinesToRender; var maxLines = Plugin.Config.MaxLinesToRender;
var startLine = messages.Count > maxLines ? messages.Count - maxLines : 0; var startLine = messages.Count > maxLines ? messages.Count - maxLines : 0;
// Card-mode pre-loop hoist: theme/drawList/winLeft/winRight/border // Card-mode pre-loop: theme/drawList/winLeft/winRight/border are invariant
// are invariant per DrawMessages call; only cursorY moves per row. // per DrawMessages call; only cursorY moves per row.
var theme = Plugin.ThemeRegistry.Active; var theme = Plugin.ThemeRegistry.Active;
var drawList = ImGui.GetWindowDrawList(); var drawList = ImGui.GetWindowDrawList();
var winLeft = ImGui.GetWindowPos().X; var winLeft = ImGui.GetWindowPos().X;
@@ -1541,11 +1517,9 @@ public sealed class ChatLogWindow : Window
var lineWidth = ImGui.GetContentRegionAvail().X; var lineWidth = ImGui.GetContentRegionAvail().X;
// v1.2.0 — Card-Rows als Default, Compact-Density als Opt-Out. // v1.2.0 card mode: sender on its own line in channel color, then body,
// Card-Mode: Sender-Header in Channel-Color auf eigener Zeile, // then a subtle border as a card separator.
// dann Body, dann subtile Border-Bottom als Card-Trenner. // Compact mode: sender + space + content on one line via SameLine.
// Compact-Mode: bisheriges Verhalten — Sender + Space + Content
// auf einer Zeile via SameLine.
var useCard = !Plugin.Config.UseCompactDensity; var useCard = !Plugin.Config.UseCompactDensity;
if (useCard) if (useCard)
{ {
@@ -1558,7 +1532,7 @@ public sealed class ChatLogWindow : Window
{ {
DrawChunks(message.Sender, true, handler, lineWidth); DrawChunks(message.Sender, true, handler, lineWidth);
} }
// KEIN SameLine — Body landet auf eigener Zeile. // No SameLine — body renders on its own line.
} }
// We need to draw something otherwise the item visibility check below won't work. // We need to draw something otherwise the item visibility check below won't work.
@@ -1572,8 +1546,7 @@ public sealed class ChatLogWindow : Window
else else
DrawChunks(message.Content, true, handler, lineWidth); DrawChunks(message.Content, true, handler, lineWidth);
// Subtile Border-Bottom als Card-Trenner. Border-Farbe mit // Border bottom as card separator. Alpha reduced to 0x33 for subtlety.
// reduzierter Alpha (RGBA → 0x33) für dezente Trennung.
{ {
var rowEndY = ImGui.GetCursorScreenPos().Y; var rowEndY = ImGui.GetCursorScreenPos().Y;
drawList.AddLine( drawList.AddLine(
@@ -1646,9 +1619,8 @@ public sealed class ChatLogWindow : Window
if (!tabItem.Success) if (!tabItem.Success)
continue; continue;
// v1.2.0 — Active-Tab-Underline-Pill (2 px Akzent statt Background-Fill). // Active-tab underline pill (2px accent). No native ImGui underline API,
// Bewusst direkt nach TabItem-Setup; GetItemRectMin/Max referenziert noch // so we use a direct DrawList pass.
// das Tab. ImGui hat keine native Underline-API, daher direkter DrawList-Pass.
{ {
var theme = Plugin.ThemeRegistry.Active; var theme = Plugin.ThemeRegistry.Active;
var min = ImGui.GetItemRectMin(); var min = ImGui.GetItemRectMin();
@@ -1680,7 +1652,7 @@ public sealed class ChatLogWindow : Window
private void DrawTabSidebar() private void DrawTabSidebar()
{ {
var currentTab = -1; var currentTab = -1;
// v1.2.0 — Sidebar fix 44 px, kein Resize. Mehr Platz fürs Chat-Log. // Sidebar fixed at 44px, no resize.
using var tabTable = ImRaii.Table( using var tabTable = ImRaii.Table(
"tabs-table", "tabs-table",
2, 2,
@@ -1696,28 +1668,19 @@ public sealed class ChatLogWindow : Window
var hasTabSwitched = false; var hasTabSwitched = false;
var childHeight = GetRemainingHeightForMessageLog(); var childHeight = GetRemainingHeightForMessageLog();
// v1.2.0 — Sidebar-Child ohne Theme-ChildBg, sonst füllt das // Sidebar child without ChildBg tint to avoid a colored block above the
// bläuliche Frame-Rect auch den oberen HeaderToolbar-Padding-Bereich // header toolbar area. Vertical separation is handled by BordersInnerV.
// aus (sieht aus wie ein angeschnittener Block oberhalb der Buttons).
// Vertikale Trennung zur Message-Spalte bleibt durch BordersInnerV
// der Tab-Table erhalten.
using (ImRaii.PushColor(ImGuiCol.ChildBg, 0u)) using (ImRaii.PushColor(ImGuiCol.ChildBg, 0u))
using (var child = ImRaii.Child("##chat2-tab-sidebar", new Vector2(-1, childHeight))) using (var child = ImRaii.Child("##chat2-tab-sidebar", new Vector2(-1, childHeight)))
{ {
if (child) if (child)
{ {
// v1.2.0 — Top-Padding spiegelt die HeaderToolbar-Höhe der // Top padding mirrors the HeaderToolbar height so sidebar buttons
// rechten Spalte (DrawChatHeaderToolbar wird dort als erstes // align with the message log start.
// gerendert, eine Frame-Zeile + ItemSpacing). Ohne diesen
// Padding würden die Sidebar-Buttons oben am Window-Top
// kleben, während die Messages erst unter der Toolbar
// beginnen — vertikales Mismatch.
ImGui.Dummy(new Vector2(0, ImGui.GetFrameHeightWithSpacing())); ImGui.Dummy(new Vector2(0, ImGui.GetFrameHeightWithSpacing()));
var previousTab = Plugin.CurrentTab; var previousTab = Plugin.CurrentTab;
// Hellion Chat — auto-tell-tabs section divider rendered // Divider rendered once before the first temp tab with a live unit counter.
// exactly once before the first temp tab, with a live unit
// counter pulled directly from the tab list.
var tempTabHeaderRendered = false; var tempTabHeaderRendered = false;
var tempTabCount = Plugin.Config.Tabs.Count(t => t.IsTempTab); var tempTabCount = Plugin.Config.Tabs.Count(t => t.IsTempTab);
@@ -1752,11 +1715,8 @@ public sealed class ChatLogWindow : Window
if (showGreetedAffordance) if (showGreetedAffordance)
{ {
// Greeted toggle sits left of the selectable so the // Greeted toggle left of the selectable to keep click areas separate.
// click areas stay separate. The icon also doubles // Compact padding keeps the icon next to the tab name.
// as the visual "I'm done with this person" cue.
// Compact frame padding keeps the icon dezent next
// to the tab name instead of a chunky button block.
var greetedIcon = tab.IsGreeted var greetedIcon = tab.IsGreeted
? FontAwesomeIcon.CheckCircle ? FontAwesomeIcon.CheckCircle
: FontAwesomeIcon.Check; : FontAwesomeIcon.Check;
@@ -1784,10 +1744,8 @@ public sealed class ChatLogWindow : Window
ImGui.SameLine(); ImGui.SameLine();
} }
// v1.2.0 — Icon-only Sidebar mit Tooltip beim Hover. // Icon-only sidebar with tooltip on hover. Active tab gets accent color;
// Active-Tab kriegt Akzent-Color am Icon, Greeted-Tabs // greeted tabs are dimmed; tell tabs get a hash-based tint.
// werden auf TextDim gedimmt (löst den alten Header-
// Dim-Trick ab, da wir keine Selectable mehr nutzen).
var theme = Plugin.ThemeRegistry.Active; var theme = Plugin.ThemeRegistry.Active;
var icon = TabIconMapping.Resolve(tab); var icon = TabIconMapping.Resolve(tab);
uint iconColor; uint iconColor;
@@ -1801,8 +1759,8 @@ public sealed class ChatLogWindow : Window
} }
else if (tab.IsTempTab && tab.TellTarget != null && tab.TellTarget.IsSet()) else if (tab.IsTempTab && tab.TellTarget != null && tab.TellTarget.IsSet())
{ {
// v1.2.0 — Hash-Color-Tint differenziert parallele Auto-Tell-Tabs // Hash-based color tint differentiates parallel Auto-Tell tabs
// visuell ohne dass User pro Tab manuell ein Custom-Icon setzen muss. // without requiring manual icon assignment per tab.
iconColor = TabTintCache.GetTint(tab); iconColor = TabTintCache.GetTint(tab);
} }
else else
@@ -1835,9 +1793,8 @@ public sealed class ChatLogWindow : Window
if (isCurrentTab) if (isCurrentTab)
{ {
// v1.2.0 — Vertikale Akzent-Pill an der linken Window-Kante. // Vertical accent pill on the left window edge, 3px wide, half tab height,
// 3 px breit, halbe Tab-Höhe, vertikal zentriert. ImGui hat keine // vertically centered. Direct DrawList pass, no native ImGui API for this.
// native Pill-API, daher direkter DrawList-Pass.
var min = ImGui.GetItemRectMin(); var min = ImGui.GetItemRectMin();
var max = ImGui.GetItemRectMax(); var max = ImGui.GetItemRectMax();
const float pillWidth = 3f; const float pillWidth = 3f;
@@ -1853,10 +1810,8 @@ public sealed class ChatLogWindow : Window
); // leichter Rounding ); // leichter Rounding
} }
// v1.2.0 — Unread-Dot oben rechts am Icon. Sichtbar ohne Hover, damit // Unread dot top-right of the icon. Active tabs have Unread=0 by convention
// User Tabs mit ungelesenen Messages sofort erkennt. Aktive Tabs haben // so the dot never conflicts with the active pill.
// per Konvention Unread = 0 (LastTab-Branch in ChatLogWindow), daher
// kollidiert der Dot nicht mit der Active-Pill.
if (!isCurrentTab && tab.UnreadMode != UnreadMode.None && tab.Unread > 0) if (!isCurrentTab && tab.UnreadMode != UnreadMode.None && tab.Unread > 0)
{ {
var min = ImGui.GetItemRectMin(); var min = ImGui.GetItemRectMin();
@@ -1868,10 +1823,7 @@ public sealed class ChatLogWindow : Window
min.Y + dotRadius + dotPadding min.Y + dotRadius + dotPadding
); );
// v1.2.0 — Sanfter Pulse-Effekt: Alpha schwankt zwischen 60% und // Sin-based 2s pulse: alpha oscillates 60-100%. Skipped when ReduceMotion is on.
// 100% mit ~2-Sekunden-Cycle (subtil, nicht hektisch).
// Plugin.Config.ReduceMotion (Field seit v1.1.0) skipt den Pulse
// und rendert statisch — Default ist Animation an.
var dotColor = theme.Colors.StatusDanger; var dotColor = theme.Colors.StatusDanger;
if (!Plugin.Config.ReduceMotion) if (!Plugin.Config.ReduceMotion)
{ {
@@ -1941,14 +1893,8 @@ public sealed class ChatLogWindow : Window
Plugin.WantedTab = null; Plugin.WantedTab = null;
} }
// Hellion Chat v0.6.1 — visible pop-out trigger right above the message // DrawChatHeaderToolbar: renders the pop-out button for the active tab.
// log so users discover the feature without having to right-click the tab. // v1.3.0 also renders the optional Honorific title slot left of it.
// Renders only for the active tab in the main ChatLogWindow; pop-out
// windows have their own render path and skip this toolbar.
//
// Hellion Chat v1.3.0 also renders the optional Honorific title slot
// left of the pop-out button, when HonorificService reports an active
// custom title and the user has ShowHonorificTitleInHeader enabled.
private void DrawChatHeaderToolbar(Tab tab) private void DrawChatHeaderToolbar(Tab tab)
{ {
DrawHonorificTitleSlot(); DrawHonorificTitleSlot();
@@ -1973,16 +1919,9 @@ public sealed class ChatLogWindow : Window
} }
} }
// Renders the Honorific custom title to the left of the pop-out button, // Title rendered first so DrawPopOutButton can anchor flush right via
// wrapped in guillemets to match how the game itself displays titles. // GetContentRegionAvail. Call order in DrawChatHeaderToolbar matters.
// We lay out the title first, then DrawPopOutButton uses // SameLine keeps both on the same toolbar row.
// GetContentRegionAvail to anchor itself flush right, which is why the
// call order in DrawChatHeaderToolbar matters: title first, button second.
//
// The slot stays on the same line as the pop-out button so the chat
// log doesn't lose vertical space; we use ImGui.SameLine after our
// text so the cursor X is still on the toolbar row when the pop-out
// button takes over.
private void DrawHonorificTitleSlot() private void DrawHonorificTitleSlot()
{ {
var service = Plugin.HonorificService; var service = Plugin.HonorificService;
@@ -2028,8 +1967,7 @@ public sealed class ChatLogWindow : Window
var theme = Plugin.ThemeRegistry.Active; var theme = Plugin.ThemeRegistry.Active;
// Group so the tooltip's IsItemHovered check fires for hover anywhere // Group so IsItemHovered covers both the crown icon and the title text.
// on the crown-plus-title pair, not just one of the two.
ImGui.BeginGroup(); ImGui.BeginGroup();
using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(theme.Colors.TextMuted))) using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(theme.Colors.TextMuted)))
using (Plugin.FontManager.FontAwesome.Push()) using (Plugin.FontManager.FontAwesome.Push())
@@ -2051,11 +1989,7 @@ public sealed class ChatLogWindow : Window
ImGui.SameLine(); ImGui.SameLine();
} }
// Hellion Chat v0.6.1 — One-Time-Hint-Banner introducing the chat header // One-time hint banner for the pop-out header button and right-click pathway.
// pop-out toolbar button and the right-click pathway. Reuses the visual
// pattern from Popout.cs DrawHintBannerIfNeeded so users see a familiar
// dismiss-affordance. Returns the vertical space the banner consumed
// (0 when not shown) so the message log can shrink accordingly.
private float DrawV061HintBannerIfNeeded() private float DrawV061HintBannerIfNeeded()
{ {
if (Plugin.Config.SeenPopOutHeaderHint) if (Plugin.Config.SeenPopOutHeaderHint)
@@ -2070,10 +2004,7 @@ public sealed class ChatLogWindow : Window
var bg = new System.Numerics.Vector4(0.16f, 0.20f, 0.28f, 1f); var bg = new System.Numerics.Vector4(0.16f, 0.20f, 0.28f, 1f);
var dismiss = false; var dismiss = false;
var openSettings = false; var openSettings = false;
// RAII for the style stack so an early return in this block // RAII style stack so an early return can never leave ImGui unbalanced.
// (or a later refactor that introduces one) can never leave the
// ImGui style stack unbalanced. Matches the convention used
// elsewhere in this file.
using (ImRaii.PushColor(ImGuiCol.ChildBg, bg)) using (ImRaii.PushColor(ImGuiCol.ChildBg, bg))
using (ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, 1f)) using (ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, 1f))
using ( using (
@@ -2176,10 +2107,8 @@ public sealed class ChatLogWindow : Window
internal readonly List<bool> PopOutDocked = []; internal readonly List<bool> PopOutDocked = [];
internal readonly HashSet<Guid> PopOutWindows = []; internal readonly HashSet<Guid> PopOutWindows = [];
// v0.6.0 — live enumeration of all active Popout windows so the // Live enumeration of active Popout windows for KeybindManager tab-cycle forwarding.
// KeybindManager can find a focused ChatInputBar to forward tab-cycle // Filters on IsOpen to skip closed-but-registered popouts.
// keybinds to. Filter on IsOpen prevents touching closed-but-still-
// registered popouts.
internal IEnumerable<Popout> ActivePopouts => internal IEnumerable<Popout> ActivePopouts =>
Plugin.WindowSystem.Windows.OfType<Popout>().Where(p => p.IsOpen); Plugin.WindowSystem.Windows.OfType<Popout>().Where(p => p.IsOpen);
@@ -2352,8 +2281,7 @@ public sealed class ChatLogWindow : Window
} }
finally finally
{ {
// ImGuiListClipperPtr wraps an unmanaged ImGuiListClipper allocated above. // Destroy frees the unmanaged ImGuiListClipper allocated above; without it the block leaks per render.
// Without Destroy() the unmanaged block leaks per autocomplete render.
clipper.Destroy(); clipper.Destroy();
} }
} }
@@ -2687,9 +2615,8 @@ public sealed class ChatLogWindow : Window
return $"Player {hashCode:X8}"; return $"Player {hashCode:X8}";
} }
// Snap threshold in pixels: at least this much of the window must overlap // Snap threshold: minimum window overlap with a visible viewport before
// a visible viewport so the user can still grab the first tab header. // we consider it off-screen.
// Below the threshold the window is considered off-screen.
private const int OnScreenMinOverlapX = 100; private const int OnScreenMinOverlapX = 100;
private const int OnScreenMinOverlapY = 40; private const int OnScreenMinOverlapY = 40;
@@ -2725,9 +2652,7 @@ public sealed class ChatLogWindow : Window
$"[Window-Recovery] {source}: snapping main window from {LastWindowPos} (size {LastWindowSize}) to {safePos}." $"[Window-Recovery] {source}: snapping main window from {LastWindowPos} (size {LastWindowSize}) to {safePos}."
); );
// Pop-outs are intentionally non-persistent (cleared on plugin reload), // Pop-outs don't persist across sessions so they can never end up off-screen
// so an off-screen pop-out can never survive a session boundary. The // after a reload. Only the main window needs explicit recovery.
// main window above is the only persistence target that needs an
// explicit recovery path.
} }
} }
-7
View File
@@ -211,13 +211,6 @@ public class DbViewer : Window
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
ImGui.SetTooltip(Language.Export_Txt_Tooltip); 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 width = 350 * ImGuiHelpers.GlobalScale;
var loadingIndicator = IsProcessing && ProcessingStart < Environment.TickCount64; var loadingIndicator = IsProcessing && ProcessingStart < Environment.TickCount64;
+13 -28
View File
@@ -5,18 +5,12 @@ using HellionChat.Util;
namespace HellionChat.Ui; namespace HellionChat.Ui;
/// <summary> // Theme-driven ImGui style override. PushGlobal is pushed once per frame
/// ImGui style override for Hellion Chat. v1.1.0 ist die Engine // in Plugin.Draw and drives every Hellion-rendered window.
/// theme-getrieben: PushGlobal nimmt eine Theme-Instance + Window-
/// Opacity, die gesamten Color- und Style-Slots werden aus dem Theme
/// gelesen statt aus einer fixen Konstanten-Tabelle.
/// </summary>
internal static class HellionStyle internal static class HellionStyle
{ {
/// <summary> // Local color stack for the active theme. Use inside a
/// Local color stack auf Basis des aktiven Themes. Cheap. Use inside a // `using var _ = HellionStyle.Push(theme);` block.
/// `using var _ = HellionStyle.Push(theme);` block.
/// </summary>
internal static IDisposable Push(Theme theme) internal static IDisposable Push(Theme theme)
{ {
var a = theme.AbgrCache; var a = theme.AbgrCache;
@@ -37,13 +31,8 @@ internal static class HellionStyle
return stack; return stack;
} }
/// <summary> // Global color and style stack pushed once per frame.
/// Global color and style-variable stack pushed once per frame in // windowOpacity: window background alpha (0.5-1.0).
/// Plugin.Draw. Drives every Hellion-rendered window from the active
/// theme's palette and layout values.
/// </summary>
/// <param name="theme">Active theme from ThemeRegistry.</param>
/// <param name="windowOpacity">Window background alpha (0.51.0).</param>
internal static IDisposable PushGlobal(Theme theme, float windowOpacity = 1.0f) internal static IDisposable PushGlobal(Theme theme, float windowOpacity = 1.0f)
{ {
var c = theme.Colors; var c = theme.Colors;
@@ -54,15 +43,11 @@ internal static class HellionStyle
var alphaByte = (uint)Math.Clamp((int)(windowOpacity * 255f), 0x55, 0xFF); var alphaByte = (uint)Math.Clamp((int)(windowOpacity * 255f), 0x55, 0xFF);
var windowBgWithAlpha = (c.WindowBg & 0xFFFFFF00u) | alphaByte; var windowBgWithAlpha = (c.WindowBg & 0xFFFFFF00u) | alphaByte;
// ChildBg-Alpha: Sub-Bereiche (Tab-Sidebar, Message-Area, Input-Bar) // ChildBg alpha: child areas rendered inside ChatLogWindow would
// werden im ChatLog-Window als BeginChild gezeichnet. Würde der ChildBg // multiply their alpha with WindowBg, making 50% opacity appear
// mit dem gleichen Alpha wie WindowBg gerendert, multiplizieren sich // ~75% solid. At full opacity the theme's alpha is preserved; below
// die Layer (1 - (1-α)² Deckung), und 50 % WindowOpacity kommt mit // it ChildBg goes fully transparent so only WindowBg sets the final
// 75 % Deckung im Child-Bereich an — das Fenster wirkt solider als der // coverage.
// Slider verspricht. Bei voller Opacity bleibt der Theme-Akzent
// erhalten (Theme-eigene Alpha-Komponente, i.d.R. FF); sobald der User
// Transparenz zieht, wird ChildBg vollständig durchsichtig damit nur
// der WindowBg-Layer die finale Deckung bestimmt.
var childBgAlpha = windowOpacity >= 0.999f ? (c.ChildBg & 0xFFu) : 0u; var childBgAlpha = windowOpacity >= 0.999f ? (c.ChildBg & 0xFFu) : 0u;
var childBgWithAlpha = (c.ChildBg & 0xFFFFFF00u) | childBgAlpha; var childBgWithAlpha = (c.ChildBg & 0xFFFFFF00u) | childBgAlpha;
@@ -77,8 +62,8 @@ internal static class HellionStyle
stack.PushStyleVar(ImGuiStyleVar.WindowBorderSize, l.WindowBorderSize); stack.PushStyleVar(ImGuiStyleVar.WindowBorderSize, l.WindowBorderSize);
stack.PushStyleVar(ImGuiStyleVar.FrameBorderSize, l.FrameBorderSize); stack.PushStyleVar(ImGuiStyleVar.FrameBorderSize, l.FrameBorderSize);
// Surfaces — WindowBg/ChildBg use the per-push opacity-modulated value, // Surfaces — WindowBg/ChildBg use opacity-modulated values (RGBA path);
// so they go through the RGBA path; everything else reads from cache. // everything else reads from the pre-computed ABGR cache.
stack.PushColor(ImGuiCol.WindowBg, windowBgWithAlpha); stack.PushColor(ImGuiCol.WindowBg, windowBgWithAlpha);
stack.PushColor(ImGuiCol.ChildBg, childBgWithAlpha); stack.PushColor(ImGuiCol.ChildBg, childBgWithAlpha);
stack.PushColorAbgr(ImGuiCol.PopupBg, a.ChildBg); stack.PushColorAbgr(ImGuiCol.PopupBg, a.ChildBg);
+20 -55
View File
@@ -12,19 +12,15 @@ internal class Popout : Window
private readonly Tab Tab; private readonly Tab Tab;
private readonly int Idx; private readonly int Idx;
private long FrameTime; // set every frame private long FrameTime;
private long LastActivityTime = Environment.TickCount64; private long LastActivityTime = Environment.TickCount64;
// v0.6.0 — optional input bar inside the pop-out window. Lazy-allocated // Optional input bar inside the pop-out. Lazy-allocated when enabled,
// when the user enables Tab.PopOutInputEnabled and torn down when the // torn down on toggle-off (buffer discarded intentionally).
// 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 ChatInputBar? InputBar { get; private set; }
public bool HasFocusedInputBar => InputBar?.IsFocused ?? false; public bool HasFocusedInputBar => InputBar?.IsFocused ?? false;
// Hellion Chat — v0.6.1 expose just the tab identifier (not the whole Tab // Exposed so AutoTellTabsService can locate this window during LRU eviction.
// reference) so AutoTellTabsService.DropOldestTempTab can locate the
// matching pop-out window when an LRU temp tab gets evicted.
internal Guid TabIdentifier => Tab.Identifier; internal Guid TabIdentifier => Tab.Identifier;
public Popout(ChatLogWindow chatLogWindow, Tab tab, int idx) public Popout(ChatLogWindow chatLogWindow, Tab tab, int idx)
@@ -40,12 +36,9 @@ internal class Popout : Window
IsOpen = true; IsOpen = true;
RespectCloseHotkey = false; RespectCloseHotkey = false;
DisableWindowSounds = true; DisableWindowSounds = true;
// v1.2.1 — KEIN AllowBackgroundBlur. Pop-Outs werden vom User häufig // AllowBackgroundBlur is intentionally off: Dalamud blurs the entire
// im Dalamud-Tab-Container mit anderen Plugin-Windows kombiniert; in // tab container, not just this window, which would affect adjacent plugins.
// dem Render-Pfad blurt Dalamud den gesamten Container, nicht nur // Users can enable blur per-window via the Dalamud hamburger menu.
// das Pop-Out — würde die Tab-Bar oben und benachbarte Plugins
// mitziehen. Wer Blur in Pop-Outs will, kann ihn via Dalamud-
// Hamburger-Menü pro Window selbst aktivieren.
} }
public override void PreOpenCheck() public override void PreOpenCheck()
@@ -70,7 +63,6 @@ internal class Popout : Window
return true; return true;
} }
// Activity in the tab, this popout window, or the main chat log window.
var lastActivityTime = Math.Max(Tab.LastActivity, LastActivityTime); var lastActivityTime = Math.Max(Tab.LastActivity, LastActivityTime);
lastActivityTime = Math.Max(lastActivityTime, ChatLogWindow.LastActivityTime); lastActivityTime = Math.Max(lastActivityTime, ChatLogWindow.LastActivityTime);
return FrameTime - lastActivityTime <= 1000 * Plugin.Config.InactivityHideTimeout; return FrameTime - lastActivityTime <= 1000 * Plugin.Config.InactivityHideTimeout;
@@ -78,10 +70,8 @@ internal class Popout : Window
public override void PreDraw() public override void PreDraw()
{ {
// Hellion Chat v1.1.0+ — Theme-Engine ist Source-of-Truth, kein // Theme engine pushes the active theme globally in Plugin.Draw;
// zusätzlicher Dalamud-StyleModel-Override mehr pro Window. Plugin.Draw // pop-outs draw consistently without per-window overrides.
// pusht das aktive Hellion-Theme global; Pop-Out zeichnet sich damit
// konsistent zum Haupt-Chat-Window.
Flags = ImGuiWindowFlags.None; Flags = ImGuiWindowFlags.None;
if (!Plugin.Config.ShowPopOutTitleBar) if (!Plugin.Config.ShowPopOutTitleBar)
Flags |= ImGuiWindowFlags.NoTitleBar; Flags |= ImGuiWindowFlags.NoTitleBar;
@@ -92,19 +82,10 @@ internal class Popout : Window
if (!Tab.CanResize) if (!Tab.CanResize)
Flags |= ImGuiWindowFlags.NoResize; Flags |= ImGuiWindowFlags.NoResize;
// Idx may point past the end if PopOutDocked was resized (e.g., a tab // Guard against Idx pointing past the end if PopOutDocked was resized mid-frame.
// dropped) between the AddPopOutsToDraw() snapshot and this frame.
// Guard the read so we don't index into stale state.
if (Idx >= 0 && Idx < ChatLogWindow.PopOutDocked.Count && !ChatLogWindow.PopOutDocked[Idx]) if (Idx >= 0 && Idx < ChatLogWindow.PopOutDocked.Count && !ChatLogWindow.PopOutDocked[Idx])
{ {
if (Tab.IndependentOpacity) BgAlpha = Tab.IndependentOpacity ? Tab.Opacity / 100f : Plugin.Config.WindowOpacity;
{
BgAlpha = Tab.Opacity / 100f;
}
else
{
BgAlpha = Plugin.Config.WindowOpacity;
}
} }
} }
@@ -118,24 +99,15 @@ internal class Popout : Window
ImGui.Separator(); 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(); var hintBannerHeight = DrawHintBannerIfNeeded();
// v0.6.0 — pop-out optional input bar. Reserve height first so the // Toggle-OFF resets InputBar so the next toggle-ON starts with a fresh buffer.
// 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; var inputEnabled = Plugin.Config.PopOutInputEnabled;
if (!inputEnabled && InputBar != null) if (!inputEnabled && InputBar != null)
{
InputBar = null; InputBar = null;
}
if (inputEnabled) if (inputEnabled)
{
InputBar ??= new ChatInputBar(ChatLogWindow.Plugin, ChatLogWindow, () => Tab); InputBar ??= new ChatInputBar(ChatLogWindow.Plugin, ChatLogWindow, () => Tab);
}
var inputBarHeight = inputEnabled var inputBarHeight = inputEnabled
? ImGui.GetFrameHeightWithSpacing() + ImGui.GetStyle().ItemSpacing.Y ? ImGui.GetFrameHeightWithSpacing() + ImGui.GetStyle().ItemSpacing.Y
@@ -155,8 +127,7 @@ internal class Popout : Window
LastActivityTime = FrameTime; LastActivityTime = FrameTime;
} }
// Returns the vertical space the banner consumed (0 when not shown) // Returns the vertical space consumed by the banner (0 when not shown).
// so the message log can shrink accordingly.
private float DrawHintBannerIfNeeded() private float DrawHintBannerIfNeeded()
{ {
if (Plugin.Config.SeenPopOutInputHint) if (Plugin.Config.SeenPopOutInputHint)
@@ -240,21 +211,18 @@ internal class Popout : Window
private bool HideStateCheck() 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) if (Tab.HideInBattle && CurrentHideState == HideState.None && Plugin.InBattle)
{ {
CurrentHideState = HideState.Battle; CurrentHideState = HideState.Battle;
Plugin.Log.Verbose($"Popout HideState [{Tab.Name}]: None Battle"); Plugin.Log.Verbose($"Popout HideState [{Tab.Name}]: None -> Battle");
} }
// If the chat is hidden because of battle, we reset it here
if (CurrentHideState is HideState.Battle && !Plugin.InBattle) if (CurrentHideState is HideState.Battle && !Plugin.InBattle)
{ {
CurrentHideState = HideState.None; CurrentHideState = HideState.None;
Plugin.Log.Verbose($"Popout HideState [{Tab.Name}]: Battle None"); Plugin.Log.Verbose($"Popout HideState [{Tab.Name}]: Battle -> None");
} }
// if the chat has no hide state and in a cutscene, set the hide state to cutscene
if ( if (
Tab.HideDuringCutscenes Tab.HideDuringCutscenes
&& CurrentHideState == HideState.None && CurrentHideState == HideState.None
@@ -264,11 +232,10 @@ internal class Popout : Window
if (ChatLogWindow.Plugin.Functions.Chat.CheckHideFlags()) if (ChatLogWindow.Plugin.Functions.Chat.CheckHideFlags())
{ {
CurrentHideState = HideState.Cutscene; CurrentHideState = HideState.Cutscene;
Plugin.Log.Verbose($"Popout HideState [{Tab.Name}]: None Cutscene"); Plugin.Log.Verbose($"Popout HideState [{Tab.Name}]: None -> Cutscene");
} }
} }
// if the chat is hidden because of a cutscene and no longer in a cutscene, set the hide state to none
if ( if (
CurrentHideState is HideState.Cutscene or HideState.CutsceneOverride CurrentHideState is HideState.Cutscene or HideState.CutsceneOverride
&& !Plugin.CutsceneActive && !Plugin.CutsceneActive
@@ -276,25 +243,23 @@ internal class Popout : Window
) )
{ {
Plugin.Log.Verbose( Plugin.Log.Verbose(
$"Popout HideState [{Tab.Name}]: {CurrentHideState} None (cutscene/gpose ended)" $"Popout HideState [{Tab.Name}]: {CurrentHideState} -> None (cutscene/gpose ended)"
); );
CurrentHideState = HideState.None; 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) if (CurrentHideState == HideState.Cutscene && ChatLogWindow.Activate)
{ {
CurrentHideState = HideState.CutsceneOverride; CurrentHideState = HideState.CutsceneOverride;
Plugin.Log.Verbose( Plugin.Log.Verbose(
$"Popout HideState [{Tab.Name}]: Cutscene CutsceneOverride (user activate)" $"Popout HideState [{Tab.Name}]: Cutscene -> CutsceneOverride (user activate)"
); );
} }
// if the user hid the chat and is now activating chat, reset the hide state
if (CurrentHideState == HideState.User && ChatLogWindow.Activate) if (CurrentHideState == HideState.User && ChatLogWindow.Activate)
{ {
CurrentHideState = HideState.None; CurrentHideState = HideState.None;
Plugin.Log.Verbose($"Popout HideState [{Tab.Name}]: User None (activate)"); Plugin.Log.Verbose($"Popout HideState [{Tab.Name}]: User -> None (activate)");
} }
return CurrentHideState is HideState.Cutscene or HideState.User or HideState.Battle return CurrentHideState is HideState.Cutscene or HideState.User or HideState.Battle
+21 -42
View File
@@ -92,10 +92,8 @@ public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window
View = SettingsView.Overview; View = SettingsView.Overview;
} }
// ESC im Detail-View kehrt zur Overview zurück. Window-Focus-Check ist // ESC in Detail view returns to Overview. Window focus check is
// Pflicht — sonst triggert ESC auch wenn der User ein anderes Fenster // required so ESC doesn't fire when the user targets a different window.
// fokussiert hat und ESC fürs Game-Menü drückt (Codebase-Pattern siehe
// Util/SearchSelector.cs:37).
if ( if (
View == SettingsView.Detail View == SettingsView.Detail
&& ImGui.IsWindowFocused(ImGuiFocusedFlags.RootAndChildWindows) && ImGui.IsWindowFocused(ImGuiFocusedFlags.RootAndChildWindows)
@@ -128,13 +126,13 @@ public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window
private void DrawDetail() private void DrawDetail()
{ {
// Breadcrumb-Header — Akzent-Cyan, klickbar, führt zurück zur Overview // Breadcrumb header -- accent cyan, clickable, returns to Overview.
using (ImRaii.PushColor(ImGuiCol.Text, 0xFF00BED2u)) using (ImRaii.PushColor(ImGuiCol.Text, 0xFF00BED2u))
using (ImRaii.PushColor(ImGuiCol.Button, 0u)) using (ImRaii.PushColor(ImGuiCol.Button, 0u))
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, 0x33FFFFFFu)) using (ImRaii.PushColor(ImGuiCol.ButtonHovered, 0x33FFFFFFu))
using (ImRaii.PushColor(ImGuiCol.ButtonActive, 0x55FFFFFFu)) using (ImRaii.PushColor(ImGuiCol.ButtonActive, 0x55FFFFFFu))
{ {
if (ImGui.SmallButton(" Settings")) if (ImGui.SmallButton("<- Settings"))
{ {
View = SettingsView.Overview; View = SettingsView.Overview;
return; return;
@@ -149,11 +147,8 @@ public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window
ImGui.Separator(); ImGui.Separator();
ImGui.Spacing(); ImGui.Spacing();
// Section-Content in voller Breite. Die Tab-Liste links ist überholt: // Section content fills full width. Navigation back to another
// der User ist bereits über die Card-Übersicht navigiert, eine zweite // section goes via the breadcrumb or ESC.
// Tab-Liste daneben würde nur den Vanilla-Look zurückbringen. Falls
// der User in eine andere Section will, geht er zurück zur Overview
// (Breadcrumb / ESC).
var style = ImGui.GetStyle(); var style = ImGui.GetStyle();
var height = var height =
ImGui.GetContentRegionAvail().Y ImGui.GetContentRegionAvail().Y
@@ -182,9 +177,7 @@ public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window
ImGui.SameLine(); ImGui.SameLine();
if (ImGui.Button(Language.Settings_Discard)) if (ImGui.Button(Language.Settings_Discard))
{
IsOpen = false; IsOpen = false;
}
const string buttonLabel = "Anna's Ko-fi"; const string buttonLabel = "Anna's Ko-fi";
const string buttonLabel2 = "Infi's Ko-fi"; const string buttonLabel2 = "Infi's Ko-fi";
@@ -217,7 +210,6 @@ public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window
if (!save) if (!save)
return; return;
// calculate all conditions before updating config
var hideChanged = !Mutable.HideChat && Mutable.HideChat != Plugin.Config.HideChat; var hideChanged = !Mutable.HideChat && Mutable.HideChat != Plugin.Config.HideChat;
var languageChanged = Mutable.LanguageOverride != Plugin.Config.LanguageOverride; var languageChanged = Mutable.LanguageOverride != Plugin.Config.LanguageOverride;
var fontChanged = var fontChanged =
@@ -230,18 +222,16 @@ public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window
Math.Abs(Mutable.SymbolsFontSizeV2 - Plugin.Config.SymbolsFontSizeV2) > 0.001 Math.Abs(Mutable.SymbolsFontSizeV2 - Plugin.Config.SymbolsFontSizeV2) > 0.001
|| Math.Abs(Mutable.FontSizeV2 - Plugin.Config.FontSizeV2) > 0.001; || Math.Abs(Mutable.FontSizeV2 - Plugin.Config.FontSizeV2) > 0.001;
var italicStateChanged = Mutable.ItalicEnabled != Plugin.Config.ItalicEnabled; var italicStateChanged = Mutable.ItalicEnabled != Plugin.Config.ItalicEnabled;
// v1.2.0 — Refilter only if a filter-relevant setting actually
// changed. The Clear+Refilter cycle reloads messages from the DB, // Only refilter when filter-relevant settings changed. Clear+Refilter
// which silently wipes any in-session message that wasn't // reloads from the DB and silently drops in-session messages that
// persisted (Privacy-First config blocks most channels from DB). // weren't persisted (Privacy-First blocks most channels). Cosmetic
// Cosmetic changes (theme, tab icons, layout flags) trigger no // changes (theme, icons, layout) skip the cycle.
// refilter — chat history stays intact.
var filtersChanged = HasFilterRelevantChanges(); var filtersChanged = HasFilterRelevantChanges();
Plugin.Config.UpdateFrom(Mutable, true); Plugin.Config.UpdateFrom(Mutable, true);
// save after 60 frames have passed, which should hopefully not // Defer save by 60 frames to avoid committing changes that cause a crash.
// commit any changes that cause a crash
Plugin.DeferredSaveFrames = 60; Plugin.DeferredSaveFrames = 60;
if (filtersChanged) if (filtersChanged)
{ {
@@ -259,24 +249,19 @@ public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window
GameFunctions.GameFunctions.SetChatInteractable(true); GameFunctions.GameFunctions.SetChatInteractable(true);
if (Plugin.Config.ShowEmotes) if (Plugin.Config.ShowEmotes)
_ = EmoteCache.LoadData(); // Fire-and-forget intentional, exceptions are caught inside _ = EmoteCache.LoadData();
Initialise(); Initialise();
} }
/// <summary> /// <summary>
/// v1.2.0 — Detects whether any setting that influences message /// Returns true if any setting that influences message filtering changed
/// filtering changed between Plugin.Config and the Mutable working /// between Plugin.Config and the Mutable working copy. Gates the heavy
/// copy. Used to gate the heavy ClearAllTabs+FilterAllTabsAsync cycle /// ClearAllTabs+FilterAllTabsAsync cycle on Save so cosmetic changes
/// in Save: cosmetic changes (theme, tab icons, layout flags) do not /// don't wipe in-session chat history.
/// touch the chat log, only filter-relevant changes do. Without this
/// gate, every settings save wipes the chat history of any channel
/// the Privacy filter blocks from being persisted to the DB —
/// reported by Flo from in-game testing 2026-05-05/06.
/// </summary> /// </summary>
private bool HasFilterRelevantChanges() private bool HasFilterRelevantChanges()
{ {
// Top-level privacy controls.
if (Mutable.PrivacyFilterEnabled != Plugin.Config.PrivacyFilterEnabled) if (Mutable.PrivacyFilterEnabled != Plugin.Config.PrivacyFilterEnabled)
return true; return true;
if (Mutable.PrivacyPersistUnknownChannels != Plugin.Config.PrivacyPersistUnknownChannels) if (Mutable.PrivacyPersistUnknownChannels != Plugin.Config.PrivacyPersistUnknownChannels)
@@ -285,27 +270,23 @@ public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window
return true; return true;
// FilterIncludePreviousSessions changes the GetMostRecentMessages // FilterIncludePreviousSessions changes the GetMostRecentMessages
// window in MessageManager.FilterAllTabs and is therefore filter- // window and is filter-relevant even outside the Privacy block.
// relevant even though it lives outside the Privacy block.
if (Mutable.FilterIncludePreviousSessions != Plugin.Config.FilterIncludePreviousSessions) if (Mutable.FilterIncludePreviousSessions != Plugin.Config.FilterIncludePreviousSessions)
return true; return true;
// Per-tab channel selection. Compare persistent tabs only // Compare persistent tabs only -- TempTabs are never refiltered.
// TempTabs are session-only and never refiltered anyway.
var origPersistent = Plugin.Config.Tabs.Where(t => !t.IsTempTab).ToList(); var origPersistent = Plugin.Config.Tabs.Where(t => !t.IsTempTab).ToList();
var newPersistent = Mutable.Tabs.Where(t => !t.IsTempTab).ToList(); var newPersistent = Mutable.Tabs.Where(t => !t.IsTempTab).ToList();
if (origPersistent.Count != newPersistent.Count) if (origPersistent.Count != newPersistent.Count)
return true; // add or delete return true;
for (var i = 0; i < origPersistent.Count; i++) for (var i = 0; i < origPersistent.Count; i++)
{ {
var orig = origPersistent[i]; var orig = origPersistent[i];
var neu = newPersistent[i]; var neu = newPersistent[i];
// Identifier mismatch at the same index means reorder or // Identifier mismatch means reorder or slot swap -- treat as filter-relevant.
// a slot got swapped — treat as filter-relevant so the new
// channel-selection layout actually applies.
if (orig.Identifier != neu.Identifier) if (orig.Identifier != neu.Identifier)
return true; return true;
@@ -314,8 +295,6 @@ public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window
if (!orig.ExtraChatChannels.SetEquals(neu.ExtraChatChannels)) if (!orig.ExtraChatChannels.SetEquals(neu.ExtraChatChannels))
return true; return true;
// SelectedChannels is a Dictionary<ChatType, (ChatSource, ChatSource)>
// — value-tuple equality already does the right thing per-pair.
if (orig.SelectedChannels.Count != neu.SelectedChannels.Count) if (orig.SelectedChannels.Count != neu.SelectedChannels.Count)
return true; return true;
foreach (var pair in orig.SelectedChannels) foreach (var pair in orig.SelectedChannels)
+6 -20
View File
@@ -11,11 +11,7 @@ internal sealed class SettingsOverview
{ {
private readonly SettingsWindow _window; private readonly SettingsWindow _window;
// Card-Reihenfolge entspricht 1:1 dem Tabs-Index in SettingsWindow. // Card order matches the Tabs index in SettingsWindow 1:1.
// v1.2.1: Cards thematisch re-sortiert. Theme & Layout vereint Theme-
// Picker + Frame-Style + Timestamps; Fonts & Colours vereint Schriften
// + Chat-Farben; Data Management vereint Storage + Retention + Cleanup
// + Export + DB-Viewer + Advanced.
private static readonly (FontAwesomeIcon Icon, string TitleKey, string SubtextKey)[] CardDefs = private static readonly (FontAwesomeIcon Icon, string TitleKey, string SubtextKey)[] CardDefs =
[ [
(FontAwesomeIcon.SlidersH, "Settings_Card_General_Title", "Settings_Card_General_Subtext"), (FontAwesomeIcon.SlidersH, "Settings_Card_General_Title", "Settings_Card_General_Subtext"),
@@ -64,9 +60,7 @@ internal sealed class SettingsOverview
var avail = ImGui.GetContentRegionAvail(); var avail = ImGui.GetContentRegionAvail();
var columns = avail.X >= 700f ? 3 : 2; var columns = avail.X >= 700f ? 3 : 2;
var cardWidth = (avail.X - (columns - 1) * 8f) / columns; var cardWidth = (avail.X - (columns - 1) * 8f) / columns;
// v1.2.1 — Subtexte wrappen jetzt auf zwei Zeilen, daher 110f statt der // 110f accommodates two-line subtexts; wrap width is matched in DrawCard.
// v1.1.0-Höhe 96f. Wrap-Breite + Y-Position der Subtext-Zeile sind in
// DrawCard auf den Card-Innenrand abgestimmt.
var cardHeight = 110f; var cardHeight = 110f;
for (var i = 0; i < CardDefs.Length; i++) for (var i = 0; i < CardDefs.Length; i++)
@@ -90,9 +84,8 @@ internal sealed class SettingsOverview
float h float h
) )
{ {
// BeginGroup macht den Card-Bereich zu einem einzelnen ImGui-Layout-Item. // BeginGroup makes the card a single layout item so SameLine works
// Damit funktioniert SameLine() im Caller-Loop — sonst tracked ImGui die // in the caller loop -- without it ImGui tracks each child separately.
// einzelnen InvisibleButton/Text-Items separat und das Wrapping bricht.
ImGui.BeginGroup(); ImGui.BeginGroup();
var cursorBefore = ImGui.GetCursorScreenPos(); var cursorBefore = ImGui.GetCursorScreenPos();
@@ -103,9 +96,6 @@ internal sealed class SettingsOverview
var draw = ImGui.GetWindowDrawList(); var draw = ImGui.GetWindowDrawList();
draw.AddRectFilled(cursorBefore, cursorBefore + new Vector2(w, h), bgColor, 4f); draw.AddRectFilled(cursorBefore, cursorBefore + new Vector2(w, h), bgColor, 4f);
// Inhalts-Overlay: Icon + Title via DrawList (kein Wrap nötig). Subtext
// läuft über ImGui-Cursor + PushTextWrapPos damit der Text bei
// Card-Innenbreite umbricht statt rechts geclippt zu werden.
var iconPos = cursorBefore + new Vector2(16f, 12f); var iconPos = cursorBefore + new Vector2(16f, 12f);
var titlePos = cursorBefore + new Vector2(16f, 40f); var titlePos = cursorBefore + new Vector2(16f, 40f);
var subtextPos = cursorBefore + new Vector2(16f, 62f); var subtextPos = cursorBefore + new Vector2(16f, 62f);
@@ -120,10 +110,8 @@ internal sealed class SettingsOverview
draw.AddText(titlePos, titleColor, title); draw.AddText(titlePos, titleColor, title);
// Subtext mit Wrap auf Card-Innenbreite (16 px Padding links + rechts). // Subtext wraps at card inner width (16px padding each side) via DrawList
// Cursor-basiertes TextUnformatted würde die ImGui-Group-Bounds // to avoid expanding the group bounds and breaking SameLine in the card row.
// erweitern und das SameLine-Wrapping in der Card-Reihe brechen, daher
// bleibt der Subtext bewusst beim DrawList-Overlay-Pattern.
var subtextWrapWidth = w - 32f; var subtextWrapWidth = w - 32f;
draw.AddText( draw.AddText(
ImGui.GetFont(), ImGui.GetFont(),
@@ -137,8 +125,6 @@ internal sealed class SettingsOverview
ImGui.EndGroup(); ImGui.EndGroup();
if (clicked) if (clicked)
{
_window.OpenSection(index); _window.OpenSection(index);
} }
}
} }
+9 -42
View File
@@ -9,10 +9,7 @@ using HellionChat.Util;
namespace HellionChat.Ui.SettingsTabs; namespace HellionChat.Ui.SettingsTabs;
// Chat-Tab — vier eigenständige Sektionen: Auto-Tell-Tabs, Behaviour, // Four sections: Auto-Tell Tabs, Behaviour, Preview, Emotes.
// 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 internal sealed class Chat : ISettingsTab
{ {
private Plugin Plugin { get; } private Plugin Plugin { get; }
@@ -22,9 +19,8 @@ internal sealed class Chat : ISettingsTab
private SearchSelector.SelectorPopupOptions WordPopupOptions; private SearchSelector.SelectorPopupOptions WordPopupOptions;
// Snapshot of EmoteCache.State for which we last built WordPopupOptions. // Tracks which EmoteCache state WordPopupOptions was built for so we
// Without this, an empty FilteredSheet (e.g., the user blocked every emote) // don't refill every frame when FilteredSheet is empty.
// would trigger a refill every frame the settings tab is open.
private EmoteCache.LoadingState? WordPopupOptionsBuiltFor; private EmoteCache.LoadingState? WordPopupOptionsBuiltFor;
internal Chat(Plugin plugin, Configuration mutable) internal Chat(Plugin plugin, Configuration mutable)
@@ -36,15 +32,13 @@ internal sealed class Chat : ISettingsTab
WordPopupOptionsBuiltFor = EmoteCache.State; WordPopupOptionsBuiltFor = EmoteCache.State;
} }
private SearchSelector.SelectorPopupOptions RefillSheet() private SearchSelector.SelectorPopupOptions RefillSheet() =>
{ new SearchSelector.SelectorPopupOptions
return new SearchSelector.SelectorPopupOptions
{ {
FilteredSheet = EmoteCache FilteredSheet = EmoteCache
.SortedCodeArray.Where(w => !Mutable.BlockedEmotes.Contains(w)) .SortedCodeArray.Where(w => !Mutable.BlockedEmotes.Contains(w))
.ToArray(), .ToArray(),
}; };
}
public void Draw(bool changed) public void Draw(bool changed)
{ {
@@ -61,9 +55,7 @@ internal sealed class Chat : ISettingsTab
{ {
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Chat_AutoTellTabs_Heading); using var tree = ImRaii.TreeNode(HellionStrings.Settings_Chat_AutoTellTabs_Heading);
if (!tree.Success) if (!tree.Success)
{
return; return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{ {
@@ -76,9 +68,7 @@ internal sealed class Chat : ISettingsTab
ImGui.SetNextItemWidth(200f * ImGuiHelpers.GlobalScale); ImGui.SetNextItemWidth(200f * ImGuiHelpers.GlobalScale);
var limit = Mutable.AutoTellTabsLimit; var limit = Mutable.AutoTellTabsLimit;
if (ImGui.SliderInt(HellionStrings.ChatLog_AutoTellTabs_Limit_Name, ref limit, 1, 50)) if (ImGui.SliderInt(HellionStrings.ChatLog_AutoTellTabs_Limit_Name, ref limit, 1, 50))
{
Mutable.AutoTellTabsLimit = limit; Mutable.AutoTellTabsLimit = limit;
}
ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_Limit_Description); ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_Limit_Description);
ImGui.Checkbox( ImGui.Checkbox(
@@ -119,9 +109,7 @@ internal sealed class Chat : ISettingsTab
100 100
) )
) )
{
Mutable.AutoTellTabsHistoryPreload = preload; Mutable.AutoTellTabsHistoryPreload = preload;
}
ImGuiUtil.HelpMarker(HellionStrings.Privacy_AutoTellTabs_Preload_Description); ImGuiUtil.HelpMarker(HellionStrings.Privacy_AutoTellTabs_Preload_Description);
ImGui.Spacing(); ImGui.Spacing();
@@ -133,9 +121,7 @@ internal sealed class Chat : ISettingsTab
{ {
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Chat_Behaviour_Heading); using var tree = ImRaii.TreeNode(HellionStrings.Settings_Chat_Behaviour_Heading);
if (!tree.Success) if (!tree.Success)
{
return; return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{ {
@@ -160,9 +146,7 @@ internal sealed class Chat : ISettingsTab
{ {
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Chat_Preview_Heading); using var tree = ImRaii.TreeNode(HellionStrings.Settings_Chat_Preview_Heading);
if (!tree.Success) if (!tree.Success)
{
return; return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{ {
@@ -178,12 +162,10 @@ internal sealed class Chat : ISettingsTab
foreach (var position in Enum.GetValues<PreviewPosition>()) foreach (var position in Enum.GetValues<PreviewPosition>())
{ {
if (ImGui.Selectable(position.Name(), Mutable.PreviewPosition == position)) if (ImGui.Selectable(position.Name(), Mutable.PreviewPosition == position))
{
Mutable.PreviewPosition = position; Mutable.PreviewPosition = position;
} }
} }
} }
}
ImGuiUtil.HelpMarker(Language.Options_Preview_Description); ImGuiUtil.HelpMarker(Language.Options_Preview_Description);
if ( if (
@@ -193,9 +175,7 @@ internal sealed class Chat : ISettingsTab
ref Mutable.PreviewMinimum ref Mutable.PreviewMinimum
) )
) )
{
Mutable.PreviewMinimum = Math.Clamp(Mutable.PreviewMinimum, 1, 250); Mutable.PreviewMinimum = Math.Clamp(Mutable.PreviewMinimum, 1, 250);
}
ImGui.Checkbox(Language.Options_PreviewOnlyIf_Name, ref Mutable.OnlyPreviewIf); ImGui.Checkbox(Language.Options_PreviewOnlyIf_Name, ref Mutable.OnlyPreviewIf);
ImGuiUtil.HelpMarker(Language.Options_PreviewOnlyIf_Description); ImGuiUtil.HelpMarker(Language.Options_PreviewOnlyIf_Description);
@@ -206,9 +186,7 @@ internal sealed class Chat : ISettingsTab
{ {
using var tree = ImRaii.TreeNode(HellionStrings.Settings_Chat_Emotes_Heading); using var tree = ImRaii.TreeNode(HellionStrings.Settings_Chat_Emotes_Heading);
if (!tree.Success) if (!tree.Success)
{
return; return;
}
using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
{ {
@@ -233,17 +211,13 @@ internal sealed class Chat : ISettingsTab
using (Plugin.FontManager.FontAwesome.Push()) using (Plugin.FontManager.FontAwesome.Push())
ImGui.Button(FontAwesomeIcon.Plus.ToIconString(), new Vector2(buttonWidth, 0)); ImGui.Button(FontAwesomeIcon.Plus.ToIconString(), new Vector2(buttonWidth, 0));
// Open the selector popup on left-click; SelectorPopup uses // OpenPopup on click because SelectorPopup uses ContextPopupItem
// ImRaii.ContextPopupItem internally which only opens on right- // which only triggers on right-click by default.
// click otherwise — without this OpenPopup the button looked
// active but the popup never appeared on a normal click.
if (ImGui.IsItemClicked()) if (ImGui.IsItemClicked())
ImGui.OpenPopup("WordAddPopup"); ImGui.OpenPopup("WordAddPopup");
if (SearchSelector.SelectorPopup("WordAddPopup", out var newWord, WordPopupOptions)) if (SearchSelector.SelectorPopup("WordAddPopup", out var newWord, WordPopupOptions))
{
Mutable.BlockedEmotes.Add(newWord); Mutable.BlockedEmotes.Add(newWord);
}
using ( using (
var table = ImRaii.Table( var table = ImRaii.Table(
@@ -257,11 +231,9 @@ internal sealed class Chat : ISettingsTab
{ {
ImGui.TableSetupColumn(Language.Options_Emote_EmoteTable); ImGui.TableSetupColumn(Language.Options_Emote_EmoteTable);
ImGui.TableSetupColumn("##Del", ImGuiTableColumnFlags.WidthStretch, 0.07f); ImGui.TableSetupColumn("##Del", ImGuiTableColumnFlags.WidthStretch, 0.07f);
ImGui.TableHeadersRow(); ImGui.TableHeadersRow();
var copiedList = Mutable.BlockedEmotes.ToArray(); foreach (var word in Mutable.BlockedEmotes.ToArray())
foreach (var word in copiedList)
{ {
ImGui.TableNextColumn(); ImGui.TableNextColumn();
ImGui.TextUnformatted(word); ImGui.TextUnformatted(word);
@@ -274,12 +246,10 @@ internal sealed class Chat : ISettingsTab
!ImGui.GetIO().KeyCtrl !ImGui.GetIO().KeyCtrl
) )
) )
{
Mutable.BlockedEmotes.Remove(word); Mutable.BlockedEmotes.Remove(word);
} }
} }
} }
}
ImGui.Spacing(); ImGui.Spacing();
ImGui.Separator(); ImGui.Separator();
@@ -289,17 +259,14 @@ internal sealed class Chat : ISettingsTab
ImGui.Spacing(); ImGui.Spacing();
if (EmoteCache.State is EmoteCache.LoadingState.Done) if (EmoteCache.State is EmoteCache.LoadingState.Done)
{
ImGui.TextColored(ImGuiColors.HealerGreen, Language.Options_Emote_Ready); ImGui.TextColored(ImGuiColors.HealerGreen, Language.Options_Emote_Ready);
}
else else
{
ImGui.TextColored(ImGuiColors.DPSRed, Language.Options_Emote_NotReady); ImGui.TextColored(ImGuiColors.DPSRed, Language.Options_Emote_NotReady);
}
ImGui.TextUnformatted( ImGui.TextUnformatted(
$"{Language.Options_Emote_Loaded} {EmoteCache.SortedCodeArray.Length}" $"{Language.Options_Emote_Loaded} {EmoteCache.SortedCodeArray.Length}"
); );
using ( using (
var emoteTable = ImRaii.Table( var emoteTable = ImRaii.Table(
"##LoadedEmotes", "##LoadedEmotes",
+1 -3
View File
@@ -8,9 +8,7 @@ using HellionChat.Util;
namespace HellionChat.Ui.SettingsTabs; namespace HellionChat.Ui.SettingsTabs;
// Information-Tab vereint die früheren About- und Changelog-Tabs in // Combines the former About and Changelog tabs into three collapsible sections.
// 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 internal sealed class Information : ISettingsTab
{ {
private Configuration Mutable { get; } private Configuration Mutable { get; }
+9 -16
View File
@@ -8,9 +8,8 @@ using HellionChat.Util;
namespace HellionChat.Ui.SettingsTabs; namespace HellionChat.Ui.SettingsTabs;
// First settings tab introduced in v1.3.0 (Plugin Integrations Cycle 1). // Added in v1.3.0. Each future integration cycle adds a section above
// Designed to grow organically: each future cycle adds a new section above // the "Coming soon" block and removes its stub item.
// the "Coming soon" block and removes the corresponding stub item.
internal sealed class Integrations : ISettingsTab internal sealed class Integrations : ISettingsTab
{ {
private Plugin Plugin { get; } private Plugin Plugin { get; }
@@ -48,11 +47,9 @@ internal sealed class Integrations : ISettingsTab
DrawHonorificStatus(); DrawHonorificStatus();
ImGui.Spacing(); ImGui.Spacing();
// The toggle is enabled regardless of detection state — leaving it // Toggle works regardless of detection state: "show when available,
// on means "render when available, hide otherwise". Disabling the // hide otherwise". Disabling it when Honorific is missing would force
// toggle when Honorific is missing would force the user to retoggle // the user to retoggle on every reload.
// it every time Honorific is reloaded, which is worse UX than the
// silent auto-hide.
if ( if (
ImGui.Checkbox( ImGui.Checkbox(
HellionStrings.Settings_Integrations_Honorific_Toggle, HellionStrings.Settings_Integrations_Honorific_Toggle,
@@ -76,11 +73,9 @@ internal sealed class Integrations : ISettingsTab
} }
} }
// Maintainer attribution. Honorific has no LICENSE in its repo so we // Honorific has no LICENSE in its repo so we link upstream and author
// can't bundle its assets, but linking to the upstream and the // instead of bundling assets. Text labels because FA Brands isn't
// author's profile is the polite minimum. Plain ImGui buttons keep // guaranteed in Dalamud's font set.
// the visual weight modest, the FontAwesome Brands subset is not
// guaranteed in Dalamud's font set so we use text labels.
ImGui.Spacing(); ImGui.Spacing();
if (ImGui.Button(HellionStrings.Settings_Integrations_Honorific_LinkRepo)) if (ImGui.Button(HellionStrings.Settings_Integrations_Honorific_LinkRepo))
{ {
@@ -147,9 +142,7 @@ internal sealed class Integrations : ISettingsTab
ImGui.TextWrapped(HellionStrings.Settings_Integrations_ComingSoon_Intro); ImGui.TextWrapped(HellionStrings.Settings_Integrations_ComingSoon_Intro);
ImGui.Spacing(); ImGui.Spacing();
// Static list maintained in code (not Configuration). Each cycle // Each integration cycle removes its stub here and adds a full section above.
// that lands a real integration removes its stub here and adds a
// full section above the Coming Soon block.
DrawComingSoonItem( DrawComingSoonItem(
HellionStrings.Settings_Integrations_ComingSoon_ContextMenu_Title, HellionStrings.Settings_Integrations_ComingSoon_ContextMenu_Title,
HellionStrings.Settings_Integrations_ComingSoon_ContextMenu_Description HellionStrings.Settings_Integrations_ComingSoon_ContextMenu_Description
+1 -2
View File
@@ -20,8 +20,7 @@ internal sealed class Privacy : ISettingsTab
Mutable = mutable; Mutable = mutable;
} }
// (HeadingKey lookup, ChatType list). Heading is resolved per-frame so // (HeadingKey, ChatType list). Heading resolved per-frame for live language switching.
// a runtime LanguageChanged call updates the labels immediately.
private static readonly (Func<string> Heading, ChatType[] Types)[] Groups = private static readonly (Func<string> Heading, ChatType[] Types)[] Groups =
[ [
( (
+5 -7
View File
@@ -123,7 +123,7 @@ internal sealed class Tabs : ISettingsTab
ImGuiInputTextFlags.EnterReturnsTrue ImGuiInputTextFlags.EnterReturnsTrue
); );
// v1.2.0 — Per-Tab Icon-Override. Default-Mapping greift falls nichts gesetzt. // Per-tab icon override added in v1.2.0. Falls back to default mapping if unset.
ImGui.TextUnformatted(HellionStrings.Tabs_Icon_Label); ImGui.TextUnformatted(HellionStrings.Tabs_Icon_Label);
ImGui.SameLine(); ImGui.SameLine();
ImGuiUtil.HelpMarker(HellionStrings.Tabs_Icon_HelpMarker); ImGuiUtil.HelpMarker(HellionStrings.Tabs_Icon_HelpMarker);
@@ -135,7 +135,7 @@ internal sealed class Tabs : ISettingsTab
{ {
if (combo.Success) if (combo.Success)
{ {
// Erste Option: Default (löscht Icon, lässt Mapping greifen). // First option clears the icon and lets the default mapping take over.
if ( if (
ImGui.Selectable( ImGui.Selectable(
HellionStrings.Tabs_Icon_DefaultOption, HellionStrings.Tabs_Icon_DefaultOption,
@@ -148,7 +148,7 @@ internal sealed class Tabs : ISettingsTab
ImGui.Separator(); ImGui.Separator();
// Pool-Optionen aus TabIconGlyphResolver.PickerOptions (Single-Source-of-Truth). // Options sourced from TabIconGlyphResolver.PickerOptions (single source of truth).
foreach (var option in TabIconGlyphResolver.PickerOptions) foreach (var option in TabIconGlyphResolver.PickerOptions)
{ {
var isSelected = string.Equals( var isSelected = string.Equals(
@@ -305,10 +305,8 @@ internal sealed class Tabs : ISettingsTab
ImGui.SameLine(); ImGui.SameLine();
// Guard against an empty worlds list — can happen briefly // Guard against an empty worlds list (character switch or sheet not yet populated)
// when switching characters or if the datacenter sheet // to avoid an out-of-bounds crash on worlds[selectedWorld].
// has not yet populated. Without the guard the indexed
// access into worlds[selectedWorld] would crash.
if (worlds.Count == 0) if (worlds.Count == 0)
{ {
ImGui.TextDisabled("(no worlds available)"); ImGui.TextDisabled("(no worlds available)");
@@ -272,9 +272,8 @@ internal sealed class ThemeAndLayout : ISettingsTab
ImGui.Separator(); ImGui.Separator();
ImGui.Spacing(); ImGui.Spacing();
// Slider 50100 % UX-Range; intern 0.51.0 als WindowOpacity-Float. // Slider range 50-100% maps to 0.5-1.0 internally. Floor at 50% prevents
// Untere Schwelle 50 % verhindert versehentliches Komplett-Wegblenden // accidentally hiding the chat background (v1.2.0 bug at WindowAlpha=0).
// des Chat-Hintergrunds (war v1.2.0 Bug bei WindowAlpha=0).
var opacityPercent = Mutable.WindowOpacity * 100f; var opacityPercent = Mutable.WindowOpacity * 100f;
if ( if (
ImGuiUtil.DragFloatVertical( ImGuiUtil.DragFloatVertical(
+9 -10
View File
@@ -7,15 +7,14 @@ namespace HellionChat.Ui.SettingsTabs;
internal static class ThemeMockup internal static class ThemeMockup
{ {
// Zeichnet ein Mini-Chat-Window-Mockup mit den Theme-Werten direkt // Mini chat window mockup drawn directly into the WindowDrawList.
// ins WindowDrawList. Keine Texture, keine Allocation pro Frame — // No textures, no per-frame allocations — pure AddRectFilled/AddText.
// alles via DrawList.AddRectFilled / AddText.
public static void Draw(Vector2 origin, Vector2 size, Theme theme) public static void Draw(Vector2 origin, Vector2 size, Theme theme)
{ {
var draw = ImGui.GetWindowDrawList(); var draw = ImGui.GetWindowDrawList();
var c = theme.Colors; var c = theme.Colors;
// Window-Bg // Window background
draw.AddRectFilled( draw.AddRectFilled(
origin, origin,
origin + size, origin + size,
@@ -23,7 +22,7 @@ internal static class ThemeMockup
theme.Layout.WindowRounding theme.Layout.WindowRounding
); );
// Title-Bar // Title bar
var titleHeight = 14f; var titleHeight = 14f;
draw.AddRectFilled( draw.AddRectFilled(
origin, origin,
@@ -32,7 +31,7 @@ internal static class ThemeMockup
theme.Layout.WindowRounding theme.Layout.WindowRounding
); );
// Tab-Bar — 3 Mini-Tabs // Tab bar (3 tabs)
var tabY = origin.Y + titleHeight + 4f; var tabY = origin.Y + titleHeight + 4f;
var tabHeight = 12f; var tabHeight = 12f;
for (var i = 0; i < 3; i++) for (var i = 0; i < 3; i++)
@@ -46,7 +45,7 @@ internal static class ThemeMockup
theme.Layout.TabRounding theme.Layout.TabRounding
); );
if (i == 0) // Active-Pill if (i == 0) // active pill
{ {
draw.AddRectFilled( draw.AddRectFilled(
new Vector2(tabX, tabY + tabHeight - 2f), new Vector2(tabX, tabY + tabHeight - 2f),
@@ -56,7 +55,7 @@ internal static class ThemeMockup
} }
} }
// Card-Row mit Mock-Sender + Text // Message card row
var rowY = tabY + tabHeight + 6f; var rowY = tabY + tabHeight + 6f;
var rowHeight = 18f; var rowHeight = 18f;
draw.AddRectFilled( draw.AddRectFilled(
@@ -66,7 +65,7 @@ internal static class ThemeMockup
2f 2f
); );
// Akzent-Button rechts unten // Accent button (bottom right)
var btnW = 28f; var btnW = 28f;
var btnH = 10f; var btnH = 10f;
var btnX = origin.X + size.X - btnW - 6f; var btnX = origin.X + size.X - btnW - 6f;
@@ -78,7 +77,7 @@ internal static class ThemeMockup
theme.Layout.FrameRounding theme.Layout.FrameRounding
); );
// Border um das gesamte Mockup // Mockup border
draw.AddRect( draw.AddRect(
origin, origin,
origin + size, origin + size,
+2 -5
View File
@@ -107,7 +107,7 @@ internal sealed class Window : ISettingsTab
1, 1,
10 10
); );
// Untergrenze von 2 Sekunden gegen Selbst-Soft-Lock. // Floor at 2 seconds to prevent self-soft-lock.
Mutable.InactivityHideTimeout = Math.Max(2, Mutable.InactivityHideTimeout); Mutable.InactivityHideTimeout = Math.Max(2, Mutable.InactivityHideTimeout);
using (ImRaii.Disabled(Mutable.HideInBattle)) using (ImRaii.Disabled(Mutable.HideInBattle))
@@ -177,7 +177,6 @@ internal sealed class Window : ISettingsTab
ImGui.Checkbox(Language.Options_CanMove_Name, ref Mutable.CanMove); ImGui.Checkbox(Language.Options_CanMove_Name, ref Mutable.CanMove);
ImGui.Checkbox(Language.Options_CanResize_Name, ref Mutable.CanResize); ImGui.Checkbox(Language.Options_CanResize_Name, ref Mutable.CanResize);
// v0.6.0 — global master switch for the pop-out input bar.
ImGui.Checkbox( ImGui.Checkbox(
HellionStrings.Settings_Window_PopOutInputEnabled_Name, HellionStrings.Settings_Window_PopOutInputEnabled_Name,
ref Mutable.PopOutInputEnabled ref Mutable.PopOutInputEnabled
@@ -186,9 +185,7 @@ internal sealed class Window : ISettingsTab
ImGui.Spacing(); ImGui.Spacing();
// Manual escape hatch for off-screen windows. The plugin already // Fallback for off-screen windows after a display layout change.
// runs an automatic bounds check once per session, but a button
// is the user-friendly fallback after a display layout change.
if (ImGui.Button(HellionStrings.Settings_Window_ResetPosition_Name)) if (ImGui.Button(HellionStrings.Settings_Window_ResetPosition_Name))
Plugin.ChatLogWindow.RequestPositionReset = true; Plugin.ChatLogWindow.RequestPositionReset = true;
ImGuiUtil.HelpMarker(HellionStrings.Settings_Window_ResetPosition_Description); ImGuiUtil.HelpMarker(HellionStrings.Settings_Window_ResetPosition_Description);
+16 -38
View File
@@ -9,32 +9,23 @@ using HellionChat.Util;
namespace HellionChat.Ui; namespace HellionChat.Ui;
/// <summary> // Bottom status bar, 22px tall. Slots left to right: channel indicator,
/// Bottom-Status-Bar (v1.2.0). Fix 22 px hoch, BorderTop als Trenner. // privacy badge, counts, tells (hidden at 0), version (right-aligned).
/// Slots links → rechts: Channel-Indicator (Color-Dot + Channel-Name), // Updates at 1Hz; format strings are cached between updates.
/// Privacy-Badge (Lock-Icon + Privacy-Label), Counts (Tabs + Msgs),
/// Tells (Auto-Tell-Counter, hidden bei 0), Version (rechtsbündig, muted).
///
/// Update-Frequenz: 1×/Sekunde. Format-Strings werden zwischen Updates
/// gecached, damit kein Per-Frame-Format-Allocation entsteht.
/// </summary>
internal sealed class StatusBar internal sealed class StatusBar
{ {
public const float Height = 22f; public const float Height = 22f;
private const long UpdateIntervalMs = 1000; private const long UpdateIntervalMs = 1000;
// Cache-State — initial outdated, damit der erste Frame frisch berechnet. // Initially outdated so the first frame always computes fresh.
private long _lastUpdateMs = -UpdateIntervalMs; private long _lastUpdateMs = -UpdateIntervalMs;
private string _cachedCountsText = string.Empty; private string _cachedCountsText = string.Empty;
private string _cachedTellsText = string.Empty; private string _cachedTellsText = string.Empty;
/// <summary> // Pure string logic, testable without ImGui init.
/// Reine String-Logik — testbar ohne ImGui-Init.
/// </summary>
public static string FormatCounts(int tabs, int messages) public static string FormatCounts(int tabs, int messages)
{ {
// InvariantCulture: User-System-Locale darf das Format nicht // InvariantCulture so locale doesn't affect the format (e.g. de_DE "1,2k").
// verändern (de_DE würde sonst "1,2k" statt "1.2k" liefern).
var msgPart = var msgPart =
messages >= 1000 messages >= 1000
? string.Format(CultureInfo.InvariantCulture, "{0:0.0}k msg", messages / 1000.0) ? string.Format(CultureInfo.InvariantCulture, "{0:0.0}k msg", messages / 1000.0)
@@ -43,10 +34,7 @@ internal sealed class StatusBar
return $"{tabsPart} · {msgPart}"; return $"{tabsPart} · {msgPart}";
} }
/// <summary> // Pure string logic, testable without ImGui init. Returns empty string at 0 tells.
/// Reine String-Logik — testbar ohne ImGui-Init.
/// 0 Tells → Leerstring (Slot wird ausgeblendet).
/// </summary>
public static string FormatTells(int count) public static string FormatTells(int count)
{ {
if (count <= 0) if (count <= 0)
@@ -54,8 +42,7 @@ internal sealed class StatusBar
return $"{count} {(count == 1 ? "tell" : "tells")}"; return $"{count} {(count == 1 ? "tell" : "tells")}";
} }
// Single-pass replacement for the LINQ Sum+Count pair in Draw. Pure // Single-pass replacement for a LINQ Sum+Count pair. Pure helper for unit testing.
// helper so a future LINQ regression gets pinned by xUnit.
internal static (int messages, int tells) AggregateForStatusBar(IList<Tab> tabs) internal static (int messages, int tells) AggregateForStatusBar(IList<Tab> tabs)
{ {
int messages = 0, int messages = 0,
@@ -69,10 +56,7 @@ internal sealed class StatusBar
return (messages, tells); return (messages, tells);
} }
/// <summary> // Test hook to verify cache logic without a real time source.
/// Test-Hook: Cache-Logic ohne reale Time-Source verifizieren.
/// Nicht für Production-Render.
/// </summary>
internal (string counts, string tells) SnapshotForTest( internal (string counts, string tells) SnapshotForTest(
long now, long now,
int tabs, int tabs,
@@ -93,24 +77,18 @@ internal sealed class StatusBar
_lastUpdateMs = now; _lastUpdateMs = now;
} }
/// <summary>
/// Render-Pfad. Aufrufer pusht bereits den HellionStyle/Theme;
/// wir lesen nur die aktiven Theme-Farben und zeichnen.
/// </summary>
public void Draw(Plugin plugin) public void Draw(Plugin plugin)
{ {
var theme = plugin.ThemeRegistry.Active; var theme = plugin.ThemeRegistry.Active;
var now = Environment.TickCount64; var now = Environment.TickCount64;
// Outer gate keeps the foreach out of the hot path 99% of frames.
// UpdateCacheIfDue runs the same check internally — idempotent.
if (now - _lastUpdateMs >= UpdateIntervalMs) if (now - _lastUpdateMs >= UpdateIntervalMs)
{ {
var (messages, tells) = AggregateForStatusBar(Plugin.Config.Tabs); var (messages, tells) = AggregateForStatusBar(Plugin.Config.Tabs);
UpdateCacheIfDue(now, Plugin.Config.Tabs.Count, messages, tells); UpdateCacheIfDue(now, Plugin.Config.Tabs.Count, messages, tells);
} }
// BorderTop als Trenner — DrawList-Line, ImGui-Separator hat zu viel Padding. // Border top via DrawList -- ImGui.Separator has too much padding.
var cursorY = ImGui.GetCursorScreenPos().Y; var cursorY = ImGui.GetCursorScreenPos().Y;
var winLeft = ImGui.GetWindowPos().X; var winLeft = ImGui.GetWindowPos().X;
var winRight = winLeft + ImGui.GetWindowSize().X; var winRight = winLeft + ImGui.GetWindowSize().X;
@@ -123,9 +101,9 @@ internal sealed class StatusBar
1f 1f
); );
ImGui.Dummy(new Vector2(0, 2)); // BorderTop-Spacing ImGui.Dummy(new Vector2(0, 2));
// Slot 1: Active-Channel-Indicator // Slot 1: active channel indicator
var inputCh = plugin.CurrentTab?.CurrentChannel?.Channel ?? InputChannel.Invalid; var inputCh = plugin.CurrentTab?.CurrentChannel?.Channel ?? InputChannel.Invalid;
var hasChannel = inputCh != InputChannel.Invalid; var hasChannel = inputCh != InputChannel.Invalid;
var chatType = inputCh.ToChatType(); var chatType = inputCh.ToChatType();
@@ -137,7 +115,7 @@ internal sealed class StatusBar
ImGui.SameLine(); ImGui.SameLine();
ImGui.TextUnformatted(channelName); ImGui.TextUnformatted(channelName);
// Slot 2: Privacy-Badge — abgeleitet aus PrivacyFilterEnabled. // Slot 2: privacy badge
ImGui.SameLine(); ImGui.SameLine();
DrawSeparator(); DrawSeparator();
ImGui.SameLine(); ImGui.SameLine();
@@ -151,13 +129,13 @@ internal sealed class StatusBar
: HellionStrings.StatusBar_Privacy_Open; : HellionStrings.StatusBar_Privacy_Open;
ImGui.TextUnformatted(privacyLabel); ImGui.TextUnformatted(privacyLabel);
// Slot 3: Counts // Slot 3: counts
ImGui.SameLine(); ImGui.SameLine();
DrawSeparator(); DrawSeparator();
ImGui.SameLine(); ImGui.SameLine();
ImGui.TextUnformatted(_cachedCountsText); ImGui.TextUnformatted(_cachedCountsText);
// Slot 4: Tells (nur wenn > 0) // Slot 4: tells (hidden at 0)
if (!string.IsNullOrEmpty(_cachedTellsText)) if (!string.IsNullOrEmpty(_cachedTellsText))
{ {
ImGui.SameLine(); ImGui.SameLine();
@@ -166,7 +144,7 @@ internal sealed class StatusBar
ImGui.TextUnformatted(_cachedTellsText); ImGui.TextUnformatted(_cachedTellsText);
} }
// Slot 5: Version (rechtsbündig, muted) // Slot 5: version, right-aligned, muted
var versionText = $"v{Plugin.Interface.Manifest.AssemblyVersion} · Hellion"; var versionText = $"v{Plugin.Interface.Manifest.AssemblyVersion} · Hellion";
var versionWidth = ImGui.CalcTextSize(versionText).X; var versionWidth = ImGui.CalcTextSize(versionText).X;
var contentRegionMax = ImGui.GetContentRegionMax().X; var contentRegionMax = ImGui.GetContentRegionMax().X;
+11 -36
View File
@@ -1,22 +1,11 @@
namespace HellionChat.Ui; namespace HellionChat.Ui;
/// <summary> // Pure string resolver logic with no Dalamud dependency, kept in its own
/// Reine String-Resolver-Logik ohne Dalamud-Dependency. Bewusst in // file so tests (HellionChat.Tests, no Dalamud reference) can call it directly.
/// eigener Datei (Dependency-Boundary auf File-Level sichtbar), damit // Used in the settings UI glyph picker and indirectly via TabIconMapping.Resolve.
/// Tests (HellionChat.Tests, Microsoft.NET.Sdk ohne Dalamud-Reference)
/// sie aufrufen können, ohne dass die JIT beim Methodenaufruf die
/// Dalamud-Assembly laden muss.
///
/// Wird im Settings-UI (T7) für die Glyph-Picker-Combobox und im
/// Render-Code indirekt über <see cref="TabIconMapping.Resolve(Tab)"/>
/// verwendet.
/// </summary>
internal static class TabIconGlyphResolver internal static class TabIconGlyphResolver
{ {
/// <summary> // Single source of truth for the glyph set; order matches the settings combobox.
/// Picker-Options-Pool — Single Source of Truth für das Glyph-Set.
/// Reihenfolge ist die UI-Reihenfolge im Settings-Tab Icon-Combobox.
/// </summary>
public static readonly IReadOnlyList<string> PickerOptions = public static readonly IReadOnlyList<string> PickerOptions =
[ [
"comment", "comment",
@@ -36,20 +25,13 @@ internal static class TabIconGlyphResolver
"fire", "fire",
]; ];
/// <summary> // Derived from PickerOptions -- never maintain this manually.
/// Glyph-Set, das überhaupt als Override akzeptiert wird. Aus
/// <see cref="PickerOptions"/> abgeleitet — KnownGlyphs nie
/// manuell pflegen.
/// </summary>
private static readonly HashSet<string> KnownGlyphs = new( private static readonly HashSet<string> KnownGlyphs = new(
PickerOptions, PickerOptions,
StringComparer.OrdinalIgnoreCase StringComparer.OrdinalIgnoreCase
); );
/// <summary> // Tab.Name is localised, so we match against a pool of DE/EN synonyms.
/// Tab-Name → Default-Glyph-Name. Tab.Name wird per Lokalisierung
/// gesetzt; wir matchen daher gegen einen Pool aus DE/EN-Synonymen.
/// </summary>
private static readonly Dictionary<string, string> NameDefaults = new( private static readonly Dictionary<string, string> NameDefaults = new(
StringComparer.OrdinalIgnoreCase StringComparer.OrdinalIgnoreCase
) )
@@ -69,18 +51,11 @@ internal static class TabIconGlyphResolver
["tell"] = "envelope", ["tell"] = "envelope",
}; };
/// <summary> // Resolves the glyph name for a tab. Priority order:
/// Test-Surface: Glyph-Name-Resolver ohne Dalamud-Dependency. // 1. Tab.Icon override (if set): known glyph -> use it, unknown -> "hashtag"
/// Reihenfolge: // 2. Auto-tell tab -> autoTellGlyph if provided, else "clock"
/// 1. Tab.Icon-Override (falls gesetzt und nicht nur Whitespace): // 3. Name default lookup
/// a) bekannter Glyph → diesen Glyph // 4. Fallback "hashtag"
/// b) unbekannter Glyph → harter Fallback "hashtag" (User hat
/// bewusst etwas gesetzt, also überstimmt das die Defaults)
/// 2. Auto-Tell-Tab → <paramref name="autoTellGlyph"/> falls
/// übergeben, sonst "clock".
/// 3. Tab-Name-Default (<see cref="NameDefaults"/>-Lookup)
/// 4. Fallback "hashtag"
/// </summary>
public static string ResolveGlyphName(Tab tab, string? autoTellGlyph = null) public static string ResolveGlyphName(Tab tab, string? autoTellGlyph = null)
{ {
if (!string.IsNullOrWhiteSpace(tab.Icon)) if (!string.IsNullOrWhiteSpace(tab.Icon))
+8 -35
View File
@@ -2,31 +2,14 @@ using Dalamud.Interface;
namespace HellionChat.Ui; namespace HellionChat.Ui;
/// <summary> // Default icon mapping for tabs, used in top-tabs (icon prefix) and sidebar (icon-only with tooltip).
/// Default-Icon-Mapping für Tabs. v1.2.0 Layout-Refresh nutzt das // Users can override per tab via Settings -> Tabs -> Tab.Icon.
/// in Top-Tabs (Icon-Prefix) und Sidebar (Icon-only mit Tooltip). // Pure string resolver logic lives in TabIconGlyphResolver (no Dalamud dependency) for testability.
/// User können in Settings → Tabs per Tab.Icon-Override eigene
/// FontAwesome-Glyphen setzen.
///
/// Diese Klasse ist Dalamud-abhängig (FontAwesomeIcon-Enum). Die
/// reine String-Resolver-Logik liegt bewusst in
/// <see cref="TabIconGlyphResolver"/> (eigene Datei, ohne
/// Dalamud-Imports), damit Tests sie ohne Dalamud-Reference aufrufen
/// können.
/// </summary>
internal static class TabIconMapping internal static class TabIconMapping
{ {
/// <summary> // Glyph name -> FontAwesomeIcon lookup for production resolve.
/// FontAwesome-Glyph-Name → Icon-Enum-Lookup. Wird für die // Every key must also exist in TabIconGlyphResolver.PickerOptions.
/// Production-Resolve-API benötigt. // A missing key silently falls back to FontAwesomeIcon.Hashtag (degraded, no crash).
///
/// INVARIANTE: Jeder Key in <see cref="GlyphLookup"/> muss auch in
/// <see cref="TabIconGlyphResolver.PickerOptions"/> stehen. Wird
/// ein Glyph zu PickerOptions hinzugefügt, aber nicht hier, fällt
/// die Override-Auflösung still auf <see cref="FontAwesomeIcon.Hashtag"/>
/// zurück (degraded, kein Crash). Build-Time-Enforcement ist nicht
/// möglich, weil PickerOptions ohne Dalamud-Reference auskommt.
/// </summary>
private static readonly Dictionary<string, FontAwesomeIcon> GlyphLookup = new( private static readonly Dictionary<string, FontAwesomeIcon> GlyphLookup = new(
StringComparer.OrdinalIgnoreCase StringComparer.OrdinalIgnoreCase
) )
@@ -48,23 +31,13 @@ internal static class TabIconMapping
["fire"] = FontAwesomeIcon.Fire, ["fire"] = FontAwesomeIcon.Fire,
}; };
/// <summary> // Resolves the icon for a tab. Auto-tell tabs get a per-partner hashed icon
/// Production-Surface: liefert das Icon für einen Tab. Wrapper um // from the tell pool so parallel tells differ by glyph shape, not just colour.
/// <see cref="TabIconGlyphResolver.ResolveGlyphName(Tab)"/> plus
/// Enum-Lookup. Wird von Render-Code (T3, T5) verwendet.
/// </summary>
public static FontAwesomeIcon Resolve(Tab tab) public static FontAwesomeIcon Resolve(Tab tab)
{ {
// v1.2.0 — Auto-Tell-Tabs bekommen ein per-Partner gehashtes
// Icon aus dem Tell-Pool. Damit unterscheiden sich parallele
// Tells nicht nur über die Color (For), sondern auch über die
// Glyph-Form. Berechnung bleibt hier (Dalamud-bound), weil
// TellTarget Dalamud-Imports hat.
string? autoTellGlyph = null; string? autoTellGlyph = null;
if (tab.IsTempTab && tab.TellTarget != null && tab.TellTarget.IsSet()) if (tab.IsTempTab && tab.TellTarget != null && tab.TellTarget.IsSet())
{
autoTellGlyph = TabTintCache.GetIcon(tab); autoTellGlyph = TabTintCache.GetIcon(tab);
}
var glyph = TabIconGlyphResolver.ResolveGlyphName(tab, autoTellGlyph); var glyph = TabIconGlyphResolver.ResolveGlyphName(tab, autoTellGlyph);
return GlyphLookup.TryGetValue(glyph, out var icon) ? icon : FontAwesomeIcon.Hashtag; return GlyphLookup.TryGetValue(glyph, out var icon) ? icon : FontAwesomeIcon.Hashtag;
@@ -2,16 +2,17 @@ using System;
namespace HellionChat._Helpers; namespace HellionChat._Helpers;
// Pure-helper mirror of the compact pop-out history-navigation cursor // Extracted history-navigation cursor math from CompactCallback to allow unit
// math. The original CompactCallback was tangled with ImGuiInputTextCallbackData // testing without ImGuiInputTextCallbackData (DeleteChars/InsertChars).
// (DeleteChars/InsertChars), which can't be exercised in xUnit. The // Buffer mutation stays at the call site; only the cursor/replacement decision lives here.
// ImGui buffer mutation stays at the call site; only the deterministic
// cursor-and-replacement decision lives here.
// //
// Index semantics match InputHistoryService: // Index semantics match InputHistoryService:
// index 0 = oldest entry // index 0 = oldest entry
// index Count - 1 = newest entry // index Count-1 = newest entry
// cursor == -1 = "not browsing history" // cursor == -1 = not browsing history
//
// replacement == null: caller must NOT touch the buffer (cursor unchanged).
// replacement != null: write it to the buffer (including "" to clear it).
// //
// TEST-MIRROR: ../../../Hellion Build test/Ui/CompactInputHistoryNavigatorTests.cs // TEST-MIRROR: ../../../Hellion Build test/Ui/CompactInputHistoryNavigatorTests.cs
public static class CompactInputHistoryNavigator public static class CompactInputHistoryNavigator
@@ -22,9 +23,6 @@ public static class CompactInputHistoryNavigator
Down, Down,
} }
// replacement == null means: caller must NOT touch the buffer. This
// distinguishes "cursor unchanged, leave the user's typing alone"
// from "cursor moved to an empty slot, clear the buffer".
public static (int cursor, string? replacement) Navigate( public static (int cursor, string? replacement) Navigate(
Direction direction, Direction direction,
int currentCursor, int currentCursor,
@@ -38,7 +36,6 @@ public static class CompactInputHistoryNavigator
ArgumentNullException.ThrowIfNull(push); ArgumentNullException.ThrowIfNull(push);
ArgumentNullException.ThrowIfNull(getByCursor); ArgumentNullException.ThrowIfNull(getByCursor);
var prev = currentCursor;
var next = currentCursor; var next = currentCursor;
switch (direction) switch (direction)
@@ -46,8 +43,7 @@ public static class CompactInputHistoryNavigator
case Direction.Up: case Direction.Up:
if (currentCursor == -1) if (currentCursor == -1)
{ {
// First Up press from a fresh buffer: stash whatever // Stash current input so the user can recover it after browsing.
// the user typed so they can recover it after browsing.
var offset = 0; var offset = 0;
if (!string.IsNullOrWhiteSpace(currentBuffer)) if (!string.IsNullOrWhiteSpace(currentBuffer))
{ {
@@ -57,10 +53,9 @@ public static class CompactInputHistoryNavigator
next = getCount() - 1 - offset; next = getCount() - 1 - offset;
} }
else if (currentCursor > 0) else if (currentCursor > 0)
{
next--; next--;
}
break; break;
case Direction.Down: case Direction.Down:
if (currentCursor != -1) if (currentCursor != -1)
{ {
@@ -71,10 +66,9 @@ public static class CompactInputHistoryNavigator
break; break;
} }
if (prev == next) if (next == currentCursor)
return (next, null); return (next, null);
var replacement = getByCursor(next) ?? string.Empty; return (next, getByCursor(next) ?? string.Empty);
return (next, replacement);
} }
} }
@@ -3,11 +3,8 @@ using HellionChat.Ui;
namespace HellionChat._Helpers; namespace HellionChat._Helpers;
// Pure-helper mirror of the compact pop-out submit flow. ChatInputBar's // Extracted submit logic from ChatInputBar.SubmitCompact to allow unit testing
// SubmitCompact used to inline this against a sealed ChatLogWindow, which // without a sealed ChatLogWindow dependency.
// blocks Moq-based isolation. Lifting the deterministic part into a POCO
// keeps the production call site a one-liner while letting xUnit assert
// the buffer/cursor reset and the sender contract directly.
// TEST-MIRROR: ../../../Hellion Build test/Ui/CompactInputSubmitterTests.cs // TEST-MIRROR: ../../../Hellion Build test/Ui/CompactInputSubmitterTests.cs
public static class CompactInputSubmitter public static class CompactInputSubmitter
{ {
+4 -1
View File
@@ -57,7 +57,7 @@ Both are good projects. Use what fits you best.
## Tooling ## Tooling
| Tool | Purpose | | Tool | Purpose |
| ----------------------------------------------------- | ------------------------------------------------------------- | | ----------------------------------------------------- | ------------------------------------------------------------------- |
| [Claude](https://claude.ai) (Anthropic) | Pair-level AI assistance via Claude Code CLI | | [Claude](https://claude.ai) (Anthropic) | Pair-level AI assistance via Claude Code CLI |
| [VS Code](https://code.visualstudio.com) + C# Dev Kit | Primary IDE | | [VS Code](https://code.visualstudio.com) + C# Dev Kit | Primary IDE |
| Dedicated Windows 11 VM | Build and in-game test environment (Dalamud requires Windows) | | Dedicated Windows 11 VM | Build and in-game test environment (Dalamud requires Windows) |
@@ -65,6 +65,9 @@ Both are good projects. Use what fits you best.
| [Microsoft Learn](https://learn.microsoft.com) | .NET and C# documentation | | [Microsoft Learn](https://learn.microsoft.com) | .NET and C# documentation |
| [Context7](https://context7.com) | Up-to-date library docs for Claude context | | [Context7](https://context7.com) | Up-to-date library docs for Claude context |
| [Stack Overflow](https://stackoverflow.com) | General C# and .NET problem-solving | | [Stack Overflow](https://stackoverflow.com) | General C# and .NET problem-solving |
| Custom build test suite | Pattern-based integration tests written from scratch, drawing on |
| | conventions from Lightless, Umbra and other standard FFXIV plugins. |
| | Not publicly available. Yet. |
## Contact ## Contact
+49 -53
View File
@@ -1,87 +1,83 @@
# Contributors — Hellion Chat # Contributors — Hellion Chat
Hellion Chat ist von der Code-Seite ein Ein-Personen-Projekt. Aber ohne die Leute auf dieser Seite gäbe es weder die Hellion Chat is a one-person project on the code side. But without the people on this page, the bug fixes and UX
Bug-Fixes noch die UX-Verbesserungen, die seit den frühen Versionen reingelaufen sind. Jeder Eintrag hier hat das Plugin improvements that have landed since the early versions would not exist. Every entry here has made the plugin concretely
konkret besser gemacht. better.
Die Anerkennung an die Upstream-Autoren von Chat 2 (Infi und Anna) liegt bewusst in [`../NOTICE.md`](../NOTICE.md), Attribution for the upstream Chat 2 authors (Infi and Anna) is intentionally in [`../NOTICE.md`](../NOTICE.md), not
nicht hier. Diese Datei deckt explizit Beiträge zur Hellion-Chat-Seite ab. here. This file covers contributions to the Hellion Chat side specifically.
--- ---
## Entwicklung ## Development
### JonKazama (Florian Wathling) — Maintainer ### JonKazama (Florian Wathling) — Maintainer
Hellion Chat ist mein erstes FFXIV-Plugin und mein erstes größeres C#-/Dalamud-Projekt. Mein beruflicher Hintergrund ist Hellion Chat is my first FFXIV plugin and my first larger C#/Dalamud project. My professional background is web
Webentwicklung (Next.js, React, TypeScript, Prisma). Plugin-Entwicklung in einer fremden Codebase, ImGui, development (Next.js, React, TypeScript, Prisma). Plugin development in an unfamiliar codebase, ImGui, FFXIV game hooks
FFXIV-Game-Hooks und der gesamte Dalamud-Stack waren Neuland. and the entire Dalamud stack were new territory.
Privacy-First-Defaults, Per-Channel-Retention, Auto-Tell-Tabs, Pop-Out-Input, ChatColours-Presets, Hellion-Theme plus Privacy-first defaults, per-channel retention, Auto-Tell-Tabs, pop-out input, ChatColours presets, the Hellion theme
Exo-2-Font und der v1.0.0-Standalone-Cut sind die Hellion-spezifischen Surface-Areas, die ich auf das Chat-2-Fundament plus Exo 2 font, and the v1.0.0 standalone cut are the Hellion-specific surface areas I built on top of the Chat 2
aufgebaut habe. Die Lern-Geschichte dahinter steht in [`LEARNING-JOURNEY.md`](LEARNING-JOURNEY.md). foundation. The learning story behind that is in [`LEARNING-JOURNEY.md`](LEARNING-JOURNEY.md).
Hellion Chat ist Teil von [Hellion Online Media](https://hellion-media.de). Hellion Chat is part of [Hellion Online Media](https://hellion-media.de).
--- ---
## Tester ## Testers
Eine kurze Notiz vorneweg: Ich teste das Plugin nicht allein. Die Leute hier haben mir Bugs gemeldet, bevor sie bei mehr A quick note: I do not test this plugin alone. The people listed here reported bugs before they hit more users, raised
Nutzern aufgeschlagen wären. Sie haben UX-Probleme angesprochen, die ich blind nicht mehr gesehen habe. Und sie haben UX problems I had gone blind to, and brought in feature requests that pushed the plugin in directions I would not have
Feature-Wünsche eingebracht, die das Plugin in Richtungen geschoben haben, in die ich von alleine nicht gegangen wäre. gone on my own. That is not a given. External testers are worth their time.
Das ist nicht selbstverständlich. Externe Tester sind ihre Zeit wert.
### Carl Beleandis (Carla) — Beta-Tester ### Carl Beleandis (Carla) — Beta Tester
Carl testet seit der Bootstrap-Phase und hat sowohl die Pop-Out-Mechanik als auch die Theme-Richtung geprägt. Sein Carl has been testing since the bootstrap phase and has shaped both the pop-out mechanics and the theme direction.
Feedback kommt direkt und ohne Umschweife und das ist genau, was ich beim Testen brauche. Feedback comes direct and without detours, which is exactly what I need when testing.
Konkrete Beiträge: Concrete contributions:
- **Pop-Out-Discoverability** — der Hinweis, dass Pop-Outs nur per Rechtsklick erreichbar waren, hat den Header-Button - **Pop-out discoverability** — pointing out that pop-outs were only reachable via right-click triggered the header
und den einmaligen Hint-Banner in v0.6.1 ausgelöst. Ich kannte den Rechtsklick-Pfad blind, deshalb hatte ich nicht button and the one-time hint banner in v0.6.1. I knew the right-click path by heart and had stopped seeing that new
mehr gesehen, dass neue Nutzer die Funktion gar nicht finden. users could not find the feature at all.
- **/tell-Pop-Out-Mode** — der Wunsch, /tell-Tabs direkt als Pop-Out zu öffnen statt über den Tab-Umweg, ist in v0.6.1 - **/tell pop-out mode** — the request to open /tell tabs directly as a pop-out instead of going through the tab sidebar
als opt-in Settings-Toggle gelandet. Bonus: Bei der Implementation ist ein alter Ghost-Window-Bug aufgefallen landed in v0.6.1 as an opt-in settings toggle. Bonus: during implementation an old ghost-window bug surfaced (LRU drop
(LRU-Drop ließ Pop-Out-Fenster als Geister stehen), der gleich mit gefixt wurde. left pop-out windows as ghosts), which got fixed at the same time.
- **Theme-Varianten mit Helligkeits-Abstufungen** — der Wunsch nach einer Grün-Familie hat mein Verständnis von "ein - **Theme variants with brightness gradations** — the request for a green family shifted my thinking from "one theme =
Theme = eine Farbe" auf "Theme-Familien mit Stimmungs-Varianten" verschoben. Steht in der [Roadmap](ROADMAP.md) für one colour" to "theme families with mood variants". On the [roadmap](ROADMAP.md) for a later cycle.
einen späteren Cycle.
### Jin (Jingliu) — Alpha-Tester ### Jin (Jingliu) — Alpha Tester
Jin ist der aktive Tester der ersten Stunde und hat den Pop-Out-Workflow architektonisch in eine andere Richtung Jin is the active tester from day one and pushed the pop-out workflow architecture in a different direction.
geschoben.
Konkrete Beiträge: Concrete contributions:
- **Pop-Out-Tab mit Input-Feld** — der Vorschlag, in einem Pop-Out auch tippen zu können (statt nur lesen), hat die - **Pop-out tab with input bar** — the suggestion to be able to type in a pop-out (instead of just reading) triggered
v0.6.0 Pop-Out-Input-Bar ausgelöst. Das war ein größerer Refactor: Der Input-Layer aus `ChatLogWindow` musste so the v0.6.0 pop-out input bar. That was a larger refactor: the input layer from `ChatLogWindow` had to be opened up so
geöffnet werden, dass er auch in `Popout.cs` lebt, mit unabhängigem Text-Buffer und History-Cursor pro Pop-Out. Hat it could also live in `Popout.cs`, with an independent text buffer and history cursor per pop-out. It dominated the
den Cycle dominiert, weil das Design erst sauber sein musste, bevor Code passieren konnte. cycle because the design had to be clean before any code could happen.
- **TempTell Persistence** — der Wunsch, /tell-Tabs per Pin-Toggle einen Relog überleben zu lassen, steht in der - **TempTell persistence** — the request for /tell tabs to survive a relog via a pin toggle is on the
[Roadmap](ROADMAP.md) für einen späteren Cycle. Berührt das Tab-System architektonisch und braucht eigenes Design. [roadmap](ROADMAP.md) for a later cycle. It touches the tab system architecturally and needs its own design work.
--- ---
## Übersetzungen ## Translations
Hellion-eigene UI-Strings werden in `HellionChat/Resources/HellionStrings.<lang>.resx` gepflegt. Hellion-specific UI strings are maintained in `HellionChat/Resources/HellionStrings.<lang>.resx`.
- **Deutsch (DE):** JonKazama (Native Speaker, Hauptsprache des Projekts) - **German (DE):** JonKazama (native speaker, primary project language)
Die Upstream-Sprach-Dateien (`Language.<lang>.resx`) sind nicht Teil dieser Datei. Sie werden über das Upstream language files (`Language.<lang>.resx`) are not covered here. They are maintained via the
[Chat-2-Crowdin-Projekt](https://github.com/Infiziert90/ChatTwo) gepflegt; Crowdin-Übersetzer findest du in den [Chat 2 Crowdin project](https://github.com/Infiziert90/ChatTwo); Crowdin translators are listed in the plugin settings
Plugin-Settings unter **Info → "Chat 2 community translators"**. under **Info → "Chat 2 community translators"**.
--- ---
## Wie du beitragen kannst ## How to Contribute
Bug-Reports, Feature-Wünsche und Pull-Requests laufen über Bug reports, feature requests and feedback are welcome — the best place to reach me is the Hellion Forge Discord:
[Gitea Issues](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/issues). Workflow und Erwartungen stehen [discord.gg/X9V7Kcv5gR](https://discord.gg/X9V7Kcv5gR). Join and ping me in the Hellion Chat channel.
in [`../CONTRIBUTING.md`](../CONTRIBUTING.md), Code of Conduct in [`../CODE_OF_CONDUCT.md`](../CODE_OF_CONDUCT.md).
Tester-Pool für neue Versionen läuft über den Hellion-Forge-Discord: For pull requests and contribution guidelines see [`../CONTRIBUTING.md`](../CONTRIBUTING.md), Code of Conduct in
[discord.gg/X9V7Kcv5gR](https://discord.gg/X9V7Kcv5gR). Wer in den Tester-Channel rein will, einfach im Forge melden. [`../CODE_OF_CONDUCT.md`](../CODE_OF_CONDUCT.md).
+218 -223
View File
@@ -1,336 +1,331 @@
# Entwicklungsgeschichte und Lernprozess # Development History and Learning Process
## Hintergrund ## Background
Ich bin Autodidakt. Hellion Chat ist mein erstes FFXIV-Plugin und mein erstes größeres C#-Projekt. Mein beruflicher I am self-taught. Hellion Chat is my first FFXIV plugin and my first larger C# project. My professional background is
Hintergrund ist Webentwicklung (Next.js, React, TypeScript, Prisma, MySQL), also Browser-Welt mit JavaScript-Toolchain. web development (Next.js, React, TypeScript, Prisma, MySQL) — browser world with a JavaScript toolchain. I knew C# only
C# kannte ich vor diesem Projekt nur oberflächlich, ImGui gar nicht, Dalamud nur als Endnutzer über andere Plugins. superficially before this project, ImGui not at all, and Dalamud only as an end user through other plugins.
Wenn ich an einer Stelle nicht weiterkomme, nutze ich AI-Tools wie Claude Code als Pair-Hilfsmittel. Wie das genau When I get stuck somewhere, I use AI tools like Claude Code as a pair assistant. What that looks like exactly and which
aussieht und welche Klassifikation ich verwende, steht transparent in [`AI_DISCLOSURE.md`](AI_DISCLOSURE.md). classification I use is documented transparently in [`AI_DISCLOSURE.md`](AI_DISCLOSURE.md).
--- ---
## Warum überhaupt ein Chat-Plugin? ## Why a chat plugin at all?
Hellion Chat soll Chat 2 nicht ersetzen. Chat 2 liefert ein vollständiges Chat-Erlebnis mit kompletter Historie, Hellion Chat is not meant to replace Chat 2. Chat 2 delivers a complete chat experience with full history, filters,
Filtern, Suche und Replay. Für die meisten Nutzer ist genau das richtig. search and replay. For most users that is exactly the right thing.
### Zwei Millionen Nachrichten in zwei Jahren ### Two million messages in two years
Mein Wunsch nach einem engeren Default war ehrlich gesagt erstmal persönlich. Nach zwei Jahren mit Chat 2 lag meine My desire for a tighter default was honestly personal at first. After two years with Chat 2 my database had grown to
Datenbank bei über zwei Millionen Nachrichten, der Großteil davon /say, /shout und /yell von wildfremden Leuten in over two million messages, the majority of them /say, /shout and /yell from complete strangers in Limsa. That is exactly
Limsa. Genau diese Daten machen Chat 2's Voll-Historie nützlich, und die meisten Nutzer behalten sie auch gerne. Mein what makes Chat 2's full history useful, and most users are happy to keep it. My own preference wanted a smaller
eigener Geschmack wollte einen kleineren Default. Also habe ich diesen Fork gebaut. default. So I built this fork.
### Greeter in mehreren Clubs ### Greeter in several clubs
Dazu kam ein zweiter Use-Case: Ich bin in mehreren FFXIV-Clubs als Greeter aktiv. Für die Greeter-Arbeit reicht die There was a second use case: I am active as a greeter in several FFXIV clubs. The vanilla chat interface is not enough
Vanilla-Chat-Oberfläche nicht. Parallel laufende /tell-Gespräche schreiben in einem einzigen Tab durcheinander, und ich for greeter work. Parallel /tell conversations write into a single tab at the same time, and I constantly lose track of
verliere ständig den Faden, wer mir gerade was geschrieben hat. Auto-Tell-Tabs (eines der frühen Hellion-Chat-Features) who wrote what. Auto-Tell-Tabs (one of the early Hellion Chat features) came directly from this workflow: one tab per
ist genau für diesen Workflow entstanden: ein Tab pro Gesprächspartner, automatisch gespawnt, mit manuellem conversation partner, automatically spawned, with a manual greeted status. The privacy hygiene benefit was a nice bonus,
Greeted-Status. Dass das auch der Privacy-Hygiene gut tut, war ein netter Bonus, nicht der Auslöser. not the trigger.
### Hellion Online Media ### Hellion Online Media
Die Privacy-Defaults sind außerdem eine Position aus meinem Hauptberuf. Hellion Online Media ist mein Einzelunternehmen, The privacy defaults also reflect a position from my main work. Hellion Online Media is my sole proprietorship, and data
und Datenschutz gegenüber Kunden ist da kein Marketing-Slogan, sondern operativ relevant. Dieser Fork ist die protection toward clients is not a marketing slogan there but operationally relevant. This fork is the plugin form of
Plugin-Form derselben Haltung. the same stance.
--- ---
## Warum nicht beim Original mitarbeiten? ## Why not contribute to the original?
Drei Gründe, in absteigender Wichtigkeit. Three reasons, in descending order of importance.
### Defaults sind nicht verhandelbar, auch nicht meine ### Defaults are not negotiable, including mine
Privacy-First als Standard ist eine Minderheits-Position. Chat 2 bedient zu Recht die breite Masse mit Voll-Historie als Privacy-first as a default is a minority position. Chat 2 rightly serves the broad majority with full history as the
Default. Diese Defaults im Upstream zu ändern wäre falsch gewesen. Ich hätte den Standard für eine große Nutzerbasis default. Changing those defaults upstream would have been wrong. I would have flipped the standard for a large user base
umgekippt, die ihn so wollte, wie er ist. Saubere Trennung über einen eigenen Plugin-Slot war der respektvollere Weg. that wanted it as it was. A clean separation through a dedicated plugin slot was the more respectful path.
### Das Webinterface musste weg ### The web interface had to go
Das ist ein zentrales Chat-2-Feature für Remote-Zugriff vom Zweitgerät. Ein PR der das entfernt, hat in einem gepflegten It is a central Chat 2 feature for remote access from a second device. A PR removing it has no chance in a
Upstream-Projekt keine Chance, und das ist auch richtig so. Aber genau das Webinterface kollidiert mit der well-maintained upstream project, and that is correct. But exactly that web interface conflicts with the privacy-first
Privacy-First-These dieses Forks: Ein Chat-Plugin das einen lokalen HTTP-Server startet, ist für mein Threat-Model eine premise of this fork: a chat plugin that starts a local HTTP server is too large an attack surface for my threat model.
zu große Angriffsfläche. Also raus damit. So out it went.
### Tempo ### Velocity
Ein Solo-Maintainer-Projekt mit kleinem Tester-Pool kann schneller iterieren als ein etabliertes Plugin mit großer A solo-maintainer project with a small tester pool can iterate faster than an established plugin with a large user base.
Nutzerbasis. Das ist kein Vorwurf an Upstream, sondern eine andere Optimierung. Ich brauche keine Roadmap-Abstimmung, That is not a criticism of upstream but a different optimization. I do not need roadmap alignment, reviewer
keine Reviewer-Verfügbarkeit, und kann Audit-Konsequenzen wie das Webinterface-Removal in einer einzigen Version availability, or to spread audit consequences like the web interface removal across multiple releases.
durchziehen statt über mehrere Releases.
EUPL-1.2 erlaubt das alles ausdrücklich, mit klarer Attribution. Der Code liegt offen unter derselben Lizenz wie Chat 2. EUPL-1.2 explicitly allows all of this with clear attribution. The code is open under the same license as Chat 2. Infi,
Infi, Anna oder sonst jemand dürfen reinschauen, Ideen mitnehmen, Fragen stellen oder den Fork einfach ignorieren. Alles Anna, or anyone else can look in, take ideas, ask questions, or simply ignore the fork. All three are fine with me.
drei ist für mich okay.
--- ---
## Wie ich so schnell release ## How I release this fast
Wer auf den Repo schaut, sieht in kurzer Zeit viele Releases und sehr viele Commits. Beides wird von außen gerne als Anyone looking at the repo sees a lot of releases and a high commit count in a short time. Both tend to read as red
Red-Flag gelesen: KI-Slop, Salami-Taktik, Code-Spam. Bei Hellion Chat ist beides eine bewusste Entscheidung, und ich flags from the outside: AI slop, salami tactics, code spam. In Hellion Chat both are deliberate decisions, and I would
erkläre lieber einmal warum, als mich später dafür zu rechtfertigen. rather explain them once than justify them later.
### Vorarbeit, lange bevor der Fork existierte ### Groundwork, long before the fork existed
Bevor ich die erste Zeile in `HellionChat/` getippt habe, war ich wochenlang nur Leser. Chat 2 ingame nutzen und damit Before I typed the first line into `HellionChat/`, I spent weeks as a reader. Using Chat 2 in-game and playing around
rumspielen. Issues im Upstream-Tracker durchgehen, vor allem die geschlossenen, weil dort steht, wie Infi und Anna Bugs with it. Going through issues in the upstream tracker, especially the closed ones, because that is where you see how
einkreisen. Commits lesen, gerne auch ältere, um zu verstehen, warum eine Architektur-Entscheidung getroffen wurde, Infi and Anna narrow down bugs. Reading commits, including older ones, to understand _why_ an architecture decision was
nicht nur, dass sie getroffen wurde. Wenn ich heute weiß, wo im Code was liegt, dann nicht, weil ich besonders schnell made, not just _that_ it was made. If I know today where things live in the codebase, it is not because I navigate
durch eine Codebase navigiere, sondern weil ich den Code vorher gelesen habe. codebases particularly fast but because I read the code beforehand.
Klingt nach Selbstverständlichkeit, ist es aber nicht. Die übliche Reihenfolge bei Solo-Forks heißt erst forken, dann That sounds obvious. It is not. The usual order for solo forks is fork first, understand later. I did it the other way
verstehen. Ich habe es andersrum gemacht. around.
### Die Codebase von Infi und Anna One thing I noticed reading the codebase closely: some patterns felt familiar in ways I had not expected, structural
choices and comment styles that show up across a lot of modern plugin and tooling code regardless of how it was written.
Nothing worth reading into. Coding workflows have changed a lot in the last few years across the board, and the traces
of that show up everywhere. It did make me less self-conscious about my own workflow.
Hellion Chat baut auf einem Boden auf, der schon flach ist. Chat 2 ist sauber strukturiert, die Naming-Konventionen sind ### Infi and Anna's codebase
konsistent, die Trennung zwischen Layern (Storage, UI, Game-Hooks, IPC) ist klar gezogen. Das ist in
Open-Source-Plugin-Welten nicht selbstverständlich, und es ist der Hauptgrund, warum sich Hellion-spezifische Features
oft "fast nativ" einbauen lassen. Ich muss nicht erst Spaghetti entwirren bevor ich was Eigenes danebenstellen kann.
Side-Fact: Selbst beim ersten Codebase-Walkthrough mit Claude kam mehrfach der Hinweis, dass die Architektur Hellion Chat builds on a foundation that is already flat. Chat 2 is cleanly structured, naming conventions are
ungewöhnlich gut aufgeräumt ist und mehrere Erweiterungspunkte vorbereitet. Das hat Gewicht, weil es von außen kommt, consistent, and the separation between layers (storage, UI, game hooks, IPC) is clearly drawn. That is not a given in
aber den eigentlichen Kredit kriegen Infi und Anna, nicht Claude. open-source plugin land, and it is the main reason Hellion-specific features often slot in "almost natively". I do not
have to untangle spaghetti before I can put something of my own next to it.
### Atomar arbeiten, kleine Commits Side note: even during the first codebase walkthrough with Claude, the comment came up several times that the
architecture is unusually tidy and has several extension points prepared. That carries weight because it comes from
outside, but the actual credit goes to Infi and Anna, not Claude.
Ein Commit, eine logische Änderung. Wenn ich einen Bug fixe, parallel eine Variable umbenenne und nebenbei einen ### Atomic work, small commits
Kommentar einbaue, sind das drei Commits, nicht einer. Klingt nach Mikro-Management, ist es aber nicht. Wenn in sechs
Monaten ein Bug auftaucht und ich `git bisect` brauche, finde ich die kaputte Änderung in zwei Minuten statt in zwei
Stunden. Bei einem 4000-Zeilen-Mega-Commit darf ich raten, welche der hundert Änderungen die kaputte ist.
Den Stil habe ich bewusst auch deshalb beibehalten, weil Infi im Upstream häufig genauso arbeitet. Manchmal ein One commit, one logical change. If I fix a bug, rename a variable and add a comment at the same time, that is three
Sechs-Zeilen-Commit, manchmal nur ein Typo-Fix. Das ist keine Schwäche, das ist eine Entscheidung für lesbare commits, not one. Sounds like micro-management, it is not. If a bug surfaces in six months and I need `git bisect`, I
Git-History. Den Stil im Fork beizubehalten ist ein Respekt-Move: Wer die beiden Repos vergleicht, soll den gleichen find the broken change in two minutes instead of two hours. With a 4000-line mega-commit I get to guess which of the
Lese-Rhythmus haben. hundred changes is the broken one.
Bonus für mich persönlich: Kleine Commits zwingen mich, jeden Schritt einzeln zu durchdenken und zu benennen. Wenn ich I kept this style deliberately also because Infi works the same way upstream. Sometimes a six-line commit, sometimes
nicht in zwei Sätzen erklären kann, was ein Commit macht, ist die Änderung wahrscheinlich noch nicht klar genug. Auf just a typo fix. That is not a weakness, it is a decision for readable Git history. Keeping the style in the fork is a
Beginner-Niveau ist das ein eingebauter Sanity-Check, den ich bei einem Big-Bang-Commit nicht hätte. respect move: anyone comparing both repos should have the same reading rhythm.
### AI als Beschleuniger, ehrlich Personal bonus: small commits force me to think through and name each step individually. If I cannot explain what a
commit does in two sentences, the change is probably not clear enough yet. At beginner level that is a built-in sanity
check I would not have with a big-bang commit.
Ja, AI hilft beim Tempo, und nicht zu knapp. Ohne CodeRabbit hätte ich Critical-Bugs der Klasse ### AI as an accelerator, honestly
`Equals/GetHashCode`-Anti-Pattern, Hook-Subscription-Leaks und TOCTOU-Races nicht gefunden. Ich bin schlicht zu
unerfahren für diese Klasse von Findings, das schreibe ich genau so hin.
Was ich aber nicht mache: blind Code übernehmen, weil ein Tool ihn als Fix markiert hat. Bei mehreren Yes, AI helps with velocity, and not a little. Without CodeRabbit I would not have found critical bugs like
CodeRabbit-Findings stand in den Original-Commits von Infi oder Anna sogar ein Stackoverflow-Link mit Begründung dabei, `Equals/GetHashCode` anti-patterns, hook subscription leaks and TOCTOU races. I am simply too inexperienced for that
warum eine bestimmte Stelle so aussieht wie sie aussieht. Die habe ich gelesen, bevor ich was geändert habe. Erst class of findings, and I write that exactly as it is.
verstehen, dann anfassen, dann committen. Das ist der Unterschied zwischen "AI gibt mir Code, ich pushe" und "AI zeigt
mir wo's klemmt, ich entscheide".
Klassifikation und konkrete Beispiele zur AI-Nutzung stehen in [`AI_DISCLOSURE.md`](AI_DISCLOSURE.md). Hier in dieser What I do not do: blindly take code because a tool marked it as a fix. On several CodeRabbit findings, the original
Sektion ging es nur um den Tempo-Aspekt: Recherche plus saubere Codebase plus atomare Commits plus AI-gestütztes commits from Infi or Anna even included a Stack Overflow link explaining why a particular spot looks the way it does. I
Review-Sparring sind die vier Faktoren zusammen. Kein einzelner davon erklärt das Tempo allein. read those before touching anything. Understand first, then change, then commit. That is the difference between "AI
gives me code, I push" and "AI shows me where it breaks, I decide".
Classification and concrete examples of AI usage are in [`AI_DISCLOSURE.md`](AI_DISCLOSURE.md). This section was only
about the velocity aspect: research plus a clean codebase plus atomic commits plus AI-assisted review sparring are the
four factors together. No single one explains the pace on its own.
--- ---
## Vom Web-Stack zu C# / Dalamud ## From the web stack to C# / Dalamud
### Type-System? Weniger Schock als erwartet ### Type system? Less of a shock than expected
C# nach TypeScript war angenehmer als gedacht. Properties statt getter/setter sind sauber, nullable reference types C# after TypeScript was more comfortable than expected. Properties instead of getters/setters are clean, nullable
fühlen sich an wie `strict: true` in TypeScript. Ungewohnt war Wert-Typen vs. Referenz-Typen explizit denken zu müssen reference types feel like `strict: true` in TypeScript. What was unfamiliar was having to think explicitly about value
(`struct` vs. `class` mit echten Verhaltens-Konsequenzen), und Generics mit Constraints sind syntaktisch anders genug, types versus reference types (`struct` vs. `class` with real behavioural consequences), and generics with constraints
dass ich beim Lesen kurz stocke. `async`/`await` ist semantisch ähnlich, aber Threading-Modelle sind in C# expliziter: are syntactically different enough that I stumble on them while reading. `async`/`await` is semantically similar, but
`Task.Run`, `ConfigureAwait`, Synchronization-Contexts. Das hat mich mehrere Bugs gekostet, bevor ich verstanden hatte, threading models are more explicit in C#: `Task.Run`, `ConfigureAwait`, synchronization contexts. That cost me several
wann der Main-Thread (in Plugin-Welt: der Framework-Tick) wirklich kritisch ist. bugs before I understood when the main thread (in plugin land: the framework tick) is actually critical.
### Build-Toolchain: ähnlich, aber anders ### Build toolchain: similar, but different
`dotnet` CLI, csproj-XML, NuGet sind funktional nicht weit weg von npm und tsconfig. Aber das XML-Format der csproj ist `dotnet` CLI, csproj XML, NuGet are functionally not far from npm and tsconfig. But the XML format of csproj is a
eine andere Sprache als JSON-Configs. Die Lock-Datei (`packages.lock.json`) musste ich erst aktiv aktivieren different language than JSON configs. The lock file (`packages.lock.json`) had to be actively enabled
(`RestorePackagesWithLockFile=true`); das ist nicht Default. Im Web-Stack ist Lock-File-First Standard, im .NET-Stack (`RestorePackagesWithLockFile=true`); that is not the default. In the web stack, lock-file-first is standard, in the
offenbar nicht. Das war eine echte Überraschung. .NET stack apparently not. That was a real surprise.
### ImGui ist eine andere Welt ### ImGui is a different world
Immediate-Mode-Rendering hat mit React-Component-Trees nichts gemein. Es gibt keine virtuelle DOM, keine Reconciliation, Immediate-mode rendering has nothing in common with React component trees. There is no virtual DOM, no reconciliation,
keinen "State der Komponente". Pro Frame zeichnet der Code die UI komplett neu, und der State lebt entweder in lokalen no "component state". Every frame the code redraws the UI from scratch, and state lives either in local variables I
Variablen, die ich selbst verwalten muss, oder in der ImGui-eigenen ID-Stack-Logik. manage myself or in ImGui's own ID stack logic.
Was in React zwei Zeilen `useState` sind, ist in ImGui ein Member-Field plus manuelle ID-Stempel auf den Widgets, sonst What is two lines of `useState` in React is a member field plus manual ID stamps on widgets in ImGui, otherwise two
kollidieren zwei Selectables in derselben Loop, weil sie auf die gleiche ID zurückfallen. Die ID-Stack-Kollision in selectables in the same loop collide because they fall back to the same ID. The ID stack collision in `SearchSelector`
`SearchSelector` (gefixt in v1.0.0) war genau dieses Symptom: Alle Selectables fielen auf dieselbe ambiguous ID zurück, (fixed in v1.0.0) was exactly that symptom: all selectables fell back to the same ambiguous ID until I mixed the row
bis ich den Row-Index in den Push-ID gemixt habe. Klassischer "warum klickt der falsche Eintrag"-Bug, den man nur index into the PushID. Classic "why is the wrong entry getting clicked" bug that you only find once you understand how
findet, wenn man verstanden hat, wie ImGui IDs intern handhabt. ImGui handles IDs internally.
### Dalamud-Spezifika ### Dalamud specifics
Plugin-Lifecycle, IPC-Subscriber-Pattern, Hook-System für Game-Functions, Game-Object-Threading. Viel davon war nur Plugin lifecycle, IPC subscriber pattern, hook system for game functions, game object threading. Much of that was only
durch Lesen der Upstream-Codebase und durch [dalamud.dev](https://dalamud.dev) zu verstehen. Meine Trainings- und understandable through reading the upstream codebase and through [dalamud.dev](https://dalamud.dev). Search results for
Such-Ergebnisse für "Dalamud" liefern oft veraltete API-Beispiele aus alten Versionen. dalamud.dev ist die zuverlässige "Dalamud" often turn up outdated API examples from old versions. dalamud.dev is the reliable source. If someone is just
Quelle. Wenn jemand neu anfängt: dort hin, nicht zu Stack Overflow. starting out: go there, not to Stack Overflow.
### Der Tag, an dem mich der DalamudPackager einen Tag gekostet hat ### The day DalamudPackager cost me a day
Dalamud SDK 15 liefert seinen eigenen Default-Packager mit, der Icons und Image-URLs ins Manifest einträgt. Ich hatte Dalamud SDK 15 ships its own default packager that writes icons and image URLs into the manifest. I had carried over a
aus dem Upstream-Repo eine eigene `DalamudPackager.targets`-Datei mit `HandleImages`-Override übernommen, und die hat `DalamudPackager.targets` file from the upstream repo with a `HandleImages` override, and it was overriding the SDK
den SDK-Default überschrieben. Resultat: Das Manifest hatte keinen `IconUrl` mehr, und das Plugin tauchte in der default. Result: the manifest had no `IconUrl` anymore, and the plugin appeared in the plugin list without an icon.
Plugin-Liste ohne Icon auf.
Symptom war einfach zu sehen, Ursache hat einen Tag gekostet. Ich hatte die Override-Datei für eine Pflicht-Datei The symptom was easy to spot, the cause cost a day. I had treated the override file as mandatory when it was not.
gehalten, war sie aber nicht. Removal in v0.5.2, seitdem läuft der SDK-Default. Lektion: Erstmal mit Defaults arbeiten, Removed in v0.5.2, SDK default running since then. Lesson: start with defaults, add overrides only when the default
Overrides erst wenn der Default nachweislich nicht passt. demonstrably does not fit.
--- ---
## Was ich aus dem Fork gelernt habe ## What I learned from the fork
### Refactor in einer fremden Codebase ### Refactoring in an unfamiliar codebase
Der Standalone-Cut in v1.0.0 hat die `ChatTwo.*`-Identität komplett auf `HellionChat.*` migriert. Klingt nach The standalone cut in v1.0.0 migrated the entire `ChatTwo.*` identity to `HellionChat.*`. That sounds like find and
Find-and-Replace. War es nicht. replace. It was not.
Konkret bedeutete das: Code-Namespace über alle 80 Source-Files plus 100 using-Direktiven plus zwei FQN-Aliases plus die In concrete terms: code namespace across all 80 source files plus 100 using directives plus two FQN aliases plus the
Resource-Designer-Strings. Sechs IPC-Channels umbenannt (Breaking Change für Drittplugins, keine bekannten Anbindungen). resource designer strings. Six IPC channels renamed (breaking change for third-party plugins, no known integrations).
Repo-Ordner-Struktur (`ChatTwo/` `HellionChat/`) inklusive csproj, sln, allen GitHub-Workflows und der dependabot.yml. Repo folder structure (`ChatTwo/` -> `HellionChat/`) including csproj, sln, all GitHub workflows and dependabot.yml.
Public-Facing-Branding in README, repo.json, yaml auf Standalone-Framing umformuliert. Public-facing branding in README, repo.json and yaml reformulated to standalone framing.
Das war kein Solo-Find-and-Replace, weil Unicode-String-Pfade in Workflow-YAMLs anders quotiert werden müssen als It was not a solo find-and-replace because Unicode string paths in workflow YAMLs need different quoting than C#
C#-Strings. Weil Resource-Designer-Files generierte Inhalte haben, die nicht jede Toolchain im Blick hat. Und weil die strings. Because resource designer files have generated content that not every toolchain tracks. And because the
`ChatTwo.*`-IPC-Channel-Namen Strings in `GetIpcSubscriber`-Calls sind: kein Symbol, kein Compile-Error, wenn man einen `ChatTwo.*` IPC channel names are strings in `GetIpcSubscriber` calls: no symbol, no compile error if you miss one. That
vergisst. Da merkst du, was alles still bleibt. is when you find out what stays quiet.
### Sicherheit ist kein abstraktes Thema mehr ### Security is no longer abstract
Vor diesem Projekt war Supply-Chain-Sicherheit für mich akademisch. Drei konkrete Lektionen haben das geändert. Before this project, supply chain security was academic for me. Three concrete lessons changed that.
**SQLite-Native-Binary.** Ich musste auf 3.50.3 pinnen (`SQLitePCLRaw.lib.e_sqlite3` Override), weil **SQLite native binary.** I had to pin to 3.50.3 (`SQLitePCLRaw.lib.e_sqlite3` override) because `Microsoft.Data.Sqlite`
`Microsoft.Data.Sqlite` die transitiv nachgezogene Lib in einer Version mitschleppte, die CVE-2025-6965 was pulling in a transitively referenced library at a version containing CVE-2025-6965 (memory corruption via aggregate
(Memory-Corruption durch Aggregate-Term-Overflow) und CVE-2025-7709 enthielt. Der Managed-Wrapper war neu, die term overflow) and CVE-2025-7709. The managed wrapper was new; the native library was not. Lesson: transitive
Native-Lib war es nicht. Lektion: Transitive Dependencies prüfen sich nicht von selbst, du musst hinschauen. dependencies do not audit themselves, you have to look.
**Lock-File-Drift.** `packages.lock.json` honored bei `dotnet restore` (per `RestorePackagesWithLockFile=true` in der **Lock file drift.** `packages.lock.json` honoured via `RestorePackagesWithLockFile=true` in the csproj prevents
csproj) verhindert, dass transitive Versionen zwischen meiner Maschine und CI silent driften. Erst nach einem transitive versions from silently drifting between my machine and CI. I only understood why this is not the default
Build-Output-Mismatch zwischen lokal und GitHub-Actions hatte ich überhaupt verstanden, warum das nicht der Default ist. after a build output mismatch between local and GitHub Actions.
**WrapText und der CodeQL-Alarm der drei Releases gekostet hat.** CodeQL hat in `ImGuiUtil.WrapText` einen **WrapText and the CodeQL alert that cost three releases.** CodeQL flagged a critical alert in `ImGuiUtil.WrapText` for
Critical-Alert wegen "unvalidated local pointer arithmetic" geworfen. v0.5.2 hat einen Edge-Case validiert. Alert kam unvalidated local pointer arithmetic. v0.5.2 validated an edge case. Alert came back. v0.5.3 checked buffer length via
wieder. v0.5.3 hat den Buffer-Length via `GetByteCount` vor der Pointer-Math gecheckt. Alert kam wieder. v0.5.4 hat den `GetByteCount` before the pointer math. Alert came back. v0.5.4 rebuilt the whole algorithm on `Span` and int offsets
ganzen Algorithmus auf `Span` und int-Offsets umgebaut, mit einem 16-KiB-Cap auf den ArrayPool-Rent. Erst da war Ruhe. with a 16 KiB cap on the ArrayPool rent. Only then did it go quiet.
Lektion: Wenn ein statischer Analyzer drei Mal hintereinander meckert, ist nicht der Analyzer überempfindlich. Die Lesson: when a static analyser complains three times in a row, the analyser is not oversensitive. The data flow logic
Datenflusslogik ist es. is.
### CodeRabbit als externer Code-Reviewer ### CodeRabbit as an external code reviewer
Der v1.0.0-Sweep hat 3 Critical und 21 Major Findings hochgespült. Drei Klassen davon waren besonders lehrreich: The v1.0.0 sweep surfaced 3 critical and 21 major findings. Three classes were particularly instructive:
- **`Equals`-Methoden die `GetHashCode()` vergleichen.** Klassisches Hash-Kollisions-Anti-Pattern. Klingt nach "ist doch - **`Equals` methods comparing `GetHashCode()`.** Classic hash collision anti-pattern. Sounds like "if hashes are equal
egal, wenn Hashes gleich sind, sind die Objekte auch gleich", ist aber genau falsch. Hashes können kollidieren, the objects are equal", which is exactly backwards. Hashes can collide; the objects are not equal.
Objekte sind dann nicht gleich. - **`Dispose` methods that only unsubscribe part of their subscriptions.** Leak on every plugin reload. In normal use
- **`Dispose`-Methoden die nur einen Teil der Subscriptions wieder abmelden.** Leak bei jedem Plugin-Reload. Im you do not notice it immediately; in a long-running test you do.
Nutzer-Alltag merkst du das nicht sofort, im Long-Running-Test schon. - **TOCTOU races.** Between a bounds check and a read another thread can swap out the array underneath you
- **TOCTOU-Races.** Zwischen Bounds-Check und Read kann ein anderer Thread das Array unter dir austauschen
(`GlobalParametersCache`, `AutoTranslate`). (`GlobalParametersCache`, `AutoTranslate`).
Davon hatte ich vorher bestenfalls die Theorie gelesen, nicht selbst diagnostiziert. CodeRabbit war für mich der Moment, I had at best read the theory on all of these before, never diagnosed them in my own code. CodeRabbit was the moment
wo "akademisches Wissen" zu "okay, das ist mein Code, das ist mein Bug" wurde. where "academic knowledge" became "okay, that is my code, that is my bug".
### Externe Tester sind ihr Gewicht in Gold wert ### External testers are worth their weight
Carlas Feedback zur Pop-Out-Discoverability hat den Header-Button in v0.6.1 ausgelöst. Dass Pop-Outs nur per Rechtsklick Carla's feedback on pop-out discoverability triggered the header button in v0.6.1. That pop-outs were only reachable via
erreichbar waren, hatte ich als Maintainer nicht mehr gesehen, ich kannte den Pfad blind. Carls Wunsch nach right-click was something I as maintainer had stopped seeing; I knew the path by heart. Carl's request for theme
Theme-Varianten mit Helligkeits-Abstufungen hat mein Verständnis von "ein Theme = eine Farbe" auf "Theme-Familien mit variants with brightness gradations shifted my thinking from "one theme = one colour" to "theme families with mood
Stimmungs-Varianten" verschoben. Jingliu hat TempTell-Persistence gefordert, was das Tab-System architektonisch in Frage variants". Jingliu asked for TempTell persistence, which puts the tab system architecturally into question.
stellt.
Solo hätte ich diese drei Dinge nicht erkannt. Punkt. Solo I would not have seen any of those three things. Full stop.
### release.yml und die Markdown-Hölle ### release.yml and the YAML rabbit hole
Der `release.yml`-Workflow ist beim ersten v0.6.0-Tag-Push einfach nicht losgegangen. Ich habe Stunden in Permissions, The `release.yml` workflow simply did not fire on the first v0.6.0 tag push. I dug through permissions, secret scopes
Secret-Scopes und Tag-Trigger-Konfiguration gegraben, bevor ich verstand, was eigentlich los war: Der and tag trigger configuration for hours before I understood what was actually happening: the PowerShell heredoc footer
PowerShell-Heredoc-Footer im "Generate release body"-Step enthielt eine `---`-Markdown-Horizontal-Rule an Spalte 1, und in the "Generate release body" step contained a `---` Markdown horizontal rule at column 1, and that terminated the YAML
genau das hat das YAML-Block-Scalar von `run: |` beendet. GitHub konnte die Workflow-Datei nicht parsen, also hat der block scalar of `run: |`. GitHub could not parse the workflow file, so the push-tag trigger never registered.
Push-Tag-Trigger nie registriert.
Fix: Footer in eine externe `.github/release-footer.md` extrahiert, Workflow liest sie via `Get-Content` ein. Lektion: Fix: extracted the footer into an external `.github/release-footer.md`, workflow reads it via `Get-Content`. Lesson: if
Wenn ein Workflow nicht triggert, verifiziere als Erstes, dass GitHub die Datei überhaupt parsen kann. Das war einer der a workflow does not trigger, verify first that GitHub can even parse the file. That was one of the bugs where I laughed
Bugs, bei denen ich nach dem Fix kurz gelacht habe und mich dann gefragt, wie viele andere YAML-Dateien ich noch habe, briefly after the fix and then asked myself how many other YAML files I had that might have the same trap in them.
die so eine Falle drin haben könnten.
--- ---
## Was ich noch lerne ## What I am still learning
### Performance-Profiling im Game-Context ### Performance profiling in a game context
Der FPS-Drop-Bug aus Upstream Chat 2 ([#145](https://github.com/Infiziert90/ChatTwo/issues/145)) ist auch in Hellion The FPS drop bug from upstream Chat 2 ([#145](https://github.com/Infiziert90/ChatTwo/issues/145)) has not been
Chat noch nicht reproduziert oder verifiziert. v1.0.0 hat mehrere Fixes auf den verdächtigen Pfaden (DbViewer O(N²) reproduced or verified in Hellion Chat. v1.0.0 applied several fixes on the suspected paths (DbViewer O(N²) -> O(N),
O(N), AutoTranslate Lock-Serialisierung, EmoteCache HttpClient-Reuse), aber das systematische Vermessen unter Last fehlt AutoTranslate lock serialisation, EmoteCache HttpClient reuse), but systematic measurement under load is missing. I
mir. Ich muss noch lernen, wie man im Plugin-Kontext sauber misst, was wirklich das Frame-Budget frisst. still need to learn how to properly measure what is actually consuming the frame budget in a plugin context.
### Native-Interop und Pointer-Math ### Native interop and pointer math
Auch nach dem WrapText-Span-Refactor in v0.5.4 ist mir Pointer-Math unsicher. ImGui zwingt einen an mehreren Stellen in Even after the WrapText Span refactor in v0.5.4, pointer math makes me uneasy. ImGui forces you into `unsafe` code in
`unsafe`-Code, und der Sicherheitsabstand zur "unbounded ArrayPool allocation"-Klasse von Bugs ist schmaler als mir lieb several places, and the safety margin from the "unbounded ArrayPool allocation" class of bugs is narrower than I would
ist. Da will ich besser werden, bevor ich tieferes ImGui-Custom-Drawing anfasse. like. I want to get better at that before touching deeper ImGui custom drawing.
### Test-Disziplin für Plugin-Code ### Test discipline for plugin code
Aktuell hat das Repo kein Test-Projekt. Das ist eine bewusste Entscheidung, keine vergessene. Plugin-Code mit The repo currently has no test project. That is a deliberate decision, not a forgotten one. Testing plugin code with
FFXIV-Hooks und Dalamud-Lifecycle sauber zu testen ist nicht trivial, und ich hatte keinen Ansatz gefunden, der ohne FFXIV hooks and Dalamud lifecycle cleanly is non-trivial, and I had not found an approach that made sense without a
riesiges Mocking-Gerüst sinnvoll wirkte. Privacy-Filter und Configuration-Migration wären gute Testkandidaten, weil sie large mocking scaffold. Privacy filter and configuration migration would be good test candidates because they are
isoliert sind. Steht auf der Liste, ist aber kein Quick-Win. isolated. On the list, but not a quick win.
### Linux-Eigenheiten unter Wine ### Linux quirks under Wine
XDG-Compliance, libnotify-Integration, WireGuard-Network-Detection, alles in der [Roadmap](ROADMAP.md), und alles XDG compliance, libnotify integration, WireGuard network detection, all on the [roadmap](ROADMAP.md), and all
technisch noch nicht ganz klar. Wine und sandboxed Plugin-Code teilen nicht alle System-APIs, und ich weiß nicht, wo die technically still unclear. Wine and sandboxed plugin code do not share all system APIs, and I do not know where the
Stolperfallen liegen, bevor ich sie gefunden habe. pitfalls are until I have found them.
--- ---
## Einsatz von AI-Tools ## Use of AI tools
Ich verwende Claude Code als Hilfsmittel, nicht als Ersatz für eigene Arbeit. I use Claude Code as an assistant, not as a replacement for my own work.
**Wofür ich AI einsetze:** **What I use AI for:**
- Debugging von Problemen, bei denen ich nach längerer Eigenrecherche nicht weiterkomme - Debugging problems where I am stuck after extended research of my own
- Mustererkennen über große Codebasen hinweg (z. B. der ChatTwoHellionChat-Sweep über 80 Dateien) - Pattern recognition across large codebases (e.g. the ChatTwo -> HellionChat sweep across 80 files)
- Verständnisfragen zu C#- und Dalamud-Konzepten, die mir noch nicht geläufig sind - Understanding questions on C# and Dalamud concepts I am not yet familiar with
- Code-Review-Sparring, bevor ich CodeRabbit drauflasse - Code review sparring before I run CodeRabbit on something
**Was ich selbst mache:** **What I do myself:**
- Architektur und Designentscheidungen - Architecture and design decisions
- Privacy-First-Defaults und das Threat-Model dahinter - Privacy-first defaults and the threat model behind them
- Tester-Kommunikation und Roadmap-Priorisierung - Tester communication and roadmap prioritisation
- Reviewen, Verifizieren, Pushen - Reviewing, verifying, pushing
Die Klassifikation und konkrete Beispiele stehen in [`AI_DISCLOSURE.md`](AI_DISCLOSURE.md). Mir ist wichtig, dass Nutzer Classification and concrete examples are in [`AI_DISCLOSURE.md`](AI_DISCLOSURE.md). It matters to me that users and
und potenzielle Beiträger verstehen, wie der Code zustande gekommen ist, gerade bei einem Plugin, das mit Nutzerdaten potential contributors understand how the code came together, especially for a plugin that handles user data.
arbeitet.
Ja, AI. Ja, alleine. Beides öfter erwähnt als nötig. Willkommen im Open-Source-Plugin-Klima. Yes, AI. Yes, alone. Both mentioned more than strictly necessary. Welcome to the open-source plugin climate.
--- ---
## Warum diese Transparenz ## Why this transparency
Wer sich den Quellcode ansieht, soll wissen: Anyone reading the source code should know:
- Ich bin kein professioneller C#- oder Plugin-Entwickler und lerne weiterhin dazu - I am not a professional C# or plugin developer and am still learning
- AI-Unterstützung ist ein Werkzeug, kein Ghostwriter - AI assistance is a tool, not a ghostwriter
- Die Privacy-Position, die Designentscheidungen und die Roadmap sind meine - The privacy position, the design decisions and the roadmap are mine
- Ich versuche, meinen Code so sauber und sicher zu halten, wie meine aktuellen Fähigkeiten es zulassen - I try to keep my code as clean and secure as my current skills allow
Hellion Chat ist auch ein Lernprojekt, und das soll man dem Repository ansehen dürfen. Hellion Chat is also a learning project, and that should be visible in the repository.
--- ---
## Verlinkungen ## Links
- [`AI_DISCLOSURE.md`](AI_DISCLOSURE.md) — KI-Pair-Disclosure mit Klassifikations-Schema - [`AI_DISCLOSURE.md`](AI_DISCLOSURE.md) -- AI pair disclosure with classification schema
- [`CONTRIBUTORS.md`](CONTRIBUTORS.md) — wer hat dieses Plugin neben mir besser gemacht - [`CONTRIBUTORS.md`](CONTRIBUTORS.md) -- who has made this plugin better alongside me
- [`../NOTICE.md`](../NOTICE.md) — Anerkennung an Infi und Anna für das Chat-2-Fundament - [`../NOTICE.md`](../NOTICE.md) -- attribution to Infi and Anna for the Chat 2 foundation
- [`ROADMAP.md`](ROADMAP.md) — geplante Cycles und Themen - [`ROADMAP.md`](ROADMAP.md) -- planned cycles and topics
+3 -3
View File
File diff suppressed because one or more lines are too long