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