From a9f42e32c503521c5cd87670e877d784ea74a4a7 Mon Sep 17 00:00:00 2001 From: Forge Bot Date: Mon, 1 Jun 2026 00:32:04 +0000 Subject: [PATCH 001/129] chore(deps): update actions/setup-dotnet digest to 9a946fd --- .gitea/workflows/build.yml | 2 +- .gitea/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index acedfca..91e6f21 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -35,7 +35,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Setup .NET 10 - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5 with: dotnet-version: 10.0.x diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 7920761..cb62ee5 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -54,7 +54,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Setup .NET 10 - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5 with: dotnet-version: 10.0.x From 7ef1337ea0285a1741b25a2f98e23175cd396906 Mon Sep 17 00:00:00 2001 From: Forge Bot Date: Sat, 23 May 2026 12:33:12 +0000 Subject: [PATCH 002/129] chore(deps): update minor and patch updates (nuget) --- HellionChat/HellionChat.csproj | 4 ++-- HellionChat/packages.lock.json | 24 ++++++++++++------------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj index 208b462..036ed4f 100644 --- a/HellionChat/HellionChat.csproj +++ b/HellionChat/HellionChat.csproj @@ -14,7 +14,7 @@ - + - + diff --git a/HellionChat/packages.lock.json b/HellionChat/packages.lock.json index acf75ef..3240abf 100644 --- a/HellionChat/packages.lock.json +++ b/HellionChat/packages.lock.json @@ -27,11 +27,11 @@ }, "Microsoft.Data.Sqlite": { "type": "Direct", - "requested": "[10.0.7, )", - "resolved": "10.0.7", - "contentHash": "DZ6G2QuyPrsh5VS+wfiZbNBtYT6p+CkxXjD0aZHF04xso7QsG/uk0JpG30hzYlK6u/wtTzta1Dqfgbc/Sl2sDA==", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "iyDWyD6r/SnqgoYYQIlLhxL1ZIGZr+SByMXrJKSA1w7sOt7bPMJmN3h2laqwKqyQkjh/lUPJ7LTXwpvqzhggOQ==", "dependencies": { - "Microsoft.Data.Sqlite.Core": "10.0.7", + "Microsoft.Data.Sqlite.Core": "10.0.8", "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", "SQLitePCLRaw.core": "2.1.11" } @@ -104,11 +104,11 @@ }, "NAudio.WinMM": { "type": "Direct", - "requested": "[2.2.1, )", - "resolved": "2.2.1", - "contentHash": "xFHRFwH4x6aq3IxRbewvO33ugJRvZFEOfO62i7uQJRUNW2cnu6BeBTHUS0JD5KBucZbHZaYqxQG8dwZ47ezQuQ==", + "requested": "[2.3.0, )", + "resolved": "2.3.0", + "contentHash": "5G1dRjsZm50T3luyuqcmI2BSvj3K4ZJaD/x776/0Epj88qOsOryDZG40+MufwIk1UFJSFWhRobBqtJYFc8Ss4g==", "dependencies": { - "NAudio.Core": "2.2.1" + "NAudio.Core": "2.3.0" } }, "Pidgin": { @@ -141,8 +141,8 @@ }, "Microsoft.Data.Sqlite.Core": { "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "xVrtBg3M1wJlBDkoT0dXEYB/wSc8bIHJPYtw/bu1AqpWgF79uPSs87DAhERR/Ilumre6TKZa1cjMg3VUUObVLA==", + "resolved": "10.0.8", + "contentHash": "26t7WDiEjjAls/sFpWvVEFDxt+7Q5VPt6+blU2Lafuj9L8PzAv/GtGV4cqVPtrhWbfD2BX/z2v8hD1qXYtK6Aw==", "dependencies": { "SQLitePCLRaw.core": "2.1.11" } @@ -377,8 +377,8 @@ }, "NAudio.Core": { "type": "Transitive", - "resolved": "2.2.1", - "contentHash": "GgkdP6K/7FqXFo7uHvoqGZTJvW4z8g2IffhOO4JHaLzKCdDOUEzVKtveoZkCuUX8eV2HAINqi7VFqlFndrnz/g==" + "resolved": "2.3.0", + "contentHash": "jMd7r6dB6tAtXhOYL58ntPqwERNm1/Rhw5MKOIYvsnXzuX+PTGsa2VMam6n0npZYSwlSidKa4GAm4bFcXFUlcg==" }, "SQLitePCLRaw.bundle_e_sqlite3": { "type": "Transitive", From f5ba1c02467cfd53cfcf3b53a2ae01fb89fc85b5 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 12:02:53 +0200 Subject: [PATCH 003/129] A1: add ColourUtil.LerpTowardWhite accent-tint helper --- HellionChat/Util/ColourUtil.cs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/HellionChat/Util/ColourUtil.cs b/HellionChat/Util/ColourUtil.cs index 5ece96b..66c0c61 100755 --- a/HellionChat/Util/ColourUtil.cs +++ b/HellionChat/Util/ColourUtil.cs @@ -102,6 +102,26 @@ internal static class ColourUtil return (abgr & 0x00FFFFFFu) | ((uint)newAlpha << 24); } + // Mixes an ABGR colour's RGB channels toward white (0xFF) by factor t in + // [0, 1]; the alpha byte is left untouched. A1 hover-sheen accent-tint: + // a low factor nudges the sweep toward the element's accent hue without + // going fully saturated (effect level stays "subtle"). RGB-only on + // purpose -- DrawHoverSheen owns the alpha falloff. + internal static uint LerpTowardWhite(uint abgr, float t) + { + t = Math.Clamp(t, 0f, 1f); + var a = (byte)((abgr >> 24) & 0xFFu); + var b = (byte)((abgr >> 16) & 0xFFu); + var g = (byte)((abgr >> 8) & 0xFFu); + var r = (byte)(abgr & 0xFFu); + + var nr = (byte)Math.Round(r + (0xFF - r) * t); + var ng = (byte)Math.Round(g + (0xFF - g) * t); + var nb = (byte)Math.Round(b + (0xFF - b) * t); + + return ((uint)a << 24) | ((uint)nb << 16) | ((uint)ng << 8) | nr; + } + public static uint HexToRgba(string hex) { ArgumentNullException.ThrowIfNull(hex); From 7ead1202137c108c8ca7be8edeb7d6a74b5405df Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 12:17:39 +0200 Subject: [PATCH 004/129] A1: scharfschalten DrawHoverSheen accent-tint (Variante A) --- .../Ui/StyleEngine/DrawListExtensions.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs index 1d0545f..1c0700c 100644 --- a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs +++ b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs @@ -12,9 +12,16 @@ namespace HellionChat.Ui.StyleEngine; // ABGR before delegating to ImDrawList. Hover-sheen state lives in a small // static dictionary keyed by constant strings — keep keys constant and // scope to static UI elements so the per-key footprint stays bounded. +// TEST-MIRROR: Util/ColourUtilTintTests.cs internal static class DrawListExtensions { private const float SheenDurationSeconds = 0.65f; + + // A1 accent-tint (Variante A): how far the white sweep is pulled toward + // the element's accent hue. Kept low so the sheen reads as a tinted + // highlight, not a saturated accent flash (effect level "subtle"). + private const float SheenTintStrength = 0.35f; + private static readonly Dictionary SheenStarts = new(); public static void DrawHoverSheen( @@ -46,7 +53,11 @@ internal static class DrawListExtensions var t = (float)(elapsed / SheenDurationSeconds); var alpha = (byte)Math.Round(0x40 * (1f - t)); - var sheenAbgr = ((uint)alpha << 24) | 0x00FFFFFFu; + // Tint the sweep toward the accent hue, then stamp the falloff alpha. + // accentRgba is RGBA; convert to ABGR FIRST or the draw-list swaps R/B. + var accentAbgr = ColourUtil.RgbaToAbgr(accentRgba); + var tintedRgb = ColourUtil.LerpTowardWhite(accentAbgr, SheenTintStrength) & 0x00FFFFFFu; + var sheenAbgr = ((uint)alpha << 24) | tintedRgb; var sweepX = min.X + (max.X - min.X) * t; dl.AddRectFilled( new Vector2(sweepX - 12f, min.Y), @@ -54,11 +65,6 @@ internal static class DrawListExtensions sheenAbgr, 2f ); - - // Accent currently unused — reserved for a tinted-sweep variant that - // tracks the element's accent hue. Keeping it in the signature so - // call-sites don't churn when the tinted path lands. - _ = accentRgba; } public static void DrawGlowBorder( From c1765ce8ca8c96ba95a9df25c851e7d411283536 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 12:22:29 +0200 Subject: [PATCH 005/129] A1: harden HoverSheenAllocStep with real dictionary-footprint assertion --- HellionChat/SelfTests/HoverSheenAllocStep.cs | 13 ++++++++++++- HellionChat/Ui/StyleEngine/DrawListExtensions.cs | 5 +++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/HellionChat/SelfTests/HoverSheenAllocStep.cs b/HellionChat/SelfTests/HoverSheenAllocStep.cs index 12edffb..b7a557e 100644 --- a/HellionChat/SelfTests/HoverSheenAllocStep.cs +++ b/HellionChat/SelfTests/HoverSheenAllocStep.cs @@ -39,10 +39,21 @@ internal sealed class HoverSheenAllocStep : ISelfTestStep foreach (var key in keys) dl.DrawHoverSheen(min, max, accent, key, hovered: true); - // Un-hover sweep to verify the cleanup path drops the entries. + // Hovered loop must have registered exactly the three constant keys + // (no per-frame growth/duplication) — assert via observable state, + // not an unconditional pass (K7 false-green fix). + foreach (var key in keys) + if (!DrawListExtensions.IsSheenTracked(key)) + return SelfTestStepResult.Fail; + + // Un-hover sweep must drop every entry through the cleanup branch. foreach (var key in keys) dl.DrawHoverSheen(min, max, accent, key, hovered: false); + foreach (var key in keys) + if (DrawListExtensions.IsSheenTracked(key)) + return SelfTestStepResult.Fail; + return SelfTestStepResult.Pass; } diff --git a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs index 1c0700c..7fd1590 100644 --- a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs +++ b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs @@ -24,6 +24,11 @@ internal static class DrawListExtensions private static readonly Dictionary SheenStarts = new(); + // Test-observability hook for HoverSheenAllocStep: lets the self-test + // assert the hover/un-hover dictionary contract via real state instead + // of an unconditional pass (K7 false-green fix). Not a runtime path. + internal static bool IsSheenTracked(string elementId) => SheenStarts.ContainsKey(elementId); + public static void DrawHoverSheen( this ImDrawListPtr dl, Vector2 min, From e98cefa0b2bcff0a5675a0a4804b8de8ff770146 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 12:29:57 +0200 Subject: [PATCH 006/129] A2: name Sheen tuning knobs + SmoothStep crossfade easing --- HellionChat/Themes/ThemeRegistry.cs | 9 +++++++-- HellionChat/Ui/StyleEngine/DrawListExtensions.cs | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/HellionChat/Themes/ThemeRegistry.cs b/HellionChat/Themes/ThemeRegistry.cs index f8cb3f2..8f063bd 100644 --- a/HellionChat/Themes/ThemeRegistry.cs +++ b/HellionChat/Themes/ThemeRegistry.cs @@ -522,7 +522,10 @@ public sealed class ThemeRegistry ) { var t = (float)(now - _crossfadeStartTickMs) / CrossfadeDurationMs; - snapshot = ThemeAbgrCacheLerp.Lerp(_previousAbgrSnapshot.Value, _active.AbgrCache, t); + // A2: SmoothStep easing so the fade eases in/out instead of a + // linear ramp. MUST stay in lockstep with TryGetActiveCrossfade (K8). + var te = t * t * (3f - 2f * t); + snapshot = ThemeAbgrCacheLerp.Lerp(_previousAbgrSnapshot.Value, _active.AbgrCache, te); } else { @@ -548,7 +551,9 @@ public sealed class ThemeRegistry return false; var t = (float)elapsed / CrossfadeDurationMs; - lerped = ThemeAbgrCacheLerp.Lerp(_previousAbgrSnapshot.Value, _active.AbgrCache, t); + // A2: SmoothStep easing -- keep identical to ArmCrossfade (K8). + var te = t * t * (3f - 2f * t); + lerped = ThemeAbgrCacheLerp.Lerp(_previousAbgrSnapshot.Value, _active.AbgrCache, te); return true; } diff --git a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs index 7fd1590..b19a7bb 100644 --- a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs +++ b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs @@ -22,6 +22,9 @@ internal static class DrawListExtensions // highlight, not a saturated accent flash (effect level "subtle"). private const float SheenTintStrength = 0.35f; + // A2 tuning knob: peak alpha at t=0, fading linearly to 0 over the sweep. + private const byte SheenPeakAlpha = 0x40; + private static readonly Dictionary SheenStarts = new(); // Test-observability hook for HoverSheenAllocStep: lets the self-test @@ -57,7 +60,7 @@ internal static class DrawListExtensions return; var t = (float)(elapsed / SheenDurationSeconds); - var alpha = (byte)Math.Round(0x40 * (1f - t)); + var alpha = (byte)Math.Round(SheenPeakAlpha * (1f - t)); // Tint the sweep toward the accent hue, then stamp the falloff alpha. // accentRgba is RGBA; convert to ABGR FIRST or the draw-list swaps R/B. var accentAbgr = ColourUtil.RgbaToAbgr(accentRgba); From f05d7104aff8a918c5560761b4cb9f556ae3742f Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 12:33:26 +0200 Subject: [PATCH 007/129] A3: gate pop-out affordance on expanded sidebar (icon-only overlap fix) --- HellionChat/Ui/Components/Sidebar.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index 3bacbee..9228044 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -248,7 +248,11 @@ internal sealed class Sidebar // Only split off a separate pop-out hit area when there's room for // both buttons. Below that, the whole row stays as a single // selectable strip without the pop-out affordance. - var hasPopOut = avail > PopOutHitWidth + 4f; + // A3: gate the pop-out affordance on the expanded sidebar too. In + // icon-only mode avail still clears the width threshold, which used to + // paint the pop-out glyph over the tab icon. The row stays a single + // selectable strip when collapsed; right-click pop-out is unaffected. + var hasPopOut = expanded && avail > PopOutHitWidth + 4f; var tabHitWidth = hasPopOut ? avail - PopOutHitWidth : avail; if (showGreeted) { From 97e58934d696009257117d55a3a266e3808f4697 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 13:03:26 +0200 Subject: [PATCH 008/129] D1 (XC-8): extract shared PrefillTellInput/BuildTellCommand for the two tell-prefill detours --- HellionChat/GameFunctions/Chat.cs | 48 ++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/HellionChat/GameFunctions/Chat.cs b/HellionChat/GameFunctions/Chat.cs index a4e77f1..0ad0a74 100755 --- a/HellionChat/GameFunctions/Chat.cs +++ b/HellionChat/GameFunctions/Chat.cs @@ -317,6 +317,30 @@ internal sealed unsafe class Chat : IDisposable ReplyInSelectedChatModeHook!.Original(agent); } + // Pure /tell-prefill command builder, shared by the two native SetTellTarget + // detours and the PayloadHandler Send-Tell payload. Empty/null world drops the + // @World suffix (matches the old IsNullOrEmpty guard); trailing space lets the + // user type straight after. internal static so the Build-Suite can pin it + // frame-free. TEST-MIRROR: ../../../Hellion Build test/GameFunctions/PrefillTellCommandTests.cs + internal static string BuildTellCommand(string name, string? world) + { + var command = $"/tell {name}"; + if (!string.IsNullOrEmpty(world)) + command += $"@{world}"; + command += " "; + return command; + } + + // Prefills + focuses our own input bar with a /tell command. The DI/Dalamud + // vorspann (Plugin.InputBar) lives here; the string assembly is BuildTellCommand. + // The in-foray TellSpecial routing (SetEurekaTellChannel) is NOT this helper's + // job — it stays at the call-site (v1.8.1 deferral). + private void PrefillTellInput(string name, string? world) + { + Plugin.InputBar.SetPendingMessage(BuildTellCommand(name, world)); + Plugin.InputBar.Activate = true; + } + private bool SetContextTellTarget( RaptureShellModule* a1, Utf8String* playerName, @@ -334,14 +358,10 @@ internal sealed unsafe class Chat : IDisposable // "Send Tell" payload menu does (PayloadHandler), then focus. Prefill- // only — no tab switch, no ChatActivatedArgs revival (Flo decision // 2026-06-15). The game supplies worldName here, so no sheet lookup. - var tellName = playerName->ToString(); - var tellWorld = worldName != null ? worldName->ToString() : string.Empty; - var tellCommand = $"/tell {tellName}"; - if (!string.IsNullOrEmpty(tellWorld)) - tellCommand += $"@{tellWorld}"; - tellCommand += " "; - Plugin.InputBar.SetPendingMessage(tellCommand); - Plugin.InputBar.Activate = true; + PrefillTellInput( + playerName->ToString(), + worldName != null ? worldName->ToString() : null + ); } return SetChatLogTellTargetHook!.Original( @@ -374,14 +394,10 @@ internal sealed unsafe class Chat : IDisposable // In-foray right-click -> Send Tell: same prefill path as the non-foray // tell. The foray-specific TellSpecial channel routing stays deferred // (v1.8.1, SetEurekaTellChannel) — prefill-only here (Flo decision 2026-06-15). - var forayName = playerName->ToString(); - var forayWorld = worldName != null ? worldName->ToString() : string.Empty; - var forayCommand = $"/tell {forayName}"; - if (!string.IsNullOrEmpty(forayWorld)) - forayCommand += $"@{forayWorld}"; - forayCommand += " "; - Plugin.InputBar.SetPendingMessage(forayCommand); - Plugin.InputBar.Activate = true; + PrefillTellInput( + playerName->ToString(), + worldName != null ? worldName->ToString() : null + ); } ContextMenuTellInForayHook!.Original( From 5bdf4217d6b24ede4c6c86a5057843451d2964ae Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 13:10:22 +0200 Subject: [PATCH 009/129] D1-3 (XC-8): route PayloadHandler Send-Tell through shared BuildTellCommand (IsPublic at call-site) --- HellionChat/PayloadHandler.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/HellionChat/PayloadHandler.cs b/HellionChat/PayloadHandler.cs index 46be55b..82143d8 100644 --- a/HellionChat/PayloadHandler.cs +++ b/HellionChat/PayloadHandler.cs @@ -263,13 +263,15 @@ internal sealed class PayloadHandler // Eureka, Bozja and Occult need special handling as tells work different if (!Sheets.IsInForay()) { - // §6.9: single SetPendingMessage call; v1.5.6 used incremental Chat += writes - var builder = $"/tell {player.PlayerName}"; - if (world.Value.IsPublic) - builder += $"@{world.Value.Name}"; - - builder += " "; - _inputBar.SetPendingMessage(builder); + // §6.9: single SetPendingMessage call; v1.5.6 used incremental Chat += writes. + // XC-8: shares the /tell builder with the native detours. IsPublic (not + // IsNullOrEmpty) is resolved HERE — a private/null world must NOT leak @World. + _inputBar.SetPendingMessage( + GameFunctions.Chat.BuildTellCommand( + player.PlayerName, + world.Value.IsPublic ? world.Value.Name.ToString() : null + ) + ); } else if (validContentId) { From ea3f00f107bcd3b724f27686fca15476618f2fcd Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 13:26:17 +0200 Subject: [PATCH 010/129] GP-04: reset Tab.PopOut on load via shared helper (clears stale pinned flags) --- HellionChat/Plugin.cs | 7 +++++++ HellionChat/Util/TabLifecycleHelpers.cs | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index cc2a810..a0cf891 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -241,6 +241,13 @@ public sealed class Plugin : IAsyncDalamudPlugin // TempTabs survive reload — Jin's tester feedback (v1.4.7). Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnLoad); + // GP-04: clear stale Tab.PopOut flags now — the pool binds further down + // (ChannelPopoutPool resolve below), so at this point no tab can own a + // slot. A persisted PopOut=true (notably on surviving pinned TempTabs) + // would otherwise be a flag with no window. Runs after the strip, before + // any pool TryOpen. + TabLifecycleHelpers.ResetPopOutOnLoad(Config.Tabs); + LanguageChanged(Interface.UiLanguage); // v1.5.3 migration: Settings.Apply auto-activates the matching diff --git a/HellionChat/Util/TabLifecycleHelpers.cs b/HellionChat/Util/TabLifecycleHelpers.cs index dc87ae2..d95ddcb 100644 --- a/HellionChat/Util/TabLifecycleHelpers.cs +++ b/HellionChat/Util/TabLifecycleHelpers.cs @@ -17,6 +17,19 @@ internal static class TabLifecycleHelpers public static bool ShouldStripOnSave(Tab t) => IsInUnpinnedPool(t); + // GP-04: clear every Tab.PopOut at load time. The pool binds later, so at + // load NO tab can own a slot — a persisted PopOut=true is always a stale flag + // with no window. Unconditional (pinned included) because pinned TempTabs + // survive the load and are the main stale-flag source; a !IsPinned filter + // would leave exactly those leaking. Lockstep with the two in-memory resets + // (AutoTellTabsService pool-full + Configuration.UpdateFrom backToOriginal). + // TEST-MIRROR: ../../../Hellion Build test/_Helpers/PopOutResetOnLoadTests.cs + internal static void ResetPopOutOnLoad(IEnumerable tabs) + { + foreach (var tab in tabs) + tab.PopOut = false; + } + // Stale-tell strip + channel derive, run at every tab activation. When a // DIFFERENT tab becomes the input surface, drop any runtime tell state the // game-side detour left on it (the CurrentChannel tell target plus the From fa20b534551ef23c887caa848dad626ad667a845 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 14:09:28 +0200 Subject: [PATCH 011/129] C2/C3: restore rotation keybinds (REPLY/LS-cycle) + focus contract routing on the focused chat surface --- HellionChat/GameFunctions/Chat.cs | 11 + HellionChat/GameFunctions/KeybindManager.cs | 190 +++++++++++++++--- HellionChat/Ui/Windows/ChannelPopoutWindow.cs | 14 +- .../Ui/Windows/IFocusableChatWindow.cs | 14 ++ HellionChat/Ui/Windows/MainWindow.cs | 14 +- 5 files changed, 213 insertions(+), 30 deletions(-) create mode 100644 HellionChat/Ui/Windows/IFocusableChatWindow.cs diff --git a/HellionChat/GameFunctions/Chat.cs b/HellionChat/GameFunctions/Chat.cs index 0ad0a74..2546d40 100755 --- a/HellionChat/GameFunctions/Chat.cs +++ b/HellionChat/GameFunctions/Chat.cs @@ -451,6 +451,17 @@ internal sealed unsafe class Chat : IDisposable uint currentIndex, RotateMode rotate, Func validFn + ) => RotateLinkshellIndex(currentIndex, rotate, validFn); + + // Pure index-stepper (Dalamud-free): wrap (8 + i + delta) % 8 and return the + // first index validFn accepts within 8 iterations, else null. Extracted so the + // modulo/termination logic is unit-testable with a synthetic predicate; the + // production caller binds validFn to InfoProxyLinkshell (in-game only). + // TEST-MIRROR: ../../Hellion Build test/_Helpers/RotateLinkshellIndexTests.cs + internal static uint? RotateLinkshellIndex( + uint currentIndex, + RotateMode rotate, + Func validFn ) { if (rotate == RotateMode.None) diff --git a/HellionChat/GameFunctions/KeybindManager.cs b/HellionChat/GameFunctions/KeybindManager.cs index 1b8f0a0..55a4a28 100644 --- a/HellionChat/GameFunctions/KeybindManager.cs +++ b/HellionChat/GameFunctions/KeybindManager.cs @@ -7,6 +7,7 @@ using FFXIVClientStructs.FFXIV.Client.System.String; using FFXIVClientStructs.FFXIV.Client.UI; using HellionChat.Code; using HellionChat.GameFunctions.Types; +using HellionChat.Ui.Windows; using HellionChat.Util; using Microsoft.Extensions.Logging; using ModifierFlag = HellionChat.GameFunctions.Types.ModifierFlag; @@ -504,41 +505,174 @@ internal unsafe class KeybindManager : IDisposable if (!KeybindsToIntercept.TryGetValue(currentBest.Item2, out var info)) return; - // Re-surface the chat-activation entry point retired in v1.6.0: a chat-open - // keybind shows + focuses the window, restoring it from a user-hide or a - // closed state. - Plugin.Instance.MainWindow?.ActivateChat(); + // Resolve the surface this keybind acts on FIRST: a focused pop-out otherwise + // the main window. Channel-set/REPLY/prefill all write here so the action + // follows the input the user is typing in (C3 full tail rebuild, OD-1). + var (targetWindow, targetTab) = ResolveKeybindTarget(); - // Direct channel-switch binds (CMD_SAY/PARTY/numbered linkshells/…): switch the - // game channel AND mirror it onto the active tab so the input pill shows the - // real send target (pill-sync, Flo decision 2026-06-15). Rotation binds (REPLY / - // linkshell-cycle, Rotate != None) are skipped; the temp-vs-permanent distinction - // (v1.5.6's UseTempChannel / info.Permanent) collapses to one permanent-style - // switch here — restoring it is the keybind-routing follow-cycle. - if (info.Channel is { } channel && info.Rotate == RotateMode.None) + // Surface + focus the resolved target ONCE, before routing. Main: ActivateChat + // re-surfaces it from a hide/closed state (the chat-activation entry point + // retired in v1.6.0). Pop-out: arm only its focus — NOT ActivateChat, which + // would yank the main window to front and un-hide it on every pop-out-targeted + // keybind (OD-1: stay where the user types). Exactly one window arms focus per + // keybind, so the next frame has no SetKeyboardFocusHere race. + if (targetWindow is ChannelPopoutWindow) + targetWindow.RequestInputFocus(); + else + Plugin.Instance.MainWindow?.ActivateChat(); + + // The routing tail makes native game calls (GetTellHistoryInfo, UIModule, + // RotateLinkshellHistory) on the framework tick — wrap it so one bad frame logs + // instead of throwing into Dalamud's update loop (v1.5.6 parity). + try { - Plugin.Instance.Functions.Chat.SetChannel(channel); - // Only mirror onto the tab when the game actually accepted the switch — an - // empty linkshell slot leaves the game channel untouched, so the pill must - // stay put rather than show a target the game will not send to. - if ( - Chat.IsChannelOrExistingLinkshell(channel) - && Plugin.Instance.MainWindow?.ActiveTab is { } activeTab - ) + if (info.Channel is { } channel && info.Rotate == RotateMode.None) { - activeTab.CurrentChannel.SetChannel(channel); - activeTab.CurrentChannel.TellTarget = null; - activeTab.CurrentChannel.ResetTempChannel(); + // Direct channel-switch binds (CMD_SAY/PARTY/numbered linkshells/…): switch + // the game channel AND mirror it onto the resolved tab so the input pill + // shows the real send target (pill-sync, Flo decision 2026-06-15). + Plugin.Instance.Functions.Chat.SetChannel(channel); + // Only mirror onto the tab when the game actually accepted the switch — an + // empty linkshell slot leaves the game channel untouched, so the pill must + // stay put rather than show a target the game will not send to. + if (Chat.IsChannelOrExistingLinkshell(channel) && targetTab is { } directTab) + { + directTab.CurrentChannel.SetChannel(channel); + directTab.CurrentChannel.TellTarget = null; + directTab.CurrentChannel.ResetTempChannel(); + } } - } + else if (info.Channel is { } rotateChannel && info.Rotate != RotateMode.None) + { + // Rotation binds (REPLY / linkshell-cycle). Ported from v1.5.6's + // ChatLogWindow.Activated (1d3b429:240-334) without the ChatActivatedArgs + // indirection (gone in the rewrite). Writes onto the resolved surface's + // tab (C2/C3 shared target), not Plugin.CurrentTab. + if (targetTab is { } rotTab) + { + var targetChannel = (InputChannel?)rotateChannel; - // Prefill text binds (CMD_COMMAND seeds "/"): drop the token into our input. - if (info.Text is { } text) - Plugin.Instance.InputBar.SetPendingMessage(text); + // REPLY rotation: the reply target is ALWAYS temp (never permanent — + // a permanent reply would leak the partner onto the tab) and ALWAYS + // TellReason.Reply. info.Permanent does not gate this step; only the + // channel-set tail below honours the _ALWAYS binds' permanence. + if (rotateChannel == InputChannel.Tell) + { + var idx = + rotTab.CurrentChannel.TempChannel != InputChannel.Tell ? 0 + : info.Rotate == RotateMode.Reverse ? -1 + : 1; + + var tellInfo = Plugin.Instance.Functions.Chat.GetTellHistoryInfo(idx); + if (tellInfo != null) + rotTab.CurrentChannel.TempTellTarget = new TellTarget( + tellInfo.Name, + tellInfo.World, + tellInfo.ContentId, + TellReason.Reply + ); + } + else + { + // Cycling AWAY from Tell to a linkshell: drop any stale permanent + // tell target so a typed line cannot silently route to the old + // partner (v1.5.6 ChatLogWindow.cs:280, privacy guard). + rotTab.CurrentChannel.TellTarget = null; + } + + // LS/CWLS cycle: permanent rotates the game's own history and reads the + // landed cycle index back; temp resolves the next valid linkshell index + // without touching game state. Both leave targetChannel null on failure + // (no valid linkshell in 8 iterations) so the tail below logs + skips. + if (rotateChannel is InputChannel.Linkshell1 or InputChannel.CrossLinkshell1) + { + var module = UIModule.Instance(); + if (info.Permanent) + { + if (rotateChannel == InputChannel.Linkshell1) + { + Chat.RotateLinkshellHistory(info.Rotate); + targetChannel = rotateChannel + (uint)module->LinkshellCycle; + } + else + { + Chat.RotateCrossLinkshellHistory(info.Rotate); + targetChannel = + rotateChannel + (uint)module->CrossWorldLinkshellCycle; + } + } + else + { + targetChannel = Chat.ResolveTempInputChannel( + rotTab.CurrentChannel.TempChannel, + rotateChannel, + info.Rotate + ); + } + } + + // Shared channel-set tail (runs for Tell too: IsChannelOrExistingLinkshell + // is true for Tell and targetChannel stays Tell). Permanent => commit the + // game channel; temp => arm UseTempChannel/TempChannel only. This is the + // ONLY place info.Permanent decides temp vs permanent for the channel. + if ( + targetChannel is null + || !Chat.IsChannelOrExistingLinkshell(targetChannel.Value) + ) + { + _logger.LogWarning( + "Rotation channel resolved to an invalid value '{Channel}', ignoring", + targetChannel + ); + return; + } + + if (info.Permanent) + { + Plugin.Instance.Functions.Chat.SetChannel(targetChannel.Value); + rotTab.CurrentChannel.SetChannel(targetChannel.Value); + } + else + { + rotTab.CurrentChannel.UseTempChannel = true; + rotTab.CurrentChannel.TempChannel = targetChannel.Value; + } + } + } + + // Prefill text binds (CMD_COMMAND seeds "/"): the token always goes to the + // main InputBar (the focus contract does not expose pop-out buffers); a + // focused pop-out already received focus above, so only token routing matters + // here (documented scope limit, OD-1). + if (info.Text is { } text) + Plugin.Instance.InputBar.SetPendingMessage(text); + } + catch (Exception ex) + { + _logger.LogError(ex, "Keybind routing failed for channel {Channel}", info.Channel); + } } - // Pop-out input-bar focus-forward stays deferred (no focus contract yet) — - // main-window tabs only. + // Resolve which chat surface a keybind action targets: the open pop-out whose + // input currently has focus, otherwise the main window. C2/C3 share this so a + // channel-switch/REPLY/prefill follows the surface the user is typing in. The + // returned tab is that surface's bound tab (pop-out: Bound; main: ActiveTab). + // Null tab => skip the tab-write (early-load window where no tab exists yet). + private (IFocusableChatWindow Window, Tab? Tab) ResolveKeybindTarget() + { + foreach (var popout in Plugin.Instance.ChannelPopoutPool.Instances) + { + if (popout.Bound is { } bound && popout.IsOpen && popout.HasFocusedInput) + return (popout, bound); + } + + var main = Plugin.Instance.MainWindow; + return (main!, main?.ActiveTab); + } + + // Tab-delta keybinds (ChatTabForward/Backward) stay main-window-only by design: + // a channel-bound pop-out has no tab list to cycle (OD-1). The focus contract is + // consumed by the channel-set/REPLY/prefill tail, not here. private void DispatchTabDelta(int delta) { Plugin.Instance.MainWindow?.ChangeTabDelta(delta); diff --git a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs index 606f82c..7d0aa3b 100644 --- a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs +++ b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs @@ -13,7 +13,7 @@ namespace HellionChat.Ui.Windows; // via ctor — see plan §B.2. The ###id carries the slot index so all N // instances are unique for WindowSystem.AddWindow and ImGui state is stable // per slot (not per bound tab). -internal sealed class ChannelPopoutWindow : Window +internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow { private readonly int _slotIndex; private readonly MessageList _messages; @@ -75,6 +75,18 @@ internal sealed class ChannelPopoutWindow : Window IsOpen = false; } + // IFocusableChatWindow — this pop-out's own InputBar carries the focus state + // the keybind tail checks when deciding whether to route at this surface (C3). + public bool HasFocusedInput => _input.IsFocused; + + // Arm-and-hold the one-frame Activate flag; the pop-out's Draw applies the + // ImGui focus next frame. Framework-thread safe (field write only). + public void RequestInputFocus() + { + BringToFront(); + _input.Activate = true; + } + public override void PreDraw() { // Gate the native title bar on the user toggle (1.5.6 parity). DrawHeader diff --git a/HellionChat/Ui/Windows/IFocusableChatWindow.cs b/HellionChat/Ui/Windows/IFocusableChatWindow.cs new file mode 100644 index 0000000..04bf634 --- /dev/null +++ b/HellionChat/Ui/Windows/IFocusableChatWindow.cs @@ -0,0 +1,14 @@ +namespace HellionChat.Ui.Windows; + +// Focus contract shared by the main window and each pop-out so the keybind tail +// can route channel-set / REPLY / prefill to whichever surface currently owns the +// input focus, without the KeybindManager reaching into either window's privates. +// HasFocusedInput reads the bound InputBar's per-frame focus state; RequestInputFocus +// only arms the one-frame Activate flag (ImGui focus is frame-bound — never call +// SetKeyboardFocusHere from the framework thread). +internal interface IFocusableChatWindow +{ + bool HasFocusedInput { get; } + + void RequestInputFocus(); +} diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index 524d3ad..d8c2900 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -16,7 +16,7 @@ namespace HellionChat.Ui.Windows; // Components are fully qualified through the Ui.Components prefix so the // old Ui.StatusBar type (still alive until the cleanup block removes it) // cannot shadow the new layer through parent-namespace resolution. -internal sealed class MainWindow : Window +internal sealed class MainWindow : Window, IFocusableChatWindow { private const float DefaultWidth = 620f; private const float DefaultHeight = 340f; @@ -205,6 +205,18 @@ internal sealed class MainWindow : Window _input.Activate = true; } + // IFocusableChatWindow — the keybind tail resolves which surface owns the + // input focus before routing a channel-set/REPLY/prefill at it (C3). + public bool HasFocusedInput => _input.IsFocused; + + // Arm-and-hold: field writes only, safe from the framework thread; the draw + // path applies the actual ImGui focus next frame (same path as ActivateChat). + public void RequestInputFocus() + { + BringToFront(); + _input.Activate = true; + } + // new-shadow on Window.Toggle so the open path also writes Config. A user-hide // counts as "not visible", so /hellion is a reliable one-press recovery even when // the Enter keybind can't fire (DirectChat / a focused game text field). From 6733c7f92e2597be21a2c182cbbe06a954bc218e Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 18:30:23 +0200 Subject: [PATCH 012/129] Restore pop-out exclusivity: hide popped tabs from main window + keybind/unread parity --- HellionChat/PluginHostFactory.cs | 3 +- HellionChat/Ui/Components/Sidebar.cs | 29 +------ HellionChat/Ui/Components/TopTabBar.cs | 9 ++- HellionChat/Ui/Windows/ChannelPopoutWindow.cs | 5 ++ HellionChat/Ui/Windows/MainWindow.cs | 36 ++++++++- HellionChat/Util/TabLifecycleHelpers.cs | 78 +++++++++++++++++++ 6 files changed, 129 insertions(+), 31 deletions(-) diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 06a9c32..d700eaf 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -231,7 +231,8 @@ internal static class PluginHostFactory sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService>() + sp.GetRequiredService>(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Integrations.FailedTellNotifier( sp.GetRequiredService>() diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index 9228044..19b255b 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -134,7 +134,10 @@ internal sealed class Sidebar // the tab list itself stays untouched and every row keeps its // ORIGINAL list index for PushID, so an open context-menu popup // stays bound to its tab when sectioning moves it visually. - var renderOrder = BuildRenderOrder(tabs); + var renderOrder = TabLifecycleHelpers.BuildRenderOrder( + tabs, + t => _pool.IsOpen(t.Identifier) + ); var pinnedHeaderRendered = false; var unpinnedHeaderRendered = false; foreach (var i in renderOrder) @@ -186,30 +189,6 @@ internal sealed class Sidebar LastDrawnSectionHeaderCount++; } - // Mirror of 1.5.6's BuildSidebarRenderOrder: returns indices into the - // live tab list grouped by section, so the list order itself is never - // mutated and headers gate on the first tab actually reached per pool - // (an empty pool draws neither separator nor header). - private static List BuildRenderOrder(IList tabs) - { - var persistent = new List(tabs.Count); - var pinned = new List(); - var unpinned = new List(); - for (var i = 0; i < tabs.Count; i++) - { - if (TabLifecycleHelpers.IsInPinnedPool(tabs[i])) - pinned.Add(i); - else if (TabLifecycleHelpers.IsInUnpinnedPool(tabs[i])) - unpinned.Add(i); - else - persistent.Add(i); - } - - persistent.AddRange(pinned); - persistent.AddRange(unpinned); - return persistent; - } - private void DrawRow( Tab tab, int index, diff --git a/HellionChat/Ui/Components/TopTabBar.cs b/HellionChat/Ui/Components/TopTabBar.cs index 5010335..c7c4e00 100644 --- a/HellionChat/Ui/Components/TopTabBar.cs +++ b/HellionChat/Ui/Components/TopTabBar.cs @@ -18,11 +18,18 @@ internal sealed class TopTabBar public void Draw(IList tabs, ref Tab? activeTab) { + var firstDrawn = true; for (var i = 0; i < tabs.Count; i++) { var tab = tabs[i]; - if (i > 0) + // POP-1b: a popped-out tab is owned by its pop-out window, not the main + // strip (1.5.6 exclusivity). Gate on the pool, not Tab.PopOut (stale flag). + if (_pool.IsOpen(tab.Identifier)) + continue; + + if (!firstDrawn) ImGui.SameLine(); + firstDrawn = false; var selected = ReferenceEquals(tab, activeTab); // Size the selectable to its own label width. A zero width makes ImGui diff --git a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs index 7d0aa3b..a7fa4e1 100644 --- a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs +++ b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs @@ -111,6 +111,11 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow if (Bound is null) return; + // POP-1f: the bound tab is live-visible in this pop-out, so it carries no + // unread badge — mirror MainWindow's per-frame zero for the active tab. + // View-state reset only (tab.Messages store is untouched). + Bound.Unread = 0; + var inputHeight = InputBar.Height; using ( var body = ImRaii.Child( diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index d8c2900..6c5dcac 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -30,6 +30,7 @@ internal sealed class MainWindow : Window, IFocusableChatWindow private readonly Components.InputBar _input; private readonly Components.StatusBar _status; private readonly Lender _handlerLender; + private readonly ChannelPopoutPool _pool; private Tab? _activeTab; @@ -51,7 +52,8 @@ internal sealed class MainWindow : Window, IFocusableChatWindow Components.MessageList messages, Components.InputBar input, Components.StatusBar status, - Lender handlerLender + Lender handlerLender, + ChannelPopoutPool pool ) : base($"{Plugin.PluginName}###hellion-main") { @@ -62,6 +64,7 @@ internal sealed class MainWindow : Window, IFocusableChatWindow _input = input; _status = status; _handlerLender = handlerLender; + _pool = pool; Size = new Vector2(DefaultWidth, DefaultHeight); SizeCondition = ImGuiCond.FirstUseEver; @@ -176,7 +179,13 @@ internal sealed class MainWindow : Window, IFocusableChatWindow if (idx < 0) idx = 0; // active tab not in the list (mid-strip) -> start from the first - ActivateTab(tabs[TabLifecycleHelpers.WrapTabIndex(idx, delta, tabs.Count)]); + var nextIndex = TabLifecycleHelpers.NextMainTabIndex( + idx, + delta, + tabs, + t => _pool.IsOpen(t.Identifier) + ); + ActivateTab(tabs[nextIndex]); } // Internal accessors for self-tests so the probes can reach the live @@ -270,6 +279,25 @@ internal sealed class MainWindow : Window, IFocusableChatWindow TabLifecycleHelpers.OnTabActivated(reseed, active); } + // POP-1c: a popped-out tab must not stay the main window's active surface + // (1.5.6 exclusivity). Re-anchor to the first non-popped tab the moment the + // active one is popped; null when every tab is popped (POP-1d guards Draw). + // Runs post-seed, before the sidebar/top-tab draw, so the popped tab never + // renders. Idempotent: PickMainActiveTab returns the same reference once + // settled, so OnTabActivated fires only on the pop frame. + var visibleActive = TabLifecycleHelpers.PickMainActiveTab( + _activeTab, + Plugin.Config.Tabs, + t => _pool.IsOpen(t.Identifier) + ); + if (!ReferenceEquals(visibleActive, _activeTab)) + { + var previousActive = _activeTab; + _activeTab = visibleActive; + if (visibleActive is not null) + TabLifecycleHelpers.OnTabActivated(visibleActive, previousActive); + } + // The active tab's messages are on screen, so it carries no unread badge // (1.5.6 convention: zero the current tab every frame so the dot only ever // shows on tabs you are NOT looking at). @@ -336,8 +364,8 @@ internal sealed class MainWindow : Window, IFocusableChatWindow ) ) { - if (messages.Success) - _messages.Draw(_activeTab!); + if (messages.Success && _activeTab is not null) + _messages.Draw(_activeTab); } // Inside-mode inline render: measure first so PreviewHeight is fresh diff --git a/HellionChat/Util/TabLifecycleHelpers.cs b/HellionChat/Util/TabLifecycleHelpers.cs index d95ddcb..103f482 100644 --- a/HellionChat/Util/TabLifecycleHelpers.cs +++ b/HellionChat/Util/TabLifecycleHelpers.cs @@ -107,4 +107,82 @@ internal static class TabLifecycleHelpers return 0; return ((current + delta) % count + count) % count; } + + // Sectioned sidebar render order (1.5.6 parity): persistent → pinned TempTabs → + // unpinned TempTabs. Returns indices into the live tab list so the list order is + // never mutated and DrawRow keeps each tab's ORIGINAL index for PushID. POP-1a: + // isPoppedOut excludes tabs bound to a pop-out window — an excluded tab draws no + // row and its pool's section header gates on the first tab actually reached. Pure + // + Dalamud-free so the Build-Suite can pin it. + // TEST-MIRROR: ../../../Hellion Build test/_Helpers/SidebarRenderOrderTests.cs + internal static List BuildRenderOrder(IList tabs, Func isPoppedOut) + { + var persistent = new List(tabs.Count); + var pinned = new List(); + var unpinned = new List(); + for (var i = 0; i < tabs.Count; i++) + { + if (isPoppedOut(tabs[i])) + continue; + if (IsInPinnedPool(tabs[i])) + pinned.Add(i); + else if (IsInUnpinnedPool(tabs[i])) + unpinned.Add(i); + else + persistent.Add(i); + } + + persistent.AddRange(pinned); + persistent.AddRange(unpinned); + return persistent; + } + + // POP-1c: returns the tab the main window should display — the current tab if it + // is not popped out, else the FIRST non-popped tab in list order, else null when + // every tab is popped. NOTE: deliberately NOT ResetActiveTabIfRemoved's + // unconditional Tabs[0] — Tabs[0] may itself be popped and would re-trigger the + // re-anchor every frame. Pure + Dalamud-free. + // TEST-MIRROR: ../../../Hellion Build test/_Helpers/PickMainActiveTabTests.cs + internal static Tab? PickMainActiveTab( + Tab? current, + IList tabs, + Func isPoppedOut + ) + { + if (current is not null && !isPoppedOut(current)) + return current; + foreach (var tab in tabs) + if (!isPoppedOut(tab)) + return tab; + return null; + } + + // POP-1e: popout-aware sibling of WrapTabIndex for the ChatTabForward/Backward + // keybind. Steps from current by delta's sign (±1), wrapping, and returns the + // first index whose tab is NOT popped out within tabs.Count steps; returns current + // when every other tab is popped (no-op), the list is empty, or a single tab. + // Skipping popped tabs here is what stops the POP-1c re-anchor from making the + // cycle stick (CYCLE-1). Pure + Dalamud-free. + // TEST-MIRROR: ../../../Hellion Build test/_Helpers/NextMainTabIndexTests.cs + internal static int NextMainTabIndex( + int current, + int delta, + IList tabs, + Func isPoppedOut + ) + { + if (tabs.Count == 0) + return current; + var step = delta >= 0 ? 1 : -1; + var idx = current; + for (var n = 0; n < tabs.Count; n++) + { + idx = ((idx + step) % tabs.Count + tabs.Count) % tabs.Count; + if (idx == current) + break; + if (!isPoppedOut(tabs[idx])) + return idx; + } + return current; + } } From 458df3b4bdb350aa97474cd79ab603ffd10c8efe Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 19:03:34 +0200 Subject: [PATCH 013/129] Restore permanent-REPLY game-side tell pre-targeting (1.5.6 parity) --- HellionChat/GameFunctions/KeybindManager.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/HellionChat/GameFunctions/KeybindManager.cs b/HellionChat/GameFunctions/KeybindManager.cs index 55a4a28..fa47706 100644 --- a/HellionChat/GameFunctions/KeybindManager.cs +++ b/HellionChat/GameFunctions/KeybindManager.cs @@ -629,7 +629,18 @@ internal unsafe class KeybindManager : IDisposable if (info.Permanent) { - Plugin.Instance.Functions.Chat.SetChannel(targetChannel.Value); + // KB-01 (1.5.6 parity, ChatLogWindow.SetChannel 1d3b429:1476-1479): + // committing the game channel also pre-targets the game's native input. + // Forward the tab's reply target for Tell so the partner is armed + // game-side (ChangeChatChannel code 17); null for a linkshell — + // targetChannel is the FINAL resolved value (9..16/19..26 for LS, never + // 0=Tell), so a stale TempTellTarget can never flip an LS cycle to Tell. + var gameTarget = + targetChannel.Value == InputChannel.Tell + ? rotTab.CurrentChannel.TempTellTarget + ?? rotTab.CurrentChannel.TellTarget + : null; + Plugin.Instance.Functions.Chat.SetChannel(targetChannel.Value, gameTarget); rotTab.CurrentChannel.SetChannel(targetChannel.Value); } else From 68c4e284958a4b25c4b0976c11b3886136d51136 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 19:13:45 +0200 Subject: [PATCH 014/129] perf(baseline): time full Draw() handler into LastDrawMs field --- HellionChat/Plugin.cs | 100 ++++++++++++++++++++++++++---------------- 1 file changed, 63 insertions(+), 37 deletions(-) diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index a0cf891..06f1875 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -166,6 +166,15 @@ public sealed class Plugin : IAsyncDalamudPlugin // because the tick reads it from a different thread than the writer. private volatile bool _isDisposing; + // v1.9.0 B5: last full Draw() wall-time in ms, written once per frame at + // the end of the UiBuilder.Draw handler. Covers the GlobalStyleScope push + // and the font push (§7.5 First-Frame-HITCH must include atlas/style + // prologue cost), not just WindowSystem.Draw — measuring the inner call + // alone would drop the prologue and make the figure non-comparable to the + // v1.5.6 baseline. Only accumulated here; the disk write happens in + // PerformanceBaselineStep so the hot path stays allocation-free. + internal double LastDrawMs; + internal int DeferredSaveFrames = -1; // Cancels the v1.4.8 FTS5 bulk-insert worker on plugin teardown. The @@ -1012,48 +1021,65 @@ public sealed class Plugin : IAsyncDalamudPlugin private void Draw() { - // v1.4.8 B2: pick up external edits of the active custom theme JSON - // without forcing the user to re-click the picker. The disk-stat is - // 1Hz-throttled inside RefreshActiveIfStale, so this is essentially - // free on built-in themes and ~1 stat/second on custom themes. - ThemeRegistry.RefreshActiveIfStale(); - - using IDisposable _style = Ui.StyleEngine.GlobalStyleScope.Push( - ThemeRegistry.Active, - ThemeRegistry, - Config.WindowOpacity - ); - - if (Config.HideInLoadingScreens && Condition[ConditionFlag.BetweenAreas]) - { - TypingIpc.Update(); + // v1.9.0 B5: time the whole handler (style + font prologue included). + // Bail before measuring once teardown has begun — a late Draw tick + // must not touch ThemeRegistry / FontManager after DisposeAsync. + if (_isDisposing) return; - } - // Hide all plugin windows while the New Game+ menu is open. - if ( - Config.HideInNewGamePlusMenu - && GameFunctions.GameFunctions.IsAddonInteractable( - GameFunctions.GameFunctions.NewGamePlusAddonName + var drawWatch = Stopwatch.StartNew(); + try + { + // v1.4.8 B2: pick up external edits of the active custom theme JSON + // without forcing the user to re-click the picker. The disk-stat is + // 1Hz-throttled inside RefreshActiveIfStale, so this is essentially + // free on built-in themes and ~1 stat/second on custom themes. + ThemeRegistry.RefreshActiveIfStale(); + + using IDisposable _style = Ui.StyleEngine.GlobalStyleScope.Push( + ThemeRegistry.Active, + ThemeRegistry, + Config.WindowOpacity + ); + + if (Config.HideInLoadingScreens && Condition[ConditionFlag.BetweenAreas]) + { + TypingIpc.Update(); + return; + } + + // Hide all plugin windows while the New Game+ menu is open. + if ( + Config.HideInNewGamePlusMenu + && GameFunctions.GameFunctions.IsAddonInteractable( + GameFunctions.GameFunctions.NewGamePlusAddonName + ) ) - ) - { + { + TypingIpc.Update(); + return; + } + + Interface.UiBuilder.DisableUserUiHide = !Config.HideWhenUiHidden; + + // RegularFont is nullable only because the live rebuild path + // disposes it before reassigning; both ends of that swap happen on + // this same draw thread, so it cannot be null here. + var useRegularFont = Config.FontsEnabled || Config.UseHellionFont; + using ((useRegularFont ? FontManager.RegularFont! : FontManager.Axis).Push()) + WindowSystem.Draw(); + TypingIpc.Update(); - return; + + FileDialogManager.Draw(); + } + finally + { + // finally so the early-return frames (loading screen / NG+) record + // their (cheap) time too instead of freezing on the last full frame. + drawWatch.Stop(); + LastDrawMs = drawWatch.Elapsed.TotalMilliseconds; } - - Interface.UiBuilder.DisableUserUiHide = !Config.HideWhenUiHidden; - - // RegularFont is nullable only because the live rebuild path - // disposes it before reassigning; both ends of that swap happen on - // this same draw thread, so it cannot be null here. - var useRegularFont = Config.FontsEnabled || Config.UseHellionFont; - using ((useRegularFont ? FontManager.RegularFont! : FontManager.Axis).Push()) - WindowSystem.Draw(); - - TypingIpc.Update(); - - FileDialogManager.Draw(); } internal void SaveConfig() From 430c8f235aed3d2e791b8e70e254247b501e809a Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 19:13:45 +0200 Subject: [PATCH 015/129] perf(baseline): 1000-frame steady-state capture with quad-proxy draw calls + JSON sink --- .../SelfTests/PerformanceBaselineLog.cs | 68 +++++++++++ .../SelfTests/PerformanceBaselineStep.cs | 115 ++++++++++++++---- 2 files changed, 159 insertions(+), 24 deletions(-) create mode 100644 HellionChat/SelfTests/PerformanceBaselineLog.cs diff --git a/HellionChat/SelfTests/PerformanceBaselineLog.cs b/HellionChat/SelfTests/PerformanceBaselineLog.cs new file mode 100644 index 0000000..20f396b --- /dev/null +++ b/HellionChat/SelfTests/PerformanceBaselineLog.cs @@ -0,0 +1,68 @@ +using System.Globalization; +using System.IO; + +namespace HellionChat.SelfTests; + +// Disk sink for the B5 performance baseline. Kept separate from the SelfTest +// step so the per-frame hot path never references file IO. Writes one +// perf-baseline.json into the plugin ConfigDirectory, atomically (tmp + move) +// like ThemeRegistry's theme writer, so a mid-write crash leaves either the +// old file or the new file, never a half JSON. Field names track §7.5: +// steady-state Draw cost (avg/max ms), the quad-proxy draw-call count +// (avg/max), and frame delta (avg/max). First-frame-HITCH is read off +// drawMs max/avg by the human author, platform-annotated in the notes. +internal static class PerformanceBaselineLog +{ + internal static string Write( + double avgDrawMs, + double maxDrawMs, + double avgDrawCallsProxy, + double maxDrawCallsProxy, + double avgDeltaMs, + double maxDeltaMs, + int frames + ) + { + var targetPath = Path.Join(Plugin.Interface.ConfigDirectory.FullName, "perf-baseline.json"); + + var json = + "{\n" + + $" \"frames\": {frames},\n" + + $" \"avgDrawMs\": {Fmt(avgDrawMs)},\n" + + $" \"maxDrawMs\": {Fmt(maxDrawMs)},\n" + + $" \"avgDrawCallsProxy\": {Fmt(avgDrawCallsProxy)},\n" + + $" \"maxDrawCallsProxy\": {Fmt(maxDrawCallsProxy)},\n" + + $" \"avgDeltaMs\": {Fmt(avgDeltaMs)},\n" + + $" \"maxDeltaMs\": {Fmt(maxDeltaMs)}\n" + + "}\n"; + + // Atomic replace — same volume rename is atomic on POSIX and Windows. + var tmpPath = targetPath + ".tmp"; + File.WriteAllText(tmpPath, json); + try + { + File.Move(tmpPath, targetPath, overwrite: true); + } + catch + { + // Avoid .tmp litter if Move fails (target locked). + try + { + File.Delete(tmpPath); + } + catch + { + // best effort + } + + throw; + } + + return targetPath; + } + + private static string Fmt(double value) + { + return value.ToString("F2", CultureInfo.InvariantCulture); + } +} diff --git a/HellionChat/SelfTests/PerformanceBaselineStep.cs b/HellionChat/SelfTests/PerformanceBaselineStep.cs index 3845b21..aae0744 100644 --- a/HellionChat/SelfTests/PerformanceBaselineStep.cs +++ b/HellionChat/SelfTests/PerformanceBaselineStep.cs @@ -1,19 +1,42 @@ -using System.Diagnostics; using Dalamud.Bindings.ImGui; using Dalamud.Plugin.SelfTest; namespace HellionChat.SelfTests; -// Optional metric capture. Walks one frame's ImGui IO counters and -// prints a single JSON block so the cycle-notes author can copy/paste -// the snapshot without standing up a separate profiling harness. -// Investigations themselves are deferred to the polish cycle — this -// step only records, it never fails on threshold. +// Optional metric capture. Accumulates 1000 steady-state frames of ImGui IO +// counters plus the plugin's full-Draw wall-time (Plugin.LastDrawMs, B5-1), +// then writes a single perf-baseline.json into the plugin ConfigDirectory so +// the cycle-notes author can copy the §7.5 figures without a separate +// profiling harness. The step only records — it never fails on a threshold +// (the budgets are evaluated by a human against the JSON, §7.5 "optional, +// manual"). It returns Waiting until the sample window fills, mirroring the +// per-frame poll idiom of ThemeSwitchSelfTestStep. internal sealed class PerformanceBaselineStep : ISelfTestStep { + // §7.5 steady-state window. 1000 frames ≈ 16s at 60fps, long enough to + // average out GC blips without making the manual step tedious. + private const int TargetFrames = 1000; + + // Quad proxy: ImGui emits 4 vertices + 6 indices per quad, so vertices/6 + // approximates the draw-quad count. NOT the real ImDrawData command count — + // the <500/frame budget is checked approximately (API-3). + private const int VerticesPerQuadProxy = 6; + + private readonly Plugin _plugin; + + private int _frames; + private ulong _lastFrameCount; + private double _drawMsSum; + private double _drawMsMax; + private long _vertexSum; + private long _vertexMax; + private double _deltaMsSum; + private double _deltaMsMax; + private string? _logPath; + public PerformanceBaselineStep(Plugin plugin) { - _ = plugin; + _plugin = plugin; } public string Name => "Hellion Chat - Performance baseline capture"; @@ -21,25 +44,69 @@ internal sealed class PerformanceBaselineStep : ISelfTestStep public SelfTestStepResult RunStep() { var io = ImGui.GetIO(); - var stopwatch = Stopwatch.StartNew(); - // No actual probe — we just sample the counters that ImGui keeps - // updated each frame. Stopwatch is started so the JSON line - // includes a non-zero wall-time figure even when ImGui has not - // accumulated frame stats yet. - stopwatch.Stop(); - ImGui.Text( - "{ " - + $"\"renderVertices\": {io.MetricsRenderVertices}, " - + $"\"renderIndices\": {io.MetricsRenderIndices}, " - + $"\"renderWindows\": {io.MetricsRenderWindows}, " - + $"\"activeWindows\": {io.MetricsActiveWindows}, " - + $"\"deltaTimeMs\": {io.DeltaTime * 1000f:F2}, " - + $"\"sampleWallTimeMs\": {stopwatch.Elapsed.TotalMilliseconds:F2}" - + " }" - ); + // Count each real frame once. Without the FrameCount gate a step that + // is polled more than once per frame would inflate the sample count. + var frameCount = Plugin.Interface.UiBuilder.FrameCount; + if (frameCount != _lastFrameCount) + { + _lastFrameCount = frameCount; + _frames++; + + var drawMs = _plugin.LastDrawMs; + _drawMsSum += drawMs; + if (drawMs > _drawMsMax) + _drawMsMax = drawMs; + + long vertices = io.MetricsRenderVertices; + _vertexSum += vertices; + if (vertices > _vertexMax) + _vertexMax = vertices; + + var deltaMs = io.DeltaTime * 1000f; + _deltaMsSum += deltaMs; + if (deltaMs > _deltaMsMax) + _deltaMsMax = deltaMs; + } + + if (_frames < TargetFrames) + { + ImGui.Text( + $"Sampling steady-state… {_frames}/{TargetFrames} frames. " + + "Keep the chat window visible and idle." + ); + return SelfTestStepResult.Waiting; + } + + _logPath ??= WriteBaselineLog(); + ImGui.Text($"Baseline captured ({TargetFrames} frames). Wrote: {_logPath}"); return SelfTestStepResult.Pass; } - public void CleanUp() { } + public void CleanUp() + { + _frames = 0; + _lastFrameCount = 0; + _drawMsSum = 0; + _drawMsMax = 0; + _vertexSum = 0; + _vertexMax = 0; + _deltaMsSum = 0; + _deltaMsMax = 0; + _logPath = null; + } + + private string WriteBaselineLog() + { + // Disk write happens here, never in the per-frame hot path. + return PerformanceBaselineLog.Write( + avgDrawMs: _drawMsSum / TargetFrames, + maxDrawMs: _drawMsMax, + avgDrawCallsProxy: _vertexSum / (double)TargetFrames / VerticesPerQuadProxy, + maxDrawCallsProxy: _vertexMax / (double)VerticesPerQuadProxy, + avgDeltaMs: _deltaMsSum / TargetFrames, + maxDeltaMs: _deltaMsMax, + frames: TargetFrames + ); + } } From 32013babaf285141cd294d30471e38760dd8e06a Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 19:23:15 +0200 Subject: [PATCH 016/129] perf(style): make GlobalStyleScope.StackHandle GC-free via counter scope --- .../Ui/StyleEngine/GlobalStyleScope.cs | 144 +++++++++++------- 1 file changed, 89 insertions(+), 55 deletions(-) diff --git a/HellionChat/Ui/StyleEngine/GlobalStyleScope.cs b/HellionChat/Ui/StyleEngine/GlobalStyleScope.cs index 8820c59..8e92714 100644 --- a/HellionChat/Ui/StyleEngine/GlobalStyleScope.cs +++ b/HellionChat/Ui/StyleEngine/GlobalStyleScope.cs @@ -1,5 +1,4 @@ using Dalamud.Bindings.ImGui; -using Dalamud.Interface.Utility.Raii; using HellionChat.Themes; using HellionChat.Util; @@ -33,60 +32,73 @@ internal static class GlobalStyleScope var childBgWithAlpha = ResolveChildBgAlpha(c.ChildBg, windowOpacity); var stack = new StackHandle(); - stack.PushStyleVar(ImGuiStyleVar.WindowRounding, l.WindowRounding); - stack.PushStyleVar(ImGuiStyleVar.ChildRounding, l.ChildRounding); - stack.PushStyleVar(ImGuiStyleVar.PopupRounding, l.PopupRounding); - stack.PushStyleVar(ImGuiStyleVar.FrameRounding, l.FrameRounding); - stack.PushStyleVar(ImGuiStyleVar.GrabRounding, l.GrabRounding); - stack.PushStyleVar(ImGuiStyleVar.TabRounding, l.TabRounding); - stack.PushStyleVar(ImGuiStyleVar.ScrollbarRounding, l.ScrollbarRounding); - stack.PushStyleVar(ImGuiStyleVar.WindowBorderSize, l.WindowBorderSize); - stack.PushStyleVar(ImGuiStyleVar.FrameBorderSize, l.FrameBorderSize); + // Hard contract: every push must be balanced by a pop. The pushes below + // are pure, practically non-throwing ImGui calls, but if one ever threw + // mid-stack we must still pop what we pushed before the exception escapes, + // or the global style stack stays corrupt for every other window this + // frame. Dispose then rethrow. + try + { + stack.PushStyleVar(ImGuiStyleVar.WindowRounding, l.WindowRounding); + stack.PushStyleVar(ImGuiStyleVar.ChildRounding, l.ChildRounding); + stack.PushStyleVar(ImGuiStyleVar.PopupRounding, l.PopupRounding); + stack.PushStyleVar(ImGuiStyleVar.FrameRounding, l.FrameRounding); + stack.PushStyleVar(ImGuiStyleVar.GrabRounding, l.GrabRounding); + stack.PushStyleVar(ImGuiStyleVar.TabRounding, l.TabRounding); + stack.PushStyleVar(ImGuiStyleVar.ScrollbarRounding, l.ScrollbarRounding); + stack.PushStyleVar(ImGuiStyleVar.WindowBorderSize, l.WindowBorderSize); + stack.PushStyleVar(ImGuiStyleVar.FrameBorderSize, l.FrameBorderSize); - stack.PushColor(ImGuiCol.WindowBg, windowBgWithAlpha); - stack.PushColor(ImGuiCol.ChildBg, childBgWithAlpha); - stack.PushColorAbgr(ImGuiCol.PopupBg, a.ChildBg); - stack.PushColorAbgr(ImGuiCol.Border, a.Border); - stack.PushColorAbgr(ImGuiCol.BorderShadow, 0u); + stack.PushColor(ImGuiCol.WindowBg, windowBgWithAlpha); + stack.PushColor(ImGuiCol.ChildBg, childBgWithAlpha); + stack.PushColorAbgr(ImGuiCol.PopupBg, a.ChildBg); + stack.PushColorAbgr(ImGuiCol.Border, a.Border); + stack.PushColorAbgr(ImGuiCol.BorderShadow, 0u); - stack.PushColorAbgr(ImGuiCol.FrameBg, a.FrameBg); - stack.PushColorAbgr(ImGuiCol.FrameBgHovered, a.SurfaceHover); - stack.PushColorAbgr(ImGuiCol.FrameBgActive, a.Surface); + stack.PushColorAbgr(ImGuiCol.FrameBg, a.FrameBg); + stack.PushColorAbgr(ImGuiCol.FrameBgHovered, a.SurfaceHover); + stack.PushColorAbgr(ImGuiCol.FrameBgActive, a.Surface); - stack.PushColorAbgr(ImGuiCol.TitleBg, a.WindowBg); - stack.PushColorAbgr(ImGuiCol.TitleBgActive, a.Identity); - stack.PushColorAbgr(ImGuiCol.TitleBgCollapsed, a.WindowBg); + stack.PushColorAbgr(ImGuiCol.TitleBg, a.WindowBg); + stack.PushColorAbgr(ImGuiCol.TitleBgActive, a.Identity); + stack.PushColorAbgr(ImGuiCol.TitleBgCollapsed, a.WindowBg); - stack.PushColorAbgr(ImGuiCol.Button, a.Primary); - stack.PushColorAbgr(ImGuiCol.ButtonHovered, a.PrimaryLight); - stack.PushColorAbgr(ImGuiCol.ButtonActive, a.PrimaryDark); + stack.PushColorAbgr(ImGuiCol.Button, a.Primary); + stack.PushColorAbgr(ImGuiCol.ButtonHovered, a.PrimaryLight); + stack.PushColorAbgr(ImGuiCol.ButtonActive, a.PrimaryDark); - stack.PushColorAbgr(ImGuiCol.Header, a.Surface); - stack.PushColorAbgr(ImGuiCol.HeaderHovered, a.SurfaceHover); - stack.PushColorAbgr(ImGuiCol.HeaderActive, a.Identity); + stack.PushColorAbgr(ImGuiCol.Header, a.Surface); + stack.PushColorAbgr(ImGuiCol.HeaderHovered, a.SurfaceHover); + stack.PushColorAbgr(ImGuiCol.HeaderActive, a.Identity); - stack.PushColorAbgr(ImGuiCol.Tab, a.FrameBg); - stack.PushColorAbgr(ImGuiCol.TabHovered, a.PrimaryLight); - stack.PushColorAbgr(ImGuiCol.TabActive, a.Identity); - stack.PushColorAbgr(ImGuiCol.TabUnfocused, a.ChildBg); - stack.PushColorAbgr(ImGuiCol.TabUnfocusedActive, a.PrimaryDark); + stack.PushColorAbgr(ImGuiCol.Tab, a.FrameBg); + stack.PushColorAbgr(ImGuiCol.TabHovered, a.PrimaryLight); + stack.PushColorAbgr(ImGuiCol.TabActive, a.Identity); + stack.PushColorAbgr(ImGuiCol.TabUnfocused, a.ChildBg); + stack.PushColorAbgr(ImGuiCol.TabUnfocusedActive, a.PrimaryDark); - stack.PushColorAbgr(ImGuiCol.ScrollbarBg, a.WindowBg); - stack.PushColorAbgr(ImGuiCol.ScrollbarGrab, a.Surface); - stack.PushColorAbgr(ImGuiCol.ScrollbarGrabHovered, a.AccentLight); - stack.PushColorAbgr(ImGuiCol.ScrollbarGrabActive, a.Accent); + stack.PushColorAbgr(ImGuiCol.ScrollbarBg, a.WindowBg); + stack.PushColorAbgr(ImGuiCol.ScrollbarGrab, a.Surface); + stack.PushColorAbgr(ImGuiCol.ScrollbarGrabHovered, a.AccentLight); + stack.PushColorAbgr(ImGuiCol.ScrollbarGrabActive, a.Accent); - stack.PushColorAbgr(ImGuiCol.ResizeGrip, a.FrameBg); - stack.PushColorAbgr(ImGuiCol.ResizeGripHovered, a.AccentLight); - stack.PushColorAbgr(ImGuiCol.ResizeGripActive, a.Accent); + stack.PushColorAbgr(ImGuiCol.ResizeGrip, a.FrameBg); + stack.PushColorAbgr(ImGuiCol.ResizeGripHovered, a.AccentLight); + stack.PushColorAbgr(ImGuiCol.ResizeGripActive, a.Accent); - stack.PushColorAbgr(ImGuiCol.CheckMark, a.Primary); - stack.PushColorAbgr(ImGuiCol.SliderGrab, a.Primary); - stack.PushColorAbgr(ImGuiCol.SliderGrabActive, a.PrimaryLight); + stack.PushColorAbgr(ImGuiCol.CheckMark, a.Primary); + stack.PushColorAbgr(ImGuiCol.SliderGrab, a.Primary); + stack.PushColorAbgr(ImGuiCol.SliderGrabActive, a.PrimaryLight); - stack.PushColorAbgr(ImGuiCol.Separator, a.Border); - stack.PushColorAbgr(ImGuiCol.SeparatorHovered, a.PrimaryLight); - stack.PushColorAbgr(ImGuiCol.SeparatorActive, a.Primary); + stack.PushColorAbgr(ImGuiCol.Separator, a.Border); + stack.PushColorAbgr(ImGuiCol.SeparatorHovered, a.PrimaryLight); + stack.PushColorAbgr(ImGuiCol.SeparatorActive, a.Primary); + } + catch + { + stack.Dispose(); + throw; + } return stack; } @@ -101,24 +113,46 @@ internal static class GlobalStyleScope return (themeChildBgRgba & 0xFFFFFF00u) | childBgAlpha; } + // Counter-based scope: pushes go straight onto the global ImGui style + // stack and we only remember how many of each kind we pushed. Dispose + // pops them in one batched PopStyleColor(count)/PopStyleVar(count) call. + // This replaces the old List + per-push boxed ImRaii structs + // (44 allocations/frame) with two ints — zero per-frame GC. Every style + // var pushed here is a single-float var, so PopStyleVar(count) is valid; + // both colour paths funnel into one PushStyleColor, so one colour counter + // covers them. Symmetry is the whole contract: the pop counts must equal + // the push counts or the global stack corrupts for every other window. private sealed class StackHandle : IDisposable { - private readonly List _items = new(64); + private int _colorCount; + private int _styleVarCount; - internal void PushColor(ImGuiCol slot, uint rgba) => - _items.Add(ImRaii.PushColor(slot, ColourUtil.RgbaToAbgr(rgba))); + internal void PushColor(ImGuiCol slot, uint rgba) + { + ImGui.PushStyleColor(slot, ColourUtil.RgbaToAbgr(rgba)); + _colorCount++; + } - internal void PushColorAbgr(ImGuiCol slot, uint abgr) => - _items.Add(ImRaii.PushColor(slot, abgr)); + internal void PushColorAbgr(ImGuiCol slot, uint abgr) + { + ImGui.PushStyleColor(slot, abgr); + _colorCount++; + } - internal void PushStyleVar(ImGuiStyleVar var, float value) => - _items.Add(ImRaii.PushStyle(var, value)); + internal void PushStyleVar(ImGuiStyleVar var, float value) + { + ImGui.PushStyleVar(var, value); + _styleVarCount++; + } public void Dispose() { - for (var i = _items.Count - 1; i >= 0; i--) - _items[i].Dispose(); - _items.Clear(); + if (_styleVarCount > 0) + ImGui.PopStyleVar(_styleVarCount); + if (_colorCount > 0) + ImGui.PopStyleColor(_colorCount); + _styleVarCount = 0; + _colorCount = 0; } } } From 0508a05bab9b440dfbdb9660c5e342b0e46d451c Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 19:23:15 +0200 Subject: [PATCH 017/129] test(selftest): add GlobalStyleScope GC-reserve alloc probe --- HellionChat/Plugin.cs | 1 + .../SelfTests/GlobalStyleScopeAllocStep.cs | 61 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 HellionChat/SelfTests/GlobalStyleScopeAllocStep.cs diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 06f1875..b761245 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -409,6 +409,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.HonorificHeaderRenderStep(this), new SelfTests.AboutIntegrationsStatusStep(this), new SelfTests.PerformanceBaselineStep(this), + new SelfTests.GlobalStyleScopeAllocStep(this), new SelfTests.MainWindowFocusOpacityStep(this), new SelfTests.MainWindowFlagsStep(this), new SelfTests.SenderNameReformatStep(this), diff --git a/HellionChat/SelfTests/GlobalStyleScopeAllocStep.cs b/HellionChat/SelfTests/GlobalStyleScopeAllocStep.cs new file mode 100644 index 0000000..06df8cf --- /dev/null +++ b/HellionChat/SelfTests/GlobalStyleScopeAllocStep.cs @@ -0,0 +1,61 @@ +using System; +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Ui.StyleEngine; + +namespace HellionChat.SelfTests; + +// GC-reserve probe for the v1.9.0 B4a refactor: GlobalStyleScope.Push runs +// once per draw frame, so its StackHandle must allocate nothing. Before the +// counter rewrite it boxed 44 ImRaii structs + a List per frame; after, it +// holds two ints and pushes straight onto the ImGui stack. This step drives a +// real Push()->Dispose() cycle and asserts the per-thread allocation delta is +// ~0 (not a non-null-handle check — feedback_hellion_chat_fontmanager_push_trap). +// A warm-up cycle pays the one-time JIT/first-touch cost so the measured cycle +// reflects steady state, matching the real per-frame hot path. +internal sealed class GlobalStyleScopeAllocStep : ISelfTestStep +{ + // Headroom for incidental managed noise (GC bookkeeping, boxing inside + // ImGui bindings we do not control). The pre-fix path allocated ~1-2 KB + // per cycle (44 boxes + List), so anything under this threshold proves + // the StackHandle itself stopped allocating. Tighten only if a future + // binding upgrade removes all incidental noise. + private const long AllocBudgetBytes = 256; + + private readonly Plugin _plugin; + + public GlobalStyleScopeAllocStep(Plugin plugin) + { + _plugin = plugin; + } + + public string Name => "Hellion Chat - GlobalStyleScope GC reserve"; + + public SelfTestStepResult RunStep() + { + var registry = _plugin.ThemeRegistry; + var theme = registry.Active; + var opacity = Plugin.Config.WindowOpacity; + + // Warm-up: JIT the Push/Dispose path + first-touch any lazy ImGui + // stack growth, so the measured cycle is steady-state only. + GlobalStyleScope.Push(theme, registry, opacity).Dispose(); + + var before = GC.GetAllocatedBytesForCurrentThread(); + GlobalStyleScope.Push(theme, registry, opacity).Dispose(); + var delta = GC.GetAllocatedBytesForCurrentThread() - before; + + if (delta > AllocBudgetBytes) + { + ImGui.Text( + $"GlobalStyleScope.Push allocated {delta} bytes/cycle " + + $"(budget {AllocBudgetBytes}) — StackHandle is not GC-free." + ); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} From 2c5c40524d2d8a0cbfae98cfdd192a02047aa248 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 19:25:34 +0200 Subject: [PATCH 018/129] B1: add Dalamud-free CjkFallbackRange helper + coverage tests --- HellionChat/CjkFallbackRange.cs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 HellionChat/CjkFallbackRange.cs diff --git a/HellionChat/CjkFallbackRange.cs b/HellionChat/CjkFallbackRange.cs new file mode 100644 index 0000000..55de13f --- /dev/null +++ b/HellionChat/CjkFallbackRange.cs @@ -0,0 +1,31 @@ +namespace HellionChat; + +// Reduced CJK fallback coverage for the v1.5.3 NotoSansCjk fallback merge (B1). +// Before B1 the fallback merged over the full `Ranges` array (Default + endonyms), +// duplicating the Latin/Default work already done by the global/Japanese fonts. +// This is the trimmed remainder the fallback is actually the sole source for: +// - Hangul Syllables (AC00-D7A3): no other merged font ships Korean glyphs. +// - The full CJK Unified Ideographs (Han) block: at UseHellionFont=true the global +// font is Inter-Light (no CJK), so the fallback is the SOLE Han source. The JpRange +// overlap is harmless (MergeMode: the Japanese font wins for shared kanji). +// Deliberately excluded: ONLY the ASCII/Latin Default block (0x20-0xFF), which the +// global font already owns -- that doubled Latin merge is the B1 waste being removed. +// Kept as plain start/end pairs so it is unit-testable without the unsafe ImGui +// glyph-range builder (mirrors FontSizeResolver's split-for-test rationale). +internal static class CjkFallbackRange +{ + // Hangul Syllables block + the FULL CJK Unified Ideographs (Han) block. The full + // Han window is REQUIRED, not optional: at UseHellionFont=true the global font is + // Inter-Light (no CJK), so this fallback is the SOLE source for Han + Hangul. The + // overlap with JpRange (kanji the Japanese font also ships) is harmless under ImGui + // MergeMode -- the earlier-merged Japanese font wins for shared codepoints, the + // fallback only fills holes (简 0x7B80, 中 0x4E2D, 国 0x56FD). The dedup win is + // NOT re-merging the ASCII/Latin Default block, which the global font already owns. + internal static readonly ushort[] Pairs = + [ + 0xAC00, + 0xD7A3, // Hangul Syllables + 0x4E00, + 0x9FFF, // CJK Unified Ideographs (full Han) -- sole source at UseHellionFont + ]; +} From 7d2fd1ab65c20505869c6673b0dd446168b184fe Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 19:53:39 +0200 Subject: [PATCH 019/129] selftest: add on-disk SelfTestReport log; report PASS/FAIL details from steps --- .../SelfTests/GlobalStyleScopeAllocStep.cs | 24 +++++++----- HellionChat/SelfTests/SelfTestReport.cs | 39 +++++++++++++++++++ 2 files changed, 53 insertions(+), 10 deletions(-) create mode 100644 HellionChat/SelfTests/SelfTestReport.cs diff --git a/HellionChat/SelfTests/GlobalStyleScopeAllocStep.cs b/HellionChat/SelfTests/GlobalStyleScopeAllocStep.cs index 06df8cf..94fe490 100644 --- a/HellionChat/SelfTests/GlobalStyleScopeAllocStep.cs +++ b/HellionChat/SelfTests/GlobalStyleScopeAllocStep.cs @@ -45,16 +45,20 @@ internal sealed class GlobalStyleScopeAllocStep : ISelfTestStep GlobalStyleScope.Push(theme, registry, opacity).Dispose(); var delta = GC.GetAllocatedBytesForCurrentThread() - before; - if (delta > AllocBudgetBytes) - { - ImGui.Text( - $"GlobalStyleScope.Push allocated {delta} bytes/cycle " - + $"(budget {AllocBudgetBytes}) — StackHandle is not GC-free." - ); - return SelfTestStepResult.Fail; - } - - return SelfTestStepResult.Pass; + // Report the measured figure on BOTH outcomes (Flo's request: don't just + // show Pass) — the byte delta is the whole point of the GC-reserve probe. + var ok = delta <= AllocBudgetBytes; + var status = ok ? "PASS" : "FAIL"; + SelfTestReport.Append( + Name, + status, + new[] { $"Push/Dispose allocated {delta} bytes/cycle (budget {AllocBudgetBytes})" } + ); + ImGui.Text( + $"GlobalStyleScope.Push allocated {delta} bytes/cycle " + + $"(budget {AllocBudgetBytes}) — {status}." + ); + return ok ? SelfTestStepResult.Pass : SelfTestStepResult.Fail; } public void CleanUp() { } diff --git a/HellionChat/SelfTests/SelfTestReport.cs b/HellionChat/SelfTests/SelfTestReport.cs new file mode 100644 index 0000000..07d7bc5 --- /dev/null +++ b/HellionChat/SelfTests/SelfTestReport.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +namespace HellionChat.SelfTests; + +// Shared report sink so manual self-test steps leave a readable trace on disk, +// not just a green Pass that flashes by for a single frame. Each call appends a +// timestamped block to selftest-report.log in the plugin ConfigDirectory; the +// human reads the tail after running the self-test runner. Same idea as the B5 +// perf-baseline.json (durable, copy-pasteable output) but as one shared append +// log so a full run leaves every reporting step's findings in one place. +// Append (not atomic tmp+move) is fine: the runner is single-threaded on the +// draw thread and a torn trailing line on a crash is acceptable for a debug log. +internal static class SelfTestReport +{ + internal static string Append(string stepName, string status, IReadOnlyList details) + { + var path = Path.Join(Plugin.Interface.ConfigDirectory.FullName, "selftest-report.log"); + + var stamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); + var sb = new StringBuilder(); + sb.Append("=== ") + .Append(stamp) + .Append(" | ") + .Append(stepName) + .Append(" | ") + .Append(status) + .Append(" ===\n"); + foreach (var line in details) + sb.Append(" ").Append(line).Append('\n'); + sb.Append('\n'); + + File.AppendAllText(path, sb.ToString()); + return path; + } +} From b048a51534d0039095900f89e43c790f545454e9 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 19:53:39 +0200 Subject: [PATCH 020/129] B1: dedupe CJK/symbols merge via AddCjkAndSymbols, trim fallback range; FontsReady + report self-test --- HellionChat/FontManager.cs | 159 +++++++++++------- .../SelfTests/FontManagerCtorSmokeStep.cs | 43 +++++ 2 files changed, 141 insertions(+), 61 deletions(-) diff --git a/HellionChat/FontManager.cs b/HellionChat/FontManager.cs index 8b7fb6d..1a3202f 100644 --- a/HellionChat/FontManager.cs +++ b/HellionChat/FontManager.cs @@ -61,6 +61,17 @@ public sealed class FontManager : IDisposable private ushort[] Ranges = []; private ushort[] JpRange = []; + // B1: trimmed remainder the NotoSansCjk fallback is the sole source for + // (Hangul + Simplified-Han); excludes the Default/Latin block already merged + // by the global font, so the fallback no longer re-merges the full Ranges array. + private ushort[] CjkFallbackGlyphRange = []; + + // Report accessor for the ctor self-test: built glyph-range array lengths so + // the step can show the B1 dedup effect (a small trimmed fallback vs the large + // primary range) in its on-disk report instead of a bare Pass. + internal (int Ranges, int JpRange, int CjkFallback) GlyphRangeLengths => + (Ranges.Length, JpRange.Length, CjkFallbackGlyphRange.Length); + public static readonly HashSet AxisFontSizeList = [ 9.6f, @@ -170,6 +181,42 @@ public sealed class FontManager : IDisposable // Instance method so Ranges / JpRange are reachable without parameter // plumbing; PascalCase field names follow the existing class style. + // B1: shared CJK + symbols tail for both the regular and italic delegate + // fonts. Earlier-merged fonts win for shared codepoints (imgui MergeMode), + // so this runs AFTER the primary font is set as config.MergeFont. The CJK + // fallback is the sole Hangul/Simplified-Han source when UseHellionFont=true + // (global=Inter-Light), so it stays in the chain — only its glyph range is + // trimmed (CjkFallbackGlyphRange) to drop the Default-block/endonym overlap. + // basePt sizes the fallback to match the primary font; the Japanese merge + // keeps its own configured size and the full JpRange (which owns Traditional + // Han such as 體 U+9AD4), so japanese↔fallback no longer overlap. + private void AddCjkAndSymbols( + IFontAtlasBuildToolkitPreBuild tk, + SafeFontConfig config, + float basePt + ) + { + config.SizePt = Plugin.Config.JapaneseFontV2.SizePt; + config.GlyphRanges = JpRange; + AddFontWithFallback(tk, Plugin.Config.JapaneseFontV2.FontId, config, "japanese"); + + // v1.5.3: NotoSansCjk fallback covers Hangul, Simplified-Chinese-specific + // Han (e.g. 简) and other CJK glyphs the primary (Inter Light / global font) + // and the FFXIV Japanese font do not ship. B1: trimmed to CjkFallbackGlyphRange + // so it no longer re-merges the Default block. Merged last so earlier fonts win. + config.SizePt = basePt; + config.GlyphRanges = CjkFallbackGlyphRange; + AddFontWithFallback( + tk, + new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular), + config, + "noto-cjk-fallback" + ); + + config.SizePt = ResolveSymbolsFontPt(); + tk.AddGameSymbol(config); + } + private IFontHandle BuildRegularFontHandle(IFontAtlas atlas) => atlas.NewDelegateFontHandle(e => e.OnPreBuild(tk => @@ -183,25 +230,7 @@ public sealed class FontManager : IDisposable ? tk.AddFontFromMemory(bundledBytes, config, "Inter-Light") : AddFontWithFallback(tk, Plugin.Config.GlobalFontV2.FontId, config, "global"); - config.SizePt = Plugin.Config.JapaneseFontV2.SizePt; - config.GlyphRanges = JpRange; - AddFontWithFallback(tk, Plugin.Config.JapaneseFontV2.FontId, config, "japanese"); - - // v1.5.3: NotoSansCjk fallback covers Hangul, Simplified-Chinese - // -specific Han (e.g. 简) and other CJK glyphs that the primary - // (Inter Light / global font) and the FFXIV Japanese font do not - // ship. Merged last so earlier fonts win for shared codepoints. - config.SizePt = basePt; - config.GlyphRanges = Ranges; - AddFontWithFallback( - tk, - new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular), - config, - "noto-cjk-fallback" - ); - - config.SizePt = ResolveSymbolsFontPt(); - tk.AddGameSymbol(config); + AddCjkAndSymbols(tk, config, basePt); tk.Font = config.MergeFont; }) @@ -223,22 +252,7 @@ public sealed class FontManager : IDisposable "italic" ); - config.SizePt = Plugin.Config.JapaneseFontV2.SizePt; - config.GlyphRanges = JpRange; - AddFontWithFallback(tk, Plugin.Config.JapaneseFontV2.FontId, config, "japanese"); - - // v1.5.3: NotoSansCjk fallback (see BuildRegularFontHandle). - config.SizePt = Plugin.Config.ItalicFontV2.SizePt; - config.GlyphRanges = Ranges; - AddFontWithFallback( - tk, - new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular), - config, - "noto-cjk-fallback" - ); - - config.SizePt = ResolveSymbolsFontPt(); - tk.AddGameSymbol(config); + AddCjkAndSymbols(tk, config, Plugin.Config.ItalicFontV2.SizePt); tk.Font = config.MergeFont; }) @@ -282,7 +296,11 @@ public sealed class FontManager : IDisposable private unsafe void SetUpRanges() { - ushort[] BuildRange(IReadOnlyList? chars, params nint[] ranges) + ushort[] BuildRange( + IReadOnlyList? chars, + bool includeCommonExtras, + params nint[] ranges + ) { var builder = new ImFontGlyphRangesBuilderPtr(ImGuiNative.ImFontGlyphRangesBuilder()); foreach (var range in ranges) @@ -300,33 +318,43 @@ public sealed class FontManager : IDisposable } } - // Ingame supported ranges - var reader = new FdtReader(Plugin.DataManager.GetFile("common/font/axis_12.fdt")!.Data); - foreach (var c in reader.Glyphs) - builder.AddChar(c.Char); + // Common extras (Axis ingame glyphs, endonyms, enclosed alphanumerics) + // belong to the primary/Japanese ranges only. The trimmed CJK fallback + // (B1) skips them so it stays a pure Hangul/Simplified-Han remainder and + // does not re-merge the Default-block work the global font already did. + if (includeCommonExtras) + { + // Ingame supported ranges + var reader = new FdtReader( + Plugin.DataManager.GetFile("common/font/axis_12.fdt")!.Data + ); + foreach (var c in reader.Glyphs) + builder.AddChar(c.Char); - // French - // Romanian - builder.AddText("Œœ"); - builder.AddText("ĂăÂâÎîȘșȚț"); + // French + // Romanian + builder.AddText("Œœ"); + builder.AddText("ĂăÂâÎîȘșȚț"); - // v1.5.3: language-dropdown endonyms. The dropdown renders - // with the currently active font range; without these glyphs - // a user on an English UI cannot read non-Latin language names - // before switching. Auto-activation in Settings.Apply then - // pulls in the full ExtraGlyphRange for the chosen locale. - builder.AddText( - "Català Čeština Dansk Deutsch Ελληνικά English Español Suomi" - + " Français Magyar Italiano 日本語 한국어 Norsk bokmål Nederlands" - + " Polski Português Brasil (Portugal) Română Русский Svenska" - + " Türkçe Українська 简体中文 繁體中文" - ); + // v1.5.3: language-dropdown endonyms. The dropdown renders + // with the currently active font range; without these glyphs + // a user on an English UI cannot read non-Latin language names + // before switching. Auto-activation in Settings.Apply then + // pulls in the full ExtraGlyphRange for the chosen locale. + builder.AddText( + "Català Čeština Dansk Deutsch Ελληνικά English Español Suomi" + + " Français Magyar Italiano 日本語 한국어 Norsk bokmål Nederlands" + + " Polski Português Brasil (Portugal) Română Русский Svenska" + + " Türkçe Українська 简体中文 繁體中文" + ); - // "Enclosed Alphanumerics" (partial) https://www.compart.com/en/unicode/block/U+2460 - for (var i = 0x2460; i <= 0x24B5; i++) - builder.AddChar((char)i); + // "Enclosed Alphanumerics" (partial) https://www.compart.com/en/unicode/block/U+2460 + for (var i = 0x2460; i <= 0x24B5; i++) + builder.AddChar((char)i); + + builder.AddChar('⓪'); + } - builder.AddChar('⓪'); return builder.BuildRangesToArray(); } @@ -356,8 +384,17 @@ public sealed class FontManager : IDisposable } } - Ranges = BuildRange(customChars.Count > 0 ? customChars : null, ranges.ToArray()); - JpRange = BuildRange(GlyphRangesJapanese.GlyphRanges); + Ranges = BuildRange( + customChars.Count > 0 ? customChars : null, + includeCommonExtras: true, + ranges.ToArray() + ); + JpRange = BuildRange(GlyphRangesJapanese.GlyphRanges, includeCommonExtras: true); + + // B1: the fallback gets only the trimmed Hangul/Simplified-Han remainder. + // No Default block, no endonyms — those are already merged by the global and + // Japanese fonts, so re-merging them on the fallback was wasted atlas work. + CjkFallbackGlyphRange = BuildRange(CjkFallbackRange.Pairs, includeCommonExtras: false); } // Add font with fallback to NotoSansCjkRegular if unavailable diff --git a/HellionChat/SelfTests/FontManagerCtorSmokeStep.cs b/HellionChat/SelfTests/FontManagerCtorSmokeStep.cs index 1aaf015..c0e8f8c 100644 --- a/HellionChat/SelfTests/FontManagerCtorSmokeStep.cs +++ b/HellionChat/SelfTests/FontManagerCtorSmokeStep.cs @@ -86,6 +86,49 @@ internal sealed class FontManagerCtorSmokeStep : ISelfTestStep return SelfTestStepResult.Fail; } + // B1: assert the atlas actually finished building all required handles, + // not just that the references are non-null. FontsReady is the observable + // state the trimmed-fallback rebuild must still reach; a half-built atlas + // would pass the null/exception checks above but fail here. + if (!fm.FontsReady) + { + ImGui.Text("FontManager.FontsReady is false (atlas not fully built)."); + SelfTestReport.Append( + Name, + "FAIL", + new[] { "FontsReady is false — atlas not fully built." } + ); + return SelfTestStepResult.Fail; + } + + // Report what was actually verified (Flo's request: don't just show Pass). + // The glyph-range entry counts make the B1 dedup visible — the cjk-fallback + // range is now a small trimmed remainder next to the large primary range. + var counts = fm.GlyphRangeLengths; + var italicState = + fm.ItalicFont is null ? "disabled (null)" + : fm.ItalicFont.Available ? "available" + : "NOT available"; + var path = SelfTestReport.Append( + Name, + "PASS", + new[] + { + $"Axis available: {fm.Axis.Available}", + $"AxisItalic available: {fm.AxisItalic.Available}", + $"FontAwesome available: {fm.FontAwesome.Available}", + $"RegularFont available: {fm.RegularFont.Available}", + $"ItalicFont: {italicState}", + $"FontsReady: {fm.FontsReady}", + $"Glyph-range entries: primary={counts.Ranges}, jp={counts.JpRange}, " + + $"cjk-fallback={counts.CjkFallback} (B1 trimmed)", + $"UseHellionFont={Plugin.Config.UseHellionFont}, ItalicEnabled={Plugin.Config.ItalicEnabled}", + } + ); + ImGui.Text( + $"PASS — FontsReady, ranges primary={counts.Ranges}/jp={counts.JpRange}/" + + $"cjk-fallback={counts.CjkFallback}. Report: {path}" + ); return SelfTestStepResult.Pass; } From 1d69d0cc30144cd65736d126bbdfe029d8beefda Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 19:58:02 +0200 Subject: [PATCH 021/129] B2: add Dalamud-free CardClipPlanner variable-height clip-plan helper --- HellionChat/Ui/Components/CardClipPlanner.cs | 74 ++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 HellionChat/Ui/Components/CardClipPlanner.cs diff --git a/HellionChat/Ui/Components/CardClipPlanner.cs b/HellionChat/Ui/Components/CardClipPlanner.cs new file mode 100644 index 0000000..11d744f --- /dev/null +++ b/HellionChat/Ui/Components/CardClipPlanner.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; + +namespace HellionChat.Ui.Components; + +// B2 (PERF-B2): variable-height clip plan for card mode. ImGuiListClipper needs +// a constant row height, so card mode (rows wrap to arbitrary heights) computes +// its own plan from the cached per-row heights: a lead dummy for the rows above +// the viewport, the [first..last] index range that overlaps the viewport, and +// an end dummy for the rows below. This is the prefix-sum analogue of +// OtterGui's GetNecessarySkips, but for non-uniform heights — kept Dalamud-free +// so the Build Suite pins every edge case. +// TEST-MIRROR: ../../../../Hellion Build test/Ui/CardClipPlanTests.cs +internal readonly record struct CardClipPlan( + int FirstVisible, + int LastVisible, + float LeadDummyHeight, + float EndDummyHeight +); + +internal static class CardClipPlanner +{ + // heights[i] is the cached, spacing-inclusive height of row i in draw order. + // A row overlaps the viewport iff rowTop < windowBottom && rowBottom > + // windowTop. FirstVisible/LastVisible are -1 when nothing overlaps (empty + // list); callers then submit a single end dummy of the full content height + // so the scrollbar stays correct. + internal static CardClipPlan Plan( + IReadOnlyList heights, + float scrollY, + float viewportHeight + ) + { + var count = heights.Count; + if (count == 0) + return new CardClipPlan(-1, -1, 0f, 0f); + + var windowTop = scrollY; + var windowBottom = scrollY + viewportHeight; + + var first = -1; + var last = -1; + var leadDummy = 0f; + var endDummy = 0f; + + var cursor = 0f; // running prefix sum = top edge of the current row + for (var i = 0; i < count; i++) + { + var rowTop = cursor; + var rowBottom = cursor + heights[i]; + var overlaps = rowTop < windowBottom && rowBottom > windowTop; + + if (overlaps) + { + if (first < 0) + first = i; + last = i; + } + else if (first < 0) + { + // Still above the visible window -> grows the lead dummy. + leadDummy += heights[i]; + } + else + { + // Already past the visible window -> grows the end dummy. + endDummy += heights[i]; + } + + cursor = rowBottom; + } + + return new CardClipPlan(first, last, leadDummy, endDummy); + } +} From 93f4fbba72d0497d4a738cee510cac2670e882a1 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 20:09:45 +0200 Subject: [PATCH 022/129] perf(card): variable-height clipper + layout-fingerprint cache invalidation + clip-plan self-test --- HellionChat/Plugin.cs | 1 + HellionChat/SelfTests/CardClipPlanStep.cs | 99 ++++++++++++++ HellionChat/Ui/Components/MessageList.cs | 156 ++++++++++++++++++---- 3 files changed, 233 insertions(+), 23 deletions(-) create mode 100644 HellionChat/SelfTests/CardClipPlanStep.cs diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index b761245..22deb6e 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -426,6 +426,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.SidebarUnreadDotStep(this), new SelfTests.UnreadDecisionStep(), new SelfTests.CurrentTabGuidedStep(this), + new SelfTests.CardClipPlanStep(this), ]); // Re-surface the wizard for existing users when a major UX diff --git a/HellionChat/SelfTests/CardClipPlanStep.cs b/HellionChat/SelfTests/CardClipPlanStep.cs new file mode 100644 index 0000000..9442e6e --- /dev/null +++ b/HellionChat/SelfTests/CardClipPlanStep.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; + +namespace HellionChat.SelfTests; + +// B2: behavioural check that the card path feeds CardClipPlanner AND that a +// layout change clears the height cache — not a non-null check. Pure plan math +// is pinned headless by CardClipPlanTests; this drives the live accessors. +internal sealed class CardClipPlanStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public CardClipPlanStep(Plugin plugin) => this.plugin = plugin; + + public string Name => "Hellion Chat - Card clip plan + cache invalidation"; + + public SelfTestStepResult RunStep() + { + var messages = plugin.MainWindow.GetMessageListForSelfTest(); + if (messages is null) + { + ImGui.Text("MessageList null"); + SelfTestReport.Append(Name, "FAIL", new[] { "MessageList null" }); + return SelfTestStepResult.Fail; + } + + // 5 rows of 20, viewport 50, scroll 45 -> skip rows 0,1 (lead 40), see 2..4. + IReadOnlyList heights = [20f, 20f, 20f, 20f, 20f]; + var plan = messages.PlanCardClipForSelfTest(heights, scrollY: 45f, viewportHeight: 50f); + if ( + plan.FirstVisible != 2 + || plan.LastVisible != 4 + || plan.LeadDummyHeight is < 39.9f or > 40.1f + ) + { + var msg = + $"Plan wrong: first={plan.FirstVisible} last={plan.LastVisible} lead={plan.LeadDummyHeight}"; + ImGui.Text(msg); + SelfTestReport.Append(Name, "FAIL", new[] { msg }); + return SelfTestStepResult.Fail; + } + + var tab = plugin.MainWindow.ActiveTab; + if (tab is null) + { + ImGui.Text("No active tab"); + SelfTestReport.Append(Name, "FAIL", new[] { "No active tab" }); + return SelfTestStepResult.Fail; + } + + // Flip a height-affecting mode -> fingerprint changes -> cache must drop. + // Config restored in finally (live-singleton discipline). + var savedForm = Plugin.Config.NameFormMode; + int remaining; + try + { + messages.RunHeightCacheInvalidationForSelfTest(tab, 400f); + using (var snap = tab.Messages.GetReadOnly(3)) + { + if (snap.Count > 0) + snap[0].Height[tab.Identifier] = 42f; + } + + Plugin.Config.NameFormMode = + savedForm == NameFormMode.Full ? NameFormMode.Initials : NameFormMode.Full; + remaining = messages.RunHeightCacheInvalidationForSelfTest(tab, 400f); + } + finally + { + Plugin.Config.NameFormMode = savedForm; + messages.RunHeightCacheInvalidationForSelfTest(tab, 400f); + } + + if (remaining != 0) + { + var msg = $"Cache not cleared after layout change: {remaining} left"; + ImGui.Text(msg); + SelfTestReport.Append(Name, "FAIL", new[] { msg }); + return SelfTestStepResult.Fail; + } + + SelfTestReport.Append( + Name, + "PASS", + new[] + { + $"Plan range [{plan.FirstVisible}..{plan.LastVisible}], lead={plan.LeadDummyHeight}", + "Height cache cleared after layout-fingerprint change", + } + ); + ImGui.Text( + $"PASS — plan [{plan.FirstVisible}..{plan.LastVisible}], cache cleared on layout change." + ); + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs index a9c43ea..80e55c7 100644 --- a/HellionChat/Ui/Components/MessageList.cs +++ b/HellionChat/Ui/Components/MessageList.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Linq; using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Interface.Utility; @@ -7,11 +8,10 @@ using HellionChat.Util; namespace HellionChat.Ui.Components; -// Virtualised message list. Compact mode reuses ImGuiListClipper because -// rows have a constant line height; card mode falls back to a linear -// render with a per-message height cache and an IsItemVisible skip path -// so off-screen rows place a Dummy of the cached height rather than -// running the full render again. +// Virtualised message list. Compact mode uses ImGuiListClipper (constant row +// height); card mode rows wrap to arbitrary heights, so it runs its own +// prefix-sum clipper (CardClipPlanner) over a per-message height cache, dropped +// whenever the layout fingerprint (font/density/name-mode/width) changes. internal sealed class MessageList { private const float CompactRowHeight = 18f; @@ -27,6 +27,18 @@ internal sealed class MessageList private bool _scrolledUp; private bool _scrollToBottomRequested; + // B2: the height cache is only valid while these inputs are unchanged. + // FontManager's own fingerprint covers font sizes only, not density / the two + // name-display modes / width — a stale height would misplace the clipper dummies. + private ( + float Global, + float Symbols, + bool Compact, + int NameForm, + int WorldSuffix, + float Width + ) _lastLayoutFingerprint; + // §6.2: setter-injection breaks the PayloadHandler → MainWindow → MessageList → PayloadHandler 3-cycle. // Wired by PayloadHandlerInitHostedService.StartAsync after both singletons exist. internal void AttachPayloadHandler(PayloadHandler handler) @@ -54,6 +66,55 @@ internal sealed class MessageList // post-snap reset can be asserted; without it only the OR branch is testable. internal void RequestScrollToBottomForSelfTest() => _scrollToBottomRequested = true; + // SelfTest hook (B2): runs the real planner against a caller fixture so the + // step asserts the plan without a live scroll child (GetScrollY is garbage headless). + internal CardClipPlan PlanCardClipForSelfTest( + IReadOnlyList heights, + float scrollY, + float viewportHeight + ) => CardClipPlanner.Plan(heights, scrollY, viewportHeight); + + // SelfTest hook (B2): drives the live invalidation, returns the tab's remaining + // cached-height count so the step can assert the drop. + internal int RunHeightCacheInvalidationForSelfTest(Tab tab, float contentWidth) + { + InvalidateHeightCacheIfLayoutChanged(tab, contentWidth); + using var messages = tab.Messages.GetReadOnly(3); + return messages.Count(m => m.Height.ContainsKey(tab.Identifier)); + } + + // Width is passed in (ContentRegionAvail is only valid inside the draw child); + // enum modes widened to int so the tuple stays comparable. + private (float, float, bool, int, int, float) BuildLayoutFingerprint(float contentWidth) + { + var (global, symbols) = _fonts.EffectiveFontFingerprint(); + return ( + global, + symbols, + Plugin.Config.UseCompactDensity, + (int)Plugin.Config.NameFormMode, + (int)Plugin.Config.WorldSuffixMode, + contentWidth + ); + } + + // Drop the tab's cached heights when the layout fingerprint changed — one + // tuple compare per frame, a clear only on a real settings/resize change. + private void InvalidateHeightCacheIfLayoutChanged(Tab tab, float contentWidth) + { + var fingerprint = BuildLayoutFingerprint(contentWidth); + if (fingerprint.Equals(_lastLayoutFingerprint)) + return; + + _lastLayoutFingerprint = fingerprint; + using var messages = tab.Messages.GetReadOnly(3); + foreach (var msg in messages) + { + msg.Height.Remove(tab.Identifier); + msg.IsVisible.Remove(tab.Identifier); + } + } + public void Draw(Tab tab) { if (!_fonts.FontsReady) @@ -65,9 +126,15 @@ internal sealed class MessageList // No own ImRaii.Child here — MainWindow already wraps the message // area in one. Nesting would give the window two stacked scrolls // and a runaway content-height computation. - using var messages = tab.Messages.GetReadOnly(3); var compact = Plugin.Config.UseCompactDensity; + // B2: drop stale cached heights before the snapshot draw (card path only — + // compact rows are constant height). Width read here while it is valid. + if (!compact) + InvalidateHeightCacheIfLayoutChanged(tab, ImGui.GetContentRegionAvail().X); + + using var messages = tab.Messages.GetReadOnly(3); + // Track whether the user was pinned to the bottom before this frame // so newly arriving rows do not yank them up. The check runs against // the parent child's scroll state, which is the one MainWindow owns. @@ -192,29 +259,72 @@ internal sealed class MessageList private void DrawCard(Tab tab, IReadOnlyList messages) { var tabId = tab.Identifier; - for (var i = 0; i < messages.Count; i++) + var count = messages.Count; + if (count == 0) + return; + + // A row with no cached height yet can't be planned (first frame / post- + // invalidation), so draw everything once to fill the cache, plan next frame. + var heights = new float[count]; + var allCached = true; + for (var i = 0; i < count; i++) + { + if (messages[i].Height.TryGetValue(tabId, out var cached) && cached is float h) + heights[i] = h; + else + allCached = false; + } + + if (!allCached) + { + DrawCardLinearAndMeasure(tabId, messages); + return; + } + + var plan = CardClipPlanner.Plan(heights, ImGui.GetScrollY(), ImGui.GetWindowSize().Y); + + // Cached heights already include one trailing ItemSpacing.y; a dummy adds its + // own, which would shove the first visible row down one spacing (jitter). + // Subtract one spacing per dummy so offset AND total content height stay exact. + var spacingY = ImGui.GetStyle().ItemSpacing.Y; + float CompensatedDummy(float planned) => Math.Max(0f, planned - spacingY); + + if (plan.FirstVisible < 0) + { + // Scrolled into a gap: one full-height dummy keeps the scrollbar honest. + var gap = CompensatedDummy(plan.LeadDummyHeight + plan.EndDummyHeight); + ImGui.Dummy(new Vector2(10f, gap)); + return; + } + + if (plan.LeadDummyHeight > 0f) + ImGui.Dummy(new Vector2(10f, CompensatedDummy(plan.LeadDummyHeight))); + + for (var i = plan.FirstVisible; i <= plan.LastVisible; i++) { var msg = messages[i]; - - // Cached row: place a Dummy of the known height and skip the - // full render path if the row is off-screen. Mirrors the - // v1.5.6 Card-Mode pattern in ChatLogWindow.DrawMessages. - msg.Height.TryGetValue(tabId, out var cachedHeight); - if (cachedHeight is float h) - { - var beforeDummy = ImGui.GetCursorPos(); - ImGui.Dummy(new Vector2(10f, h)); - var visible = ImGui.IsItemVisible(); - msg.IsVisible[tabId] = visible; - if (!visible) - continue; - ImGui.SetCursorPos(beforeDummy); - } - var before = ImGui.GetCursorPosY(); DrawCardRow(msg); var after = ImGui.GetCursorPosY(); msg.Height[tabId] = after - before; + msg.IsVisible[tabId] = true; + } + + if (plan.EndDummyHeight > 0f) + ImGui.Dummy(new Vector2(10f, CompensatedDummy(plan.EndDummyHeight))); + } + + // First-frame / post-invalidation fallback: draw + measure every row into the + // cache so the next frame can take the planned path. + private void DrawCardLinearAndMeasure(Guid tabId, IReadOnlyList messages) + { + foreach (var msg in messages) + { + var before = ImGui.GetCursorPosY(); + DrawCardRow(msg); + var after = ImGui.GetCursorPosY(); + msg.Height[tabId] = after - before; + msg.IsVisible[tabId] = ImGui.IsItemVisible(); } } From 618e029ff49fbbfd1dc45f515779d74d53af30a9 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 20:39:56 +0200 Subject: [PATCH 023/129] perf(tabs): share Plugin.TabsListLock across AutoTellTabsService + MessageManager refilter + SaveConfig (B3) --- HellionChat/AutoTellTabsService.cs | 76 +++++++++++++++++------------- HellionChat/MessageManager.cs | 50 +++++++++++++++----- HellionChat/Plugin.cs | 31 ++++++++---- 3 files changed, 103 insertions(+), 54 deletions(-) diff --git a/HellionChat/AutoTellTabsService.cs b/HellionChat/AutoTellTabsService.cs index f66eed5..8e3e8e3 100644 --- a/HellionChat/AutoTellTabsService.cs +++ b/HellionChat/AutoTellTabsService.cs @@ -21,7 +21,10 @@ internal sealed class AutoTellTabsService : IDisposable private readonly MessageManager _messageManager; private readonly MessageStore _store; private readonly ILogger _logger; - private readonly object _tempTabsLock = new(); + + // Tabs-list structure lock now lives on Plugin (neutral owner) so the + // MessageManager refilter can share it. See Plugin.TabsListLock / B3. + private object TabsListLock => _plugin.TabsListLock; // Hard cap on pinned TempTabs so the sidebar doesn't inflate over years // of usage. Separate pool from AutoTellTabsLimit (15) — pinned tabs live @@ -147,7 +150,7 @@ internal sealed class AutoTellTabsService : IDisposable return; } - lock (_tempTabsLock) + lock (TabsListLock) { var existing = FindTempTab(partner.Value.Name, partner.Value.World); if (existing != null) @@ -240,46 +243,51 @@ internal sealed class AutoTellTabsService : IDisposable } // Lock-protected lookup for the framework-thread caller (TellRouterService). - // Config.Tabs is mutated under _tempTabsLock on the PendingMessage worker thread, + // Config.Tabs is mutated under the shared Plugin.TabsListLock on the worker thread, // so a framework-tick reader must take the same lock to avoid enumerating the list // mid-mutation. internal Tab? FindTempTabSafe(string name, uint world) { - lock (_tempTabsLock) + lock (TabsListLock) return FindTempTab(name, world); } internal void DropOldestTempTab() { - // Pinned tabs live in their own bucket (MaxPinnedTempTabs) and are - // never drop candidates. They leave the bucket only via Unpin or - // PromoteToPermanent. - var victim = Plugin - .Config.Tabs.Select((tab, idx) => (Tab: tab, Index: idx)) - .Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t.Tab)) - .OrderByDescending(t => t.Tab.IsGreeted) - .ThenBy(t => t.Tab.LastActivity) - .FirstOrDefault(); - - if (victim.Tab == null) + // B3: lock the list-structure ops so the (currently caller-less) Unpin path + // can't race the worker; re-entrant when HandleTell already holds the lock. + lock (TabsListLock) { - return; + // Pinned tabs live in their own bucket (MaxPinnedTempTabs) and are + // never drop candidates. They leave the bucket only via Unpin or + // PromoteToPermanent. + var victim = Plugin + .Config.Tabs.Select((tab, idx) => (Tab: tab, Index: idx)) + .Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t.Tab)) + .OrderByDescending(t => t.Tab.IsGreeted) + .ThenBy(t => t.Tab.LastActivity) + .FirstOrDefault(); + + if (victim.Tab == null) + { + return; + } + + var dropped = victim.Tab; + Plugin.Config.Tabs.RemoveAt(victim.Index); + + // Re-anchor the UI selection if it pointed at the dropped tab, and close any + // pop-out window the dropped tab owned. Both run on the PendingMessage worker + // thread and touch window state the Draw path reads (OnTabActivated re-seed + + // the pool's Unbind), so marshal onto the framework thread to serialize with + // Draw (reference_dalamud_framework_thread). TryClose is idempotent: a tab that + // was never popped is a silent no-op. + Plugin.Framework.RunOnFrameworkThread(() => + { + _plugin.ChannelPopoutPool.TryClose(dropped.Identifier); + _plugin.MainWindow?.ResetActiveTabIfRemoved(dropped); + }); } - - var dropped = victim.Tab; - Plugin.Config.Tabs.RemoveAt(victim.Index); - - // Re-anchor the UI selection if it pointed at the dropped tab, and close any - // pop-out window the dropped tab owned. Both run on the PendingMessage worker - // thread and touch window state the Draw path reads (OnTabActivated re-seed + - // the pool's Unbind), so marshal onto the framework thread to serialize with - // Draw (reference_dalamud_framework_thread). TryClose is idempotent: a tab that - // was never popped is a silent no-op. - Plugin.Framework.RunOnFrameworkThread(() => - { - _plugin.ChannelPopoutPool.TryClose(dropped.Identifier); - _plugin.MainWindow?.ResetActiveTabIfRemoved(dropped); - }); } private void SpawnTempTab((string Name, uint World) partner, Message currentMessage) @@ -302,7 +310,7 @@ internal sealed class AutoTellTabsService : IDisposable // Actually open the pop-out window for the flagged tab — without this the // flag was dead (a PopOut tab with no window). SpawnTempTab runs on the - // PendingMessage worker thread under _tempTabsLock; TryOpen does + // PendingMessage worker thread under Plugin.TabsListLock; TryOpen does // OnTabActivated + Bind (window state Draw reads), so marshal onto the // framework thread. If the pool is full, drop the flag so it never claims a // window it didn't get (flag/window parity). @@ -428,7 +436,7 @@ internal sealed class AutoTellTabsService : IDisposable return; } - lock (_tempTabsLock) + lock (TabsListLock) { // Guard against frame-race: sidebar might render a tab already removed by LRU or logout if (!Plugin.Config.Tabs.Contains(tab)) @@ -442,7 +450,7 @@ internal sealed class AutoTellTabsService : IDisposable private void OnLogout(int type, int code) { - lock (_tempTabsLock) + lock (TabsListLock) { // Pinned TempTabs must survive char-switch — that's the whole point // of pinning. Only unpinned ones get stripped. diff --git a/HellionChat/MessageManager.cs b/HellionChat/MessageManager.cs index 2acf577..dcb10a2 100644 --- a/HellionChat/MessageManager.cs +++ b/HellionChat/MessageManager.cs @@ -163,8 +163,15 @@ internal class MessageManager : IAsyncDisposable internal void ClearAllTabs() { + // B3: snapshot the tab LIST under the shared lock so the worker-thread + // add/remove can't tear the enumeration; tab.Clear() then runs lock-free + // (each tab's Messages has its own SemaphoreSlim — lock order: list outer). + List tabsSnapshot; + lock (Plugin.TabsListLock) + tabsSnapshot = Plugin.Config.Tabs.ToList(); + // TempTabs are session-only (not persisted); exclude them to preserve Tell history - foreach (var tab in Plugin.Config.Tabs.Where(t => !t.IsTempTab)) + foreach (var tab in tabsSnapshot.Where(t => !t.IsTempTab)) tab.Clear(); } @@ -176,18 +183,19 @@ internal class MessageManager : IAsyncDisposable using var messages = Store.GetMostRecentMessages(CurrentContentId, since); - // TempTabs are excluded; they maintain live state from AutoTellTabsService - var pendingTabs = Plugin - .Config.Tabs.Where(t => !t.IsTempTab) - .Select(tab => (tab, new List())) - .ToList(); - foreach (var message in messages) - foreach (var (_, pendingMessages) in pendingTabs.Where(ptab => ptab.Item1.Matches(message))) - pendingMessages.Add(message); + // TempTabs excluded (live state from AutoTellTabsService). Bucket via the + // pure MapMessagesToTabs so the assignment stays testable outside Dalamud (B3-1). + // B3: snapshot under the shared lock (list copy only — short critical + // section). The Store query above and the AddSortPrune writes below stay + // OUTSIDE the lock (lock order: list outer, MessageList inner). + List nonTempTabs; + lock (Plugin.TabsListLock) + nonTempTabs = Plugin.Config.Tabs.Where(t => !t.IsTempTab).ToList(); + var buckets = MapMessagesToTabs(nonTempTabs, messages); // Apply messages to chat log all at once. - foreach (var (tab, pendingMessages) in pendingTabs) - tab.Messages.AddSortPrune(pendingMessages, MessageDisplayLimit); + foreach (var tab in nonTempTabs) + tab.Messages.AddSortPrune(buckets[tab], MessageDisplayLimit); if (!messages.DidError) return; @@ -206,6 +214,26 @@ internal class MessageManager : IAsyncDisposable } } + // Pure message->tab bucketing for the refilter. Dalamud-free + static so the + // assignment can be unit-pinned in the build suite; the live caller owns the + // Store query, the snapshot and the SemaphoreSlim writes. + internal static Dictionary> MapMessagesToTabs( + IReadOnlyList tabs, + IEnumerable messages + ) + { + var buckets = new Dictionary>(tabs.Count); + foreach (var tab in tabs) + buckets[tab] = new List(); + + foreach (var message in messages) + foreach (var tab in tabs) + if (tab.Matches(message)) + buckets[tab].Add(message); + + return buckets; + } + internal void FilterAllTabsAsync() { Task.Run(() => diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 22deb6e..5f3dc27 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -189,6 +189,12 @@ public sealed class Plugin : IAsyncDalamudPlugin internal readonly object RetentionSweepLock = new(); internal volatile bool RetentionSweepRunning; + // B3: neutral owner of the Config.Tabs LIST-structure lock so both the + // worker-thread mutator (AutoTellTabsService) and the framework-thread + // refilter (MessageManager) share ONE monitor. Lock order: this outer, + // MessageList's SemaphoreSlim inner — never the reverse. + internal readonly object TabsListLock = new(); + internal DateTime GameStarted { get; } // Couples "current tab" to the real UI selection. The chat hooks are @@ -976,11 +982,10 @@ public sealed class Plugin : IAsyncDalamudPlugin { Log.Information($"Retention sweep deleted {deleted} expired messages."); // Schedule on the next framework tick to avoid the ~194ms - // hitch from blocking with .Wait() while the framework - // finishes the current frame. Tabs-list mutation must - // stay on the framework thread because Plugin.Config.Tabs - // (Configuration.cs:222) is not lock-protected and - // AutoTellTabsService can mutate it from background paths. + // hitch from blocking with .Wait() while the frame finishes. + // The Config.Tabs enumeration in ClearAllTabs/FilterAllTabs is + // now guarded by the shared Plugin.TabsListLock (B3), so this + // tick scheduling is purely hitch-avoidance, not safety. // Pattern reference: SimpleTweaks // Tweaks/Chat/CaseInsensitiveCommands.cs:45. Framework.RunOnTick(() => @@ -1091,12 +1096,20 @@ public sealed class Plugin : IAsyncDalamudPlugin // Config.Tabs across the save so JSON includes them. Cloning only the // unpinned subset keeps the allocation proportional to // AutoTellTabsLimit (<=15) instead of the full tab list. - var unpinnedTempTabs = Config.Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList(); - Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnSave); + // B3: the strip/restore mutates the tab LIST, so it shares TabsListLock + // with the worker add/remove and the refilter snapshot. Re-entrant: the + // one worker caller (HandleTell) already holds it; framework callers take + // it here. SavePluginConfig runs inside (short, in-memory) — the §8 fallback + // (serialize a copy outside the lock) is a tracked pre-beta to-do. + lock (TabsListLock) + { + var unpinnedTempTabs = Config.Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList(); + Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnSave); - Interface.SavePluginConfig(Config); + Interface.SavePluginConfig(Config); - Config.Tabs.AddRange(unpinnedTempTabs); + Config.Tabs.AddRange(unpinnedTempTabs); + } } internal void LanguageChanged(string langCode) From de9d11ba4a57b9b3bbdfa5c6366bcec06321eac4 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 20:59:09 +0200 Subject: [PATCH 024/129] =?UTF-8?q?docs:=20v1.9.0=20comment-pass=20(Z-3)?= =?UTF-8?q?=20=E2=80=94=20fix=20false=20TEST-MIRROR=20paths,=20comment=20a?= =?UTF-8?q?ccuracy=20+=20density?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HellionChat/CjkFallbackRange.cs | 9 ++------- HellionChat/FontManager.cs | 12 ++++-------- HellionChat/GameFunctions/Chat.cs | 6 +++--- HellionChat/SelfTests/GlobalStyleScopeAllocStep.cs | 4 +--- HellionChat/SelfTests/PerformanceBaselineStep.cs | 5 ++--- HellionChat/SelfTests/SelfTestReport.cs | 9 +++------ HellionChat/Ui/StyleEngine/DrawListExtensions.cs | 3 +-- HellionChat/Util/ColourUtil.cs | 1 + 8 files changed, 17 insertions(+), 32 deletions(-) diff --git a/HellionChat/CjkFallbackRange.cs b/HellionChat/CjkFallbackRange.cs index 55de13f..d7d16db 100644 --- a/HellionChat/CjkFallbackRange.cs +++ b/HellionChat/CjkFallbackRange.cs @@ -14,13 +14,8 @@ namespace HellionChat; // glyph-range builder (mirrors FontSizeResolver's split-for-test rationale). internal static class CjkFallbackRange { - // Hangul Syllables block + the FULL CJK Unified Ideographs (Han) block. The full - // Han window is REQUIRED, not optional: at UseHellionFont=true the global font is - // Inter-Light (no CJK), so this fallback is the SOLE source for Han + Hangul. The - // overlap with JpRange (kanji the Japanese font also ships) is harmless under ImGui - // MergeMode -- the earlier-merged Japanese font wins for shared codepoints, the - // fallback only fills holes (简 0x7B80, 中 0x4E2D, 国 0x56FD). The dedup win is - // NOT re-merging the ASCII/Latin Default block, which the global font already owns. + // Hangul Syllables + the full CJK Unified Ideographs (Han) block. Rationale: see + // class comment. Plain start/end pairs so it stays unit-testable. internal static readonly ushort[] Pairs = [ 0xAC00, diff --git a/HellionChat/FontManager.cs b/HellionChat/FontManager.cs index 1a3202f..a01764c 100644 --- a/HellionChat/FontManager.cs +++ b/HellionChat/FontManager.cs @@ -62,7 +62,7 @@ public sealed class FontManager : IDisposable private ushort[] JpRange = []; // B1: trimmed remainder the NotoSansCjk fallback is the sole source for - // (Hangul + Simplified-Han); excludes the Default/Latin block already merged + // (Hangul + full Han); excludes the Default/Latin block already merged // by the global font, so the fallback no longer re-merges the full Ranges array. private ushort[] CjkFallbackGlyphRange = []; @@ -187,9 +187,8 @@ public sealed class FontManager : IDisposable // fallback is the sole Hangul/Simplified-Han source when UseHellionFont=true // (global=Inter-Light), so it stays in the chain — only its glyph range is // trimmed (CjkFallbackGlyphRange) to drop the Default-block/endonym overlap. - // basePt sizes the fallback to match the primary font; the Japanese merge - // keeps its own configured size and the full JpRange (which owns Traditional - // Han such as 體 U+9AD4), so japanese↔fallback no longer overlap. + // The Japanese merge keeps its own configured size and the full JpRange (which + // owns Traditional Han such as 體 U+9AD4), so japanese↔fallback no longer overlap. private void AddCjkAndSymbols( IFontAtlasBuildToolkitPreBuild tk, SafeFontConfig config, @@ -200,10 +199,7 @@ public sealed class FontManager : IDisposable config.GlyphRanges = JpRange; AddFontWithFallback(tk, Plugin.Config.JapaneseFontV2.FontId, config, "japanese"); - // v1.5.3: NotoSansCjk fallback covers Hangul, Simplified-Chinese-specific - // Han (e.g. 简) and other CJK glyphs the primary (Inter Light / global font) - // and the FFXIV Japanese font do not ship. B1: trimmed to CjkFallbackGlyphRange - // so it no longer re-merges the Default block. Merged last so earlier fonts win. + // NotoSansCjk fallback, trimmed to CjkFallbackGlyphRange (B1). Merged last so earlier fonts win. config.SizePt = basePt; config.GlyphRanges = CjkFallbackGlyphRange; AddFontWithFallback( diff --git a/HellionChat/GameFunctions/Chat.cs b/HellionChat/GameFunctions/Chat.cs index 2546d40..ccb14f7 100755 --- a/HellionChat/GameFunctions/Chat.cs +++ b/HellionChat/GameFunctions/Chat.cs @@ -332,7 +332,7 @@ internal sealed unsafe class Chat : IDisposable } // Prefills + focuses our own input bar with a /tell command. The DI/Dalamud - // vorspann (Plugin.InputBar) lives here; the string assembly is BuildTellCommand. + // plumbing (Plugin.InputBar) lives here; the string assembly is BuildTellCommand. // The in-foray TellSpecial routing (SetEurekaTellChannel) is NOT this helper's // job — it stays at the call-site (v1.8.1 deferral). private void PrefillTellInput(string name, string? world) @@ -453,11 +453,11 @@ internal sealed unsafe class Chat : IDisposable Func validFn ) => RotateLinkshellIndex(currentIndex, rotate, validFn); - // Pure index-stepper (Dalamud-free): wrap (8 + i + delta) % 8 and return the + // Pure index-stepper (Dalamud-free): wrap (8 + currentIndex + delta) % 8 and return the // first index validFn accepts within 8 iterations, else null. Extracted so the // modulo/termination logic is unit-testable with a synthetic predicate; the // production caller binds validFn to InfoProxyLinkshell (in-game only). - // TEST-MIRROR: ../../Hellion Build test/_Helpers/RotateLinkshellIndexTests.cs + // TEST-MIRROR: ../../../Hellion Build test/_Helpers/RotateLinkshellIndexTests.cs internal static uint? RotateLinkshellIndex( uint currentIndex, RotateMode rotate, diff --git a/HellionChat/SelfTests/GlobalStyleScopeAllocStep.cs b/HellionChat/SelfTests/GlobalStyleScopeAllocStep.cs index 94fe490..5172223 100644 --- a/HellionChat/SelfTests/GlobalStyleScopeAllocStep.cs +++ b/HellionChat/SelfTests/GlobalStyleScopeAllocStep.cs @@ -6,9 +6,7 @@ using HellionChat.Ui.StyleEngine; namespace HellionChat.SelfTests; // GC-reserve probe for the v1.9.0 B4a refactor: GlobalStyleScope.Push runs -// once per draw frame, so its StackHandle must allocate nothing. Before the -// counter rewrite it boxed 44 ImRaii structs + a List per frame; after, it -// holds two ints and pushes straight onto the ImGui stack. This step drives a +// once per draw frame, so its StackHandle must allocate nothing. This step drives a // real Push()->Dispose() cycle and asserts the per-thread allocation delta is // ~0 (not a non-null-handle check — feedback_hellion_chat_fontmanager_push_trap). // A warm-up cycle pays the one-time JIT/first-touch cost so the measured cycle diff --git a/HellionChat/SelfTests/PerformanceBaselineStep.cs b/HellionChat/SelfTests/PerformanceBaselineStep.cs index aae0744..c5512ce 100644 --- a/HellionChat/SelfTests/PerformanceBaselineStep.cs +++ b/HellionChat/SelfTests/PerformanceBaselineStep.cs @@ -17,9 +17,8 @@ internal sealed class PerformanceBaselineStep : ISelfTestStep // average out GC blips without making the manual step tedious. private const int TargetFrames = 1000; - // Quad proxy: ImGui emits 4 vertices + 6 indices per quad, so vertices/6 - // approximates the draw-quad count. NOT the real ImDrawData command count — - // the <500/frame budget is checked approximately (API-3). + // Rough draw-call proxy: ImGui emits 6 indices per quad, so vertices/6 is an + // intentional under-count of draw work, not the exact quad count (API-3). private const int VerticesPerQuadProxy = 6; private readonly Plugin _plugin; diff --git a/HellionChat/SelfTests/SelfTestReport.cs b/HellionChat/SelfTests/SelfTestReport.cs index 07d7bc5..04c6fc6 100644 --- a/HellionChat/SelfTests/SelfTestReport.cs +++ b/HellionChat/SelfTests/SelfTestReport.cs @@ -6,12 +6,9 @@ using System.Text; namespace HellionChat.SelfTests; -// Shared report sink so manual self-test steps leave a readable trace on disk, -// not just a green Pass that flashes by for a single frame. Each call appends a -// timestamped block to selftest-report.log in the plugin ConfigDirectory; the -// human reads the tail after running the self-test runner. Same idea as the B5 -// perf-baseline.json (durable, copy-pasteable output) but as one shared append -// log so a full run leaves every reporting step's findings in one place. +// Shared report sink so manual self-test steps leave a readable trace on disk. Each +// call appends a timestamped block to selftest-report.log in the plugin ConfigDirectory; +// the human reads the tail after running the self-test runner. // Append (not atomic tmp+move) is fine: the runner is single-threaded on the // draw thread and a torn trailing line on a crash is acceptable for a debug log. internal static class SelfTestReport diff --git a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs index b19a7bb..dc47394 100644 --- a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs +++ b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs @@ -12,7 +12,6 @@ namespace HellionChat.Ui.StyleEngine; // ABGR before delegating to ImDrawList. Hover-sheen state lives in a small // static dictionary keyed by constant strings — keep keys constant and // scope to static UI elements so the per-key footprint stays bounded. -// TEST-MIRROR: Util/ColourUtilTintTests.cs internal static class DrawListExtensions { private const float SheenDurationSeconds = 0.65f; @@ -22,7 +21,7 @@ internal static class DrawListExtensions // highlight, not a saturated accent flash (effect level "subtle"). private const float SheenTintStrength = 0.35f; - // A2 tuning knob: peak alpha at t=0, fading linearly to 0 over the sweep. + // Peak sheen alpha (low so the highlight stays subtle); DrawHoverSheen applies the falloff. private const byte SheenPeakAlpha = 0x40; private static readonly Dictionary SheenStarts = new(); diff --git a/HellionChat/Util/ColourUtil.cs b/HellionChat/Util/ColourUtil.cs index 66c0c61..51a8305 100755 --- a/HellionChat/Util/ColourUtil.cs +++ b/HellionChat/Util/ColourUtil.cs @@ -107,6 +107,7 @@ internal static class ColourUtil // a low factor nudges the sweep toward the element's accent hue without // going fully saturated (effect level stays "subtle"). RGB-only on // purpose -- DrawHoverSheen owns the alpha falloff. + // TEST-MIRROR: ../../../Hellion Build test/Util/ColourUtilTintTests.cs internal static uint LerpTowardWhite(uint abgr, float t) { t = Math.Clamp(t, 0f, 1f); From d50f2cea901e64866df9782a31af2b486efda265 Mon Sep 17 00:00:00 2001 From: Jon Kazama <1+jonkazama-hellion@noreply.gitea.hellion-forge.cloud> Date: Sat, 15 Aug 2026 17:26:51 +0000 Subject: [PATCH 025/129] fix(ci): security-scan inline statt reusable workflow act_runner laedt reusable workflows per git-clone ueber HTTPS. Dieser Pfad ist auf der Forge seit 2026-08-12 defekt (git upload-pack --stateless-rpc bricht mit BUG "packfile_uris requires sideband-all" ab). Die Scans liefen bisher nur ueber eine im Juni gecachte Kopie im Runner. Der Scan steht jetzt vollstaendig in dieser Datei, der Quellstand kommt als tar-Archiv statt ueber actions/checkout. Zurueckbauen, sobald git fetch ueber HTTPS wieder funktioniert. --- .gitea/workflows/security.yml | 81 +++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/security.yml b/.gitea/workflows/security.yml index 0f484d0..2c95d47 100644 --- a/.gitea/workflows/security.yml +++ b/.gitea/workflows/security.yml @@ -1,4 +1,21 @@ name: Security + +# Self-contained security scan: Semgrep SAST + Trivy filesystem scan. +# +# Deliberately NOT calling the reusable workflow in security-workflows: +# act_runner fetches reusable workflows by cloning them over HTTPS, and git +# fetch over HTTPS is broken on this Gitea instance since 2026-08-12 +# (`git upload-pack --stateless-rpc` aborts with +# BUG("packfile_uris requires sideband-all") and dumps core). The runner only +# kept working because it still had a June copy in its action cache. Inlining +# removes that dependency entirely. +# +# For the same reason the sources are pulled as a tar archive instead of via +# actions/checkout. Restore the reusable call once the fetch path is fixed. +# +# Why one job, not two parallel jobs: act_runner v0.6.1 has a race when two +# jobs in the same task share a workspace and chown it in parallel. + on: push: branches: [main, master] @@ -7,14 +24,60 @@ on: - cron: '0 6 * * 1' workflow_dispatch: +env: + TRIVY_SEVERITY: 'CRITICAL,HIGH' + # MessageStore.cs interpoliert SQL-Strings, die plugin-lokal sicher sind; + # Semgrep matcht das Pattern, CodeQL mit Datenflussanalyse nicht. + SEMGREP_EXCLUDE_RULES: 'csharp.lang.security.sqli.csharp-sqli.csharp-sqli' + jobs: scan: - uses: JonKazama-Hellion/security-workflows/.gitea/workflows/security-scan.yml@main - with: - # MessageStore.cs uses string-interpolation in CommandText for table - # names and clause-joins that come from internal code constants, not - # user input. Values are bound via SqlParameter, the SQL surface is - # local-only inside a Dalamud plugin. Semgrep matches the pattern - # without dataflow, so it flags those eight call sites; CodeQL - # would not. Suppressed for this repo only. - semgrep-exclude-rules: 'csharp.lang.security.sqli.csharp-sqli.csharp-sqli' + name: Security Scan + runs-on: ubuntu-latest + steps: + - name: Checkout sources + env: + TOKEN: ${{ github.token }} + # On pull_request the merge SHA may not be archivable, use the head. + REF: ${{ github.event.pull_request.head.sha || github.sha }} + SERVER: ${{ github.server_url }} + REPO: ${{ github.repository }} + run: | + set -eu + echo "Fetching archive for $REPO @ $REF" + curl -sfL --retry 3 --retry-delay 5 \ + -H "Authorization: token $TOKEN" \ + "$SERVER/$REPO/archive/$REF.tar.gz" -o /tmp/source.tar.gz + tar xzf /tmp/source.tar.gz --strip-components=1 + rm -f /tmp/source.tar.gz + echo "Extracted $(find . -type f | wc -l) files" + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.x' + + - name: Install Semgrep + run: pip install --no-cache-dir semgrep + + - name: Install Trivy + # Version pinned so the install script does not hit api.github.com to + # resolve "latest", which burns the runner's unauthenticated rate limit. + # renovate: datasource=github-releases depName=aquasecurity/trivy + run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin v0.70.0 + + - name: Run Semgrep SAST + run: | + args="--config=auto --error --severity=ERROR" + if [ -n "$SEMGREP_EXCLUDE_RULES" ]; then + for rule in $(echo "$SEMGREP_EXCLUDE_RULES" | tr ',' ' '); do + args="$args --exclude-rule=$rule" + done + fi + echo "Running: semgrep scan $args" + semgrep scan $args + + - name: Run Trivy filesystem scan + # if: always() — surface Trivy findings even when Semgrep fails first. + if: always() + run: trivy fs --severity "$TRIVY_SEVERITY" --exit-code 1 --ignore-unfixed . From fd8e5a1a17ba52f4aee5c915791a7da061958123 Mon Sep 17 00:00:00 2001 From: Jon Kazama <1+jonkazama-hellion@noreply.gitea.hellion-forge.cloud> Date: Sat, 15 Aug 2026 21:17:43 +0000 Subject: [PATCH 026/129] revert(ci): security-scan wieder ueber den reusable workflow Rueckbau des Inline-Workarounds. Der Runner konnte den reusable workflow nicht mehr laden, weil git fetch gegen die Forge crashte. Ursache war kein Bug, sondern eine Kompromittierung ueber CVE-2026-59774: ein injizierter packObjectsHook ersetzte git pack-objects durch einen Malware-Dropper. Gitea laeuft jetzt auf 1.27.2, der Hook ist entfernt, fetch funktioniert wieder. Die Scan-Definition liegt damit wieder an einer Stelle statt in sieben Dateien. Details: Obsidian "Projekte/Hellion Forge/Forge Security-Incident 2026-08-15.md". --- .gitea/workflows/security.yml | 77 ++++------------------------------- 1 file changed, 7 insertions(+), 70 deletions(-) diff --git a/.gitea/workflows/security.yml b/.gitea/workflows/security.yml index 2c95d47..b37901a 100644 --- a/.gitea/workflows/security.yml +++ b/.gitea/workflows/security.yml @@ -1,20 +1,7 @@ name: Security -# Self-contained security scan: Semgrep SAST + Trivy filesystem scan. -# -# Deliberately NOT calling the reusable workflow in security-workflows: -# act_runner fetches reusable workflows by cloning them over HTTPS, and git -# fetch over HTTPS is broken on this Gitea instance since 2026-08-12 -# (`git upload-pack --stateless-rpc` aborts with -# BUG("packfile_uris requires sideband-all") and dumps core). The runner only -# kept working because it still had a June copy in its action cache. Inlining -# removes that dependency entirely. -# -# For the same reason the sources are pulled as a tar archive instead of via -# actions/checkout. Restore the reusable call once the fetch path is fixed. -# -# Why one job, not two parallel jobs: act_runner v0.6.1 has a race when two -# jobs in the same task share a workspace and chown it in parallel. +# Ruft den zentralen Scan-Workflow in security-workflows auf +# (Semgrep SAST + Trivy filesystem scan). on: push: @@ -24,60 +11,10 @@ on: - cron: '0 6 * * 1' workflow_dispatch: -env: - TRIVY_SEVERITY: 'CRITICAL,HIGH' - # MessageStore.cs interpoliert SQL-Strings, die plugin-lokal sicher sind; - # Semgrep matcht das Pattern, CodeQL mit Datenflussanalyse nicht. - SEMGREP_EXCLUDE_RULES: 'csharp.lang.security.sqli.csharp-sqli.csharp-sqli' - jobs: scan: - name: Security Scan - runs-on: ubuntu-latest - steps: - - name: Checkout sources - env: - TOKEN: ${{ github.token }} - # On pull_request the merge SHA may not be archivable, use the head. - REF: ${{ github.event.pull_request.head.sha || github.sha }} - SERVER: ${{ github.server_url }} - REPO: ${{ github.repository }} - run: | - set -eu - echo "Fetching archive for $REPO @ $REF" - curl -sfL --retry 3 --retry-delay 5 \ - -H "Authorization: token $TOKEN" \ - "$SERVER/$REPO/archive/$REF.tar.gz" -o /tmp/source.tar.gz - tar xzf /tmp/source.tar.gz --strip-components=1 - rm -f /tmp/source.tar.gz - echo "Extracted $(find . -type f | wc -l) files" - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.x' - - - name: Install Semgrep - run: pip install --no-cache-dir semgrep - - - name: Install Trivy - # Version pinned so the install script does not hit api.github.com to - # resolve "latest", which burns the runner's unauthenticated rate limit. - # renovate: datasource=github-releases depName=aquasecurity/trivy - run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin v0.70.0 - - - name: Run Semgrep SAST - run: | - args="--config=auto --error --severity=ERROR" - if [ -n "$SEMGREP_EXCLUDE_RULES" ]; then - for rule in $(echo "$SEMGREP_EXCLUDE_RULES" | tr ',' ' '); do - args="$args --exclude-rule=$rule" - done - fi - echo "Running: semgrep scan $args" - semgrep scan $args - - - name: Run Trivy filesystem scan - # if: always() — surface Trivy findings even when Semgrep fails first. - if: always() - run: trivy fs --severity "$TRIVY_SEVERITY" --exit-code 1 --ignore-unfixed . + uses: JonKazama-Hellion/security-workflows/.gitea/workflows/security-scan.yml@main + with: + # MessageStore.cs interpoliert SQL-Strings, die plugin-lokal sicher sind; + # Semgrep matcht das Pattern, CodeQL mit Datenflussanalyse nicht. + semgrep-exclude-rules: 'csharp.lang.security.sqli.csharp-sqli.csharp-sqli' From 99dca8cb31c99077564efbf0c1b1aaa53a68cbe9 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 06:49:42 +0200 Subject: [PATCH 027/129] fix(selftests): take TabsListLock around Config.Tabs mutations The three sidebar/coupling steps add, insert and remove tabs straight from the framework thread while the message worker mutates the same list under TabsListLock. CurrentTabCouplingStep's Insert(0, ...) is the worst of them: it shifts every index, so DropOldestTempTab can remove the wrong tab between its index lookup and RemoveAt. Locks sit around the individual mutations, never around a Draw call, so no step holds the lock across rendering. --- HellionChat/SelfTests/CurrentTabCouplingStep.cs | 8 ++++++-- HellionChat/SelfTests/SidebarGreetedGlyphStep.cs | 8 ++++++-- .../SelfTests/SidebarSectionHeaderStep.cs | 16 ++++++++++++---- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/HellionChat/SelfTests/CurrentTabCouplingStep.cs b/HellionChat/SelfTests/CurrentTabCouplingStep.cs index a6e3ddc..aa36c7e 100644 --- a/HellionChat/SelfTests/CurrentTabCouplingStep.cs +++ b/HellionChat/SelfTests/CurrentTabCouplingStep.cs @@ -69,7 +69,10 @@ internal sealed class CurrentTabCouplingStep : ISelfTestStep // Insert a victim at index 0: a regressed index-0 getter would return // THIS instead of ActiveTab, so ReferenceEquals would catch it. var victim = new Tab { Name = "selftest-coupling-victim" }; - Plugin.Config.Tabs.Insert(0, victim); + // Insert shifts every index; the worker's DropOldestTempTab holds + // TabsListLock across its index lookup and removal. + lock (_plugin.TabsListLock) + Plugin.Config.Tabs.Insert(0, victim); try { if (!ReferenceEquals(_plugin.CurrentTab, _plugin.MainWindow.ActiveTab)) @@ -97,7 +100,8 @@ internal sealed class CurrentTabCouplingStep : ISelfTestStep } finally { - Plugin.Config.Tabs.Remove(victim); + lock (_plugin.TabsListLock) + Plugin.Config.Tabs.Remove(victim); } } finally diff --git a/HellionChat/SelfTests/SidebarGreetedGlyphStep.cs b/HellionChat/SelfTests/SidebarGreetedGlyphStep.cs index 3687ee6..c87b875 100644 --- a/HellionChat/SelfTests/SidebarGreetedGlyphStep.cs +++ b/HellionChat/SelfTests/SidebarGreetedGlyphStep.cs @@ -49,7 +49,10 @@ internal sealed class SidebarGreetedGlyphStep : ISelfTestStep [ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All), }, }; - Plugin.Config.Tabs.Add(injected); + // Config.Tabs is mutated by the message worker under TabsListLock; a + // framework-thread writer must take the same lock. + lock (plugin.TabsListLock) + Plugin.Config.Tabs.Add(injected); Tab? active = null; var width = (float)Plugin.Config.SidebarAutoSwitchThresholdPx + 100f; // expanded try @@ -91,7 +94,8 @@ internal sealed class SidebarGreetedGlyphStep : ISelfTestStep } finally { - Plugin.Config.Tabs.Remove(injected); + lock (plugin.TabsListLock) + Plugin.Config.Tabs.Remove(injected); Plugin.Config.AutoTellTabsShowGreetedToggle = savedFlag; Plugin.Config.SidebarWidth = savedSidebarWidth; } diff --git a/HellionChat/SelfTests/SidebarSectionHeaderStep.cs b/HellionChat/SelfTests/SidebarSectionHeaderStep.cs index dbaedd3..1915ebd 100644 --- a/HellionChat/SelfTests/SidebarSectionHeaderStep.cs +++ b/HellionChat/SelfTests/SidebarSectionHeaderStep.cs @@ -50,8 +50,13 @@ internal sealed class SidebarSectionHeaderStep : ISelfTestStep } injected.Add(BuildTempProbe("Tell Probe@SelfTest", pinned: false)); injected.Add(BuildTempProbe("Pinned Probe@SelfTest", pinned: true)); - foreach (var tab in injected) - Plugin.Config.Tabs.Add(tab); + // Config.Tabs is mutated by the message worker under TabsListLock; a + // framework-thread writer must take the same lock. + lock (plugin.TabsListLock) + { + foreach (var tab in injected) + Plugin.Config.Tabs.Add(tab); + } Tab? active = null; var width = (float)Plugin.Config.SidebarAutoSwitchThresholdPx + 100f; // expanded @@ -84,8 +89,11 @@ internal sealed class SidebarSectionHeaderStep : ISelfTestStep } finally { - foreach (var tab in injected) - Plugin.Config.Tabs.Remove(tab); + lock (plugin.TabsListLock) + { + foreach (var tab in injected) + Plugin.Config.Tabs.Remove(tab); + } Plugin.Config.AutoTellTabsCompactDisplay = savedCompact; Plugin.Config.SidebarWidth = savedSidebarWidth; } From d2da51a4f7c38115c29171168f86c0efd11289be Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 06:49:53 +0200 Subject: [PATCH 028/129] perf(settings): write config on release instead of every slider frame ImGui sliders report a change in every frame the value moves, so dragging one rewrote the full 31 KB config to disk per frame -- serialize, fsync and rename, synchronously on the draw thread. Measured on Linux/Wine that showed up as a 114 ms frame while the plugin itself only drew for 2.9 ms; the rest was waiting on the write. The five shared slider helpers now defer SaveConfig to IsItemDeactivatedAfterEdit, matching what ChatColourPicker already did for the colour wheel. Renaming a tab needed its own path: the input lives inside a popup, and ImGui never re-submits it when the popup is dismissed by clicking outside, so IsItemDeactivatedAfterEdit would not fire and the new name would be lost. A pending-rename marker scoped to the owning tab flushes it when the popup is gone -- scoped, because every other tab's Draw reaches that branch too. DeferredSaveFrames is removed: the debounce was fully wired but never armed, and this approach makes it redundant. --- HellionChat/Plugin.cs | 20 ---------- .../Components/Settings/Tabs/ChannelsTab.cs | 5 ++- .../Settings/Tabs/DataPrivacyTab.cs | 5 ++- .../Ui/Components/Settings/Tabs/GeneralTab.cs | 5 ++- .../Ui/Components/Settings/Tabs/WindowTab.cs | 10 +++-- HellionChat/Ui/Components/TabContextMenu.cs | 40 ++++++++++++++++++- 6 files changed, 54 insertions(+), 31 deletions(-) diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 5f3dc27..a460488 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -175,8 +175,6 @@ public sealed class Plugin : IAsyncDalamudPlugin // PerformanceBaselineStep so the hot path stays allocation-free. internal double LastDrawMs; - internal int DeferredSaveFrames = -1; - // Cancels the v1.4.8 FTS5 bulk-insert worker on plugin teardown. The // worker runs off the framework thread on its own SqliteConnection, so a // Dispose mid-rebuild must signal cancellation before MessageManager @@ -277,8 +275,6 @@ public sealed class Plugin : IAsyncDalamudPlugin ImGuiUtil.Initialize(this); - DeferredSaveFrames = -1; - // Custom themes dir + seed run before the container builds so the // ThemeRegistry factory lambda finds the directory ready. var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes"); @@ -641,19 +637,6 @@ public sealed class Plugin : IAsyncDalamudPlugin } ); - // Flush a pending DeferredSave — FrameworkUpdate won't fire it anymore. - failure = CaptureFailure( - failure, - () => - { - if (DeferredSaveFrames >= 0) - { - SaveConfig(); - DeferredSaveFrames = -1; - } - } - ); - // Framework-thread cleanup the container does not reach. try { @@ -1134,9 +1117,6 @@ public sealed class Plugin : IAsyncDalamudPlugin private void FrameworkUpdate(IFramework framework) { - if (DeferredSaveFrames >= 0 && DeferredSaveFrames-- == 0) - SaveConfig(); - if (!Config.HideChat) return; diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs index b23658d..4e1e338 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs @@ -116,10 +116,11 @@ internal sealed class ChannelsTab { var current = get(); ImGui.SetNextItemWidth(200); + // Sliders report a change every frame while dragging; deferring the write + // to release turns ~30 full-config disk writes per second into one. if (ImGui.SliderInt(label, ref current, min, max, "%d")) - { set(current); + if (ImGui.IsItemDeactivatedAfterEdit()) _plugin.SaveConfig(); - } } } diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs index 043aa31..307adef 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -108,10 +108,11 @@ internal sealed class DataPrivacyTab { var current = get(); ImGui.SetNextItemWidth(200); + // Sliders report a change every frame while dragging; deferring the write + // to release turns ~30 full-config disk writes per second into one. if (ImGui.SliderInt(label, ref current, min, max, "%d")) - { set(current); + if (ImGui.IsItemDeactivatedAfterEdit()) _plugin.SaveConfig(); - } } } diff --git a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs index b82948f..e82f597 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs @@ -80,11 +80,12 @@ internal sealed class GeneralTab { var current = get(); ImGui.SetNextItemWidth(200); + // Sliders report a change every frame while dragging; deferring the write + // to release turns ~30 full-config disk writes per second into one. if (ImGui.SliderFloat(label, ref current, min, max, "%.2f")) - { set(current); + if (ImGui.IsItemDeactivatedAfterEdit()) _plugin.SaveConfig(); - } } // Wires the already-present ImGuiUtil.KeybindInput capture widget (dead/unwired diff --git a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs index e108486..19806b2 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs @@ -134,21 +134,23 @@ internal sealed class WindowTab { var current = get(); ImGui.SetNextItemWidth(200); + // Sliders report a change every frame while dragging; deferring the write + // to release turns ~30 full-config disk writes per second into one. if (ImGui.SliderFloat(label, ref current, min, max, "%.2f")) - { set(current); + if (ImGui.IsItemDeactivatedAfterEdit()) _plugin.SaveConfig(); - } } private void DrawSliderInt(string label, Func get, Action set, int min, int max) { var current = get(); ImGui.SetNextItemWidth(200); + // Sliders report a change every frame while dragging; deferring the write + // to release turns ~30 full-config disk writes per second into one. if (ImGui.SliderInt(label, ref current, min, max, "%d")) - { set(current); + if (ImGui.IsItemDeactivatedAfterEdit()) _plugin.SaveConfig(); - } } } diff --git a/HellionChat/Ui/Components/TabContextMenu.cs b/HellionChat/Ui/Components/TabContextMenu.cs index 3acb9e8..738a6e7 100644 --- a/HellionChat/Ui/Components/TabContextMenu.cs +++ b/HellionChat/Ui/Components/TabContextMenu.cs @@ -12,6 +12,12 @@ namespace HellionChat.Ui.Components; // state and reaches the live Config/Plugin through Plugin.Instance/Plugin.Config. internal static class TabContextMenu { + // Pending rename, scoped to one tab. ImGui never re-submits the input when the + // popup is dismissed by clicking outside, so IsItemDeactivatedAfterEdit never + // fires there — without this the rename would be lost. + private static Guid _renamingTab; + private static bool _renameDirty; + // MUST be called immediately after the row-carrying ImGui item (Sidebar // "row" InvisibleButton / TopTabBar Selectable). popupId only names the // popup; the open trigger is a right-click on the LAST submitted item @@ -20,7 +26,18 @@ internal static class TabContextMenu public static void Draw(Tab tab, string popupId, Windows.ChannelPopoutPool pool) { if (!ImGui.BeginPopupContextItem(popupId)) + { + // Popup gone: flush a pending rename. Scoped to the OWNING tab — every + // other tab's Draw lands here too and would flush foreign state. + if (_renamingTab == tab.Identifier) + { + if (_renameDirty) + Plugin.Instance.SaveConfig(); + ClearPendingRename(); + } + return; + } // Rename: focus the field the first frame the popup appears. if (ImGui.IsWindowAppearing()) @@ -28,7 +45,19 @@ internal static class TabContextMenu ImGui.SetNextItemWidth(250f * ImGuiHelpers.GlobalScale); var name = tab.Name; if (ImGui.InputText("##tab-name", ref name, 512) && ApplyTabRename(tab, name)) - Plugin.Instance.SaveConfig(); + { + _renamingTab = tab.Identifier; + _renameDirty = true; + } + + // Covers leaving the field while the popup stays open; the dismissed-popup + // case is handled above. + if (ImGui.IsItemDeactivatedAfterEdit() && _renamingTab == tab.Identifier) + { + if (_renameDirty) + Plugin.Instance.SaveConfig(); + ClearPendingRename(); + } // Per-tab notification sound (B3-3). The checkbox gates the picker so // tabs that never want a sound keep the popup short. @@ -49,6 +78,15 @@ internal static class TabContextMenu ImGui.EndPopup(); } + // The flush depends on Draw running once more for this tab. If it never does — + // LRU eviction, logout, window closed or collapsed, plugin unload, game exit — + // the name only lives in memory until some other SaveConfig happens to run. + private static void ClearPendingRename() + { + _renamingTab = Guid.Empty; + _renameDirty = false; + } + // Sound picker: 16 numbered game sounds, a separator, then the 3 bundled // Hellion clips stored as ids 17-19 (1.5.6 parity order). The collapsed // preview reuses the entry label scheme so the current pick reads the same From 89c66e0d3d38e54f506b162c497d1a9dc0e63eab Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 06:50:04 +0200 Subject: [PATCH 029/129] perf(store): index (Receiver, Date) so tell history streams sorted GetTellHistoryWithSender filters on Receiver and orders by Date DESC. Without a matching index SQLite sorted the whole receiver history into a temp b-tree before yielding row one -- measured 10 ms to first row against 9621 tells, all of it under TabsListLock, which defeats the early break in the caller. (Receiver, ChatType, Date) does not help: the ChatType IN filter sits between the equality prefix and the sort column, so the temp b-tree stays. Verified on a real database: plan now reads SEARCH ... USING INDEX idx_messages_receiver_date with no sort step. No SQL LIMIT -- an earlier 500-row cap was removed in v1.4.10 because it cut less-frequent partners off the back of the window, and 83% of partners have fewer than 21 tells in total. The migration dispatcher is cumulative, so Migrate5 is appended to every existing case, not just the new one. --- HellionChat/MessageStore.cs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index 0cfe180..1ec4372 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -279,18 +279,25 @@ internal class MessageStore : IDisposable migrationsToDo.Add(Migrate2); migrationsToDo.Add(Migrate3); migrationsToDo.Add(Migrate4); + migrationsToDo.Add(Migrate5); break; case 1: migrationsToDo.Add(Migrate2); migrationsToDo.Add(Migrate3); migrationsToDo.Add(Migrate4); + migrationsToDo.Add(Migrate5); break; case 2: migrationsToDo.Add(Migrate3); migrationsToDo.Add(Migrate4); + migrationsToDo.Add(Migrate5); break; case 3: migrationsToDo.Add(Migrate4); + migrationsToDo.Add(Migrate5); + break; + case 4: + migrationsToDo.Add(Migrate5); break; } @@ -430,6 +437,23 @@ internal class MessageStore : IDisposable SetMigrationVersion(4); } + private void Migrate5() + { + _logger.LogInformation("Running migration 5: Add (Receiver, Date) index for tell history"); + + // GetTellHistoryWithSender filters on Receiver and orders by Date DESC. + // Without a matching index SQLite sorts the whole receiver history into a + // temp b-tree before returning row one, which defeats the early break in + // the caller. (Receiver, ChatType, Date) does NOT help: the ChatType IN + // filter sits between the equality prefix and the sort column. + using var cmd = Connection.CreateCommand(); + cmd.CommandText = + "CREATE INDEX IF NOT EXISTS idx_messages_receiver_date ON messages (Receiver, Date);"; + cmd.ExecuteNonQuery(); + + SetMigrationVersion(5); + } + private void SetMigrationVersion(int version) { _logger.LogInformation($"Setting version {version}"); @@ -1014,7 +1038,8 @@ internal class MessageStore : IDisposable } // Returns up to `limit` tells exchanged with the named player, oldest-first. - // SQL narrows by Receiver + ChatType via the (Receiver, Date) index, then + // SQL narrows by Receiver + ChatType via the (Receiver, Date) index (migration + // 5; before that the ordering fell back to a temp b-tree over all rows), then // the client-side loop runs PlayerPayload comparison and breaks once // `limit` partner matches accumulate. Earlier versions had a hardcoded // 500-row scan cap that cut less-frequent pinned partners off the back of From 24dff3cc2ebfc8087f0b78fdc20a6492004c4fe6 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 07:27:21 +0200 Subject: [PATCH 030/129] fix(messages): snapshot the tab list before delivering a message ProcessMessage walked Config.Tabs live on the worker thread while SaveConfig's strip and the auto-tell spawn mutated the same list under TabsListLock. The resulting "collection was modified" was caught by the pending-message handler and only logged -- so the message was dropped entirely: no tab entry, no sound, and MessageProcessed never fired, which also meant no tell tab and no routing. Silent message loss, exactly under the load where it hurts. The loop now runs over a snapshot taken under the lock. AddMessage stays outside it, so the lock order (list outer, MessageList inner) is unchanged. SelectNotificationSound reports which tab it picked, so playback can skip a tab that disappeared between snapshot and sound -- otherwise the snapshot would let an evicted tab still make noise. While here: the current tab was read twice despite the comment claiming it was snapshotted once. --- HellionChat/MessageManager.cs | 38 +++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/HellionChat/MessageManager.cs b/HellionChat/MessageManager.cs index dcb10a2..31f8b1d 100644 --- a/HellionChat/MessageManager.cs +++ b/HellionChat/MessageManager.cs @@ -359,12 +359,21 @@ internal class MessageManager : IAsyncDisposable if (Plugin.Config.DatabaseBattleMessages || !message.Code.IsBattle()) Store.UpsertMessage(message); + // Snapshot the list, not just the active tab. This loop runs on the worker + // thread while SaveConfig's strip and the auto-tell spawn mutate Config.Tabs + // under TabsListLock — enumerating it live throws "collection was modified", + // and the catch in ProcessPendingMessages swallows that, silently dropping + // the whole message: no tab entry, no sound, no MessageProcessed. + List tabsSnapshot; + lock (Plugin.TabsListLock) + tabsSnapshot = Plugin.Config.Tabs.ToList(); + // Snapshot the active tab and whether it shows this message ONCE, so the // whole loop sees a consistent value (the getter is a cross-thread read of // MainWindow.ActiveTab). var currentTab = Plugin.CurrentTab; var currentTabMatches = currentTab.Matches(message); - foreach (var tab in Plugin.Config.Tabs) + foreach (var tab in tabsSnapshot) { if (tab.Matches(message)) tab.AddMessage(message, ShouldCountUnread(tab, currentTab, currentTabMatches)); @@ -374,12 +383,24 @@ internal class MessageManager : IAsyncDisposable // stays pure and SelfTest-able; AddMessage above and playback below keep // the side effects. var notificationSound = SelectNotificationSound( - Plugin.Config.Tabs, - Plugin.CurrentTab, + tabsSnapshot, + currentTab, message, - Plugin.Config.PlaySounds + Plugin.Config.PlaySounds, + out var soundSource ); + // The snapshot can outlive a tab (eviction, logout). Playing its sound would + // be an audible artefact for a tab that is already gone, so re-check first. + if (notificationSound is not null && soundSource is not null) + { + bool sourceStillPresent; + lock (Plugin.TabsListLock) + sourceStillPresent = Plugin.Config.Tabs.Contains(soundSource); + if (!sourceStillPresent) + notificationSound = null; + } + if (notificationSound is { } soundId) { if (soundId is >= 1 and <= 16) @@ -427,14 +448,18 @@ internal class MessageManager : IAsyncDisposable && currentTabMatches ); + // Reports the tab the sound came from, so the caller can drop it if that tab + // disappeared between snapshot and playback. internal static uint? SelectNotificationSound( IEnumerable tabs, Tab currentTab, Message probe, - bool playSounds + bool playSounds, + out Tab? source ) { uint? picked = null; + source = null; foreach (var tab in tabs) { if (!tab.Matches(probe)) @@ -449,6 +474,7 @@ internal class MessageManager : IAsyncDisposable ) { picked = tab.NotificationSoundId; + source = tab; } } return picked; @@ -460,7 +486,7 @@ internal class MessageManager : IAsyncDisposable Tab currentTab, Message probe, bool playSounds - ) => SelectNotificationSound(tabs, currentTab, probe, playSounds); + ) => SelectNotificationSound(tabs, currentTab, probe, playSounds, out _); internal class NameFormatting { From 2b4243599e37d0109ea3b6a59279208eb80d82bc Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 07:27:34 +0200 Subject: [PATCH 031/129] fix(ui): render each frame from one tab-list snapshot, key widgets by identity Sidebar, top tabs and status bar each read Config.Tabs on their own, unlocked, while the worker added or evicted tabs. That gave three independent views of a moving list: an index built in one place could resolve to a different tab a few lines later, which showed up either as an out-of-range crash on the draw thread or -- worse, because it is silent -- as a click landing in someone else's tell. MainWindow now takes one snapshot under the lock and passes it through the whole frame. Deliberately a shallow copy: tab identity is compared by reference all over the draw path, so cloning would break every ReferenceEquals and Contains. ChangeTabDelta and ResetActiveTabIfRemoved run on the framework thread and keep their own locked reads instead; ThemeQuickPicker locks its own copy, since reaching it would mean threading a parameter through InputBar, which popouts share and which has no tab list. Widget IDs move from list position to tab.Identifier. ImGui carries popup and widget state across frames under that ID, so a position-based one re-binds an open context menu to a different tab as soon as the list shifts -- a snapshot cannot fix that, it spans frames. This also resolves top tabs visually merging into each other when the list changed. The sidebar section headers counted over the live list while the rows came from BuildRenderOrder, which skips popped-out tabs. Both sides take the same predicate now, so the count matches what is drawn. --- HellionChat/Ui/Components/Sidebar.cs | 13 +++--- HellionChat/Ui/Components/StatusBar.cs | 8 ++-- HellionChat/Ui/Components/ThemeQuickPicker.cs | 7 ++- HellionChat/Ui/Components/TopTabBar.cs | 6 +-- HellionChat/Ui/Windows/MainWindow.cs | 46 ++++++++++++------- HellionChat/Util/TabLifecycleHelpers.cs | 27 ++++++++++- 6 files changed, 75 insertions(+), 32 deletions(-) diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index 19b255b..d8b05f4 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -103,7 +103,7 @@ internal sealed class Sidebar Plugin.Instance.AutoTellTabsService.MarkGreeted(tab); } - public void Draw(float windowWidth, IList tabs, ref Tab? activeTab) + public void Draw(float windowWidth, IReadOnlyList tabs, ref Tab? activeTab) { LastRenderedGreetedGlyphCount = 0; LastRenderedUnreadDotCount = 0; @@ -147,7 +147,7 @@ internal sealed class Sidebar { DrawSectionHeader( HellionStrings.PinTab_SectionHeader, - Plugin.Instance.AutoTellTabsService.PinnedTempTabCount + TabLifecycleHelpers.CountPinnedPool(tabs, t => _pool.IsOpen(t.Identifier)) ); pinnedHeaderRendered = true; } @@ -155,14 +155,13 @@ internal sealed class Sidebar { DrawSectionHeader( HellionStrings.AutoTellTabs_SectionHeader, - Plugin.Instance.AutoTellTabsService.ActiveTempTabCount + TabLifecycleHelpers.CountUnpinnedPool(tabs, t => _pool.IsOpen(t.Identifier)) ); unpinnedHeaderRendered = true; } DrawRow( tab, - i, expanded, accentRgba, textAbgr, @@ -191,7 +190,6 @@ internal sealed class Sidebar private void DrawRow( Tab tab, - int index, bool expanded, uint accentRgba, uint textAbgr, @@ -202,7 +200,10 @@ internal sealed class Sidebar ref Tab? activeTab ) { - ImGui.PushID(index); + // Identity, not position: ImGui keeps popup state across frames under this + // ID, so an index would re-bind an open context menu to a different tab as + // soon as the list shifts. String, not GetHashCode — hashes collide. + ImGui.PushID(tab.Identifier.ToString()); var origin = ImGui.GetCursorScreenPos(); var avail = ImGui.GetContentRegionAvail().X; diff --git a/HellionChat/Ui/Components/StatusBar.cs b/HellionChat/Ui/Components/StatusBar.cs index 9342c5b..3b867b8 100644 --- a/HellionChat/Ui/Components/StatusBar.cs +++ b/HellionChat/Ui/Components/StatusBar.cs @@ -60,7 +60,7 @@ internal sealed class StatusBar // Single-pass aggregator — same shape as the previous helper so the // build-suite test continues to pin the contract. - internal static (int messages, int tells) AggregateForStatusBar(IList tabs) + internal static (int messages, int tells) AggregateForStatusBar(IReadOnlyList tabs) { int messages = 0, tells = 0; @@ -93,7 +93,7 @@ internal sealed class StatusBar _lastUpdateMs = now; } - public void Draw(Tab? activeTab) + public void Draw(Tab? activeTab, IReadOnlyList tabs) { if (!_fonts.FontsReady) { @@ -105,8 +105,8 @@ internal sealed class StatusBar var now = Environment.TickCount64; if (now - _lastUpdateMs >= UpdateIntervalMs) { - var (messages, tells) = AggregateForStatusBar(Plugin.Config.Tabs); - UpdateCacheIfDue(now, Plugin.Config.Tabs.Count, messages, tells); + var (messages, tells) = AggregateForStatusBar(tabs); + UpdateCacheIfDue(now, tabs.Count, messages, tells); } // Top border via DrawList — ImGui.Separator has too much padding for diff --git a/HellionChat/Ui/Components/ThemeQuickPicker.cs b/HellionChat/Ui/Components/ThemeQuickPicker.cs index 3cb763d..105a76a 100644 --- a/HellionChat/Ui/Components/ThemeQuickPicker.cs +++ b/HellionChat/Ui/Components/ThemeQuickPicker.cs @@ -86,7 +86,12 @@ internal sealed class ThemeQuickPicker ImGui.Separator(); // Snapshot so a worker-thread temp-tab strip can't shift the list mid-loop. - var tabs = Plugin.Config.Tabs.ToList(); + // The copy itself needs the lock, otherwise it tears the same way. Not the + // frame snapshot from MainWindow: reaching it would mean threading a + // parameter through InputBar, which popouts share and which has no tab list. + List tabs; + lock (_plugin.TabsListLock) + tabs = Plugin.Config.Tabs.ToList(); var height = MathF.Min(tabs.Count * RowHeight, MaxSectionHeight); using var child = ImRaii.Child( "##hellion-quick-picker-tabs", diff --git a/HellionChat/Ui/Components/TopTabBar.cs b/HellionChat/Ui/Components/TopTabBar.cs index c7c4e00..59e10c9 100644 --- a/HellionChat/Ui/Components/TopTabBar.cs +++ b/HellionChat/Ui/Components/TopTabBar.cs @@ -16,7 +16,7 @@ internal sealed class TopTabBar _pool = pool; } - public void Draw(IList tabs, ref Tab? activeTab) + public void Draw(IReadOnlyList tabs, ref Tab? activeTab) { var firstDrawn = true; for (var i = 0; i < tabs.Count; i++) @@ -39,7 +39,7 @@ internal sealed class TopTabBar var tabWidth = ImGui.CalcTextSize(tab.Name).X; if ( ImGui.Selectable( - $"{tab.Name}###hellion_toptab_{i}", + $"{tab.Name}###hellion_toptab_{tab.Identifier}", selected, ImGuiSelectableFlags.None, new Vector2(tabWidth, 0) @@ -71,7 +71,7 @@ internal sealed class TopTabBar .AddCircleFilled(new Vector2(max.X - 4f, min.Y + 4f), 3.5f, danger, 12); } - TabContextMenu.Draw(tab, $"toptab_ctx_{i}", _pool); + TabContextMenu.Draw(tab, $"toptab_ctx_{tab.Identifier}", _pool); } ImGui.Separator(); diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index 6c5dcac..a89982a 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -144,7 +144,11 @@ internal sealed class MainWindow : Window, IFocusableChatWindow if (!ReferenceEquals(_activeTab, removed)) return; - var next = Plugin.Config.Tabs.Count > 0 ? Plugin.Config.Tabs[0] : null; + // Framework thread, not the draw frame: needs the current truth, so it takes + // its own lock instead of using the frame snapshot. + Tab? next; + lock (Plugin.Instance.TabsListLock) + next = Plugin.Config.Tabs.Count > 0 ? Plugin.Config.Tabs[0] : null; _activeTab = next; if (next is not null) TabLifecycleHelpers.OnTabActivated(next, removed); @@ -171,7 +175,11 @@ internal sealed class MainWindow : Window, IFocusableChatWindow // deferred (no focus contract) — main-window tabs only. internal void ChangeTabDelta(int delta) { - var tabs = Plugin.Config.Tabs; + // Runs on Framework.Update via the keybind dispatch, not on the draw frame — + // own lock, own copy. Stays a List so IndexOf below keeps working. + List tabs; + lock (Plugin.Instance.TabsListLock) + tabs = Plugin.Config.Tabs.ToList(); if (tabs.Count == 0) return; @@ -256,24 +264,30 @@ internal sealed class MainWindow : Window, IFocusableChatWindow // Primary pool-reset path; InputPreview has a defensive fallback for the MainWindow-closed edge case. _handlerLender.ResetCounter(); + // One snapshot for the whole frame. Everything below reads this instead of + // Config.Tabs, so sidebar, top tabs and status bar see the same list even if + // the worker adds or evicts a tab mid-frame. Deliberately a SHALLOW copy: + // tab identity is compared by reference all over the draw path, so cloning + // would break every ReferenceEquals and Contains. + List tabs; + lock (Plugin.Instance.TabsListLock) + tabs = Plugin.Config.Tabs.ToList(); + // First-frame seed: the active tab defaults to the first persisted // tab so the message list isn't empty on a clean session. - if (_activeTab is null && Plugin.Config.Tabs.Count > 0) + if (_activeTab is null && tabs.Count > 0) { - var seeded = Plugin.Config.Tabs[0]; + var seeded = tabs[0]; _activeTab = seeded; // The seeded Tabs[0] is the likeliest legacy stale-tell carrier // (pre-coupling the detour wrote here); strip it like any activation. TabLifecycleHelpers.OnTabActivated(seeded, null); } - else if (_activeTab is { } active && !Plugin.Config.Tabs.Contains(active)) + else if (_activeTab is { } active && !tabs.Contains(active)) { // Active tab is no longer in the list (e.g. a wholesale config import - // the service repair paths never see). Re-seed on the Draw thread. The - // Contains read shares the pre-existing unsynchronized-Tabs-list - // exposure that spec §6 defers (SaveConfig also strips from the worker - // thread); this adds one more racing read, not a new hazard class. - var reseed = Plugin.Config.Tabs.Count > 0 ? Plugin.Config.Tabs[0] : null; + // the service repair paths never see). Re-seed on the Draw thread. + var reseed = tabs.Count > 0 ? tabs[0] : null; _activeTab = reseed; if (reseed is not null) TabLifecycleHelpers.OnTabActivated(reseed, active); @@ -287,7 +301,7 @@ internal sealed class MainWindow : Window, IFocusableChatWindow // settled, so OnTabActivated fires only on the pop frame. var visibleActive = TabLifecycleHelpers.PickMainActiveTab( _activeTab, - Plugin.Config.Tabs, + tabs, t => _pool.IsOpen(t.Identifier) ); if (!ReferenceEquals(visibleActive, _activeTab)) @@ -309,20 +323,20 @@ internal sealed class MainWindow : Window, IFocusableChatWindow using (var body = ImRaii.Child("##hellion-body", new Vector2(-1f, -statusHeight))) { if (body.Success) - DrawBody(); + DrawBody(tabs); } - _status.Draw(_activeTab); + _status.Draw(_activeTab, tabs); } - private void DrawBody() + private void DrawBody(IReadOnlyList tabs) { var bodyWidth = ImGui.GetContentRegionAvail().X; _honorific.Draw(bodyWidth); if (Plugin.Config.MainWindowLayoutMode == MainWindowLayoutMode.TopTabs) { - _topTabs.Draw(Plugin.Config.Tabs, ref _activeTab); + _topTabs.Draw(tabs, ref _activeTab); using (ImRaii.Group()) { DrawMainArea(); @@ -333,7 +347,7 @@ internal sealed class MainWindow : Window, IFocusableChatWindow // Sidebar layout (default). using (ImRaii.Group()) { - _sidebar.Draw(bodyWidth, Plugin.Config.Tabs, ref _activeTab); + _sidebar.Draw(bodyWidth, tabs, ref _activeTab); } ImGui.SameLine(); diff --git a/HellionChat/Util/TabLifecycleHelpers.cs b/HellionChat/Util/TabLifecycleHelpers.cs index 103f482..b8fb729 100644 --- a/HellionChat/Util/TabLifecycleHelpers.cs +++ b/HellionChat/Util/TabLifecycleHelpers.cs @@ -115,7 +115,30 @@ internal static class TabLifecycleHelpers // row and its pool's section header gates on the first tab actually reached. Pure // + Dalamud-free so the Build-Suite can pin it. // TEST-MIRROR: ../../../Hellion Build test/_Helpers/SidebarRenderOrderTests.cs - internal static List BuildRenderOrder(IList tabs, Func isPoppedOut) + // Section-header counts for the sidebar. They take the same isPoppedOut + // predicate as BuildRenderOrder, which skips popped-out tabs — without it the + // header would claim "(3)" above two rendered rows. AutoTellTabsService keeps + // its own live properties: those gate the pool limits and must not see a + // snapshot. + internal static int CountUnpinnedPool(IReadOnlyList tabs, Func isPoppedOut) + { + var n = 0; + for (var i = 0; i < tabs.Count; i++) + if (IsInUnpinnedPool(tabs[i]) && !isPoppedOut(tabs[i])) + n++; + return n; + } + + internal static int CountPinnedPool(IReadOnlyList tabs, Func isPoppedOut) + { + var n = 0; + for (var i = 0; i < tabs.Count; i++) + if (IsInPinnedPool(tabs[i]) && !isPoppedOut(tabs[i])) + n++; + return n; + } + + internal static List BuildRenderOrder(IReadOnlyList tabs, Func isPoppedOut) { var persistent = new List(tabs.Count); var pinned = new List(); @@ -145,7 +168,7 @@ internal static class TabLifecycleHelpers // TEST-MIRROR: ../../../Hellion Build test/_Helpers/PickMainActiveTabTests.cs internal static Tab? PickMainActiveTab( Tab? current, - IList tabs, + IReadOnlyList tabs, Func isPoppedOut ) { From eaed0b13e076c4974c420bd4fa19e8d8e20659e2 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 07:27:48 +0200 Subject: [PATCH 032/129] fix(config): guard the shared config maps, restore lost fields in Tab.Clone TabsListLock never covered ChatColours, PrivacyPersistChannels or RetentionPerChannelDays, yet the settings UI mutates them from the draw thread while the retention thread can be serializing the same config. Adding a new key to a dictionary or a new element to a set invalidates a running enumeration, so this could throw from inside JsonConvert.SerializeObject. Not a corner case: the colour picker lists 66 channels but only 25 ship with a default, so the first edit of any of the remaining ones inserts a new key -- and the reset button removes a key, which makes the next edit a fresh insert again. The readers matter as much as the writers. IsAllowedForStorage runs per message on the worker thread and asks PrivacyPersistChannels whether a channel may be stored; a Contains racing an Add that resizes buckets can answer wrong, and that answer decides whether a message is written to disk. The retention sweep enumerates RetentionPerChannelDays on the framework thread while the wizard can clear it -- Clear does not throw there, it just cuts the enumeration short, so the sweep would run on half a policy. New ConfigMapsLock covers all of it. It sits inside TabsListLock (that edge is real, AutoTellTabsService calls SaveConfig while holding the tabs lock), never the other way round -- so every call site closes the lock before saving. Tab.Clone silently dropped Icon and ChatCodes, both serialized. A reflection test now walks the serialized fields so a future one cannot slip past. Also: CurrentTab read Count and [0] as two separate accesses. --- HellionChat/Configuration.cs | 17 ++++++++- HellionChat/Plugin.cs | 35 ++++++++++++++++--- .../Components/Settings/ChatColourPicker.cs | 17 ++++++--- .../Ui/Components/Settings/Tabs/ChatTab.cs | 17 +++++---- .../Settings/Tabs/DataPrivacyTab.cs | 16 +++++---- HellionChat/Ui/FirstRunWizard.cs | 5 ++- 6 files changed, 84 insertions(+), 23 deletions(-) diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index 3ef8a62..b46cb68 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -79,7 +79,15 @@ public class Configuration : IPluginConfiguration { if (!PrivacyFilterEnabled) return true; - if (PrivacyPersistChannels.Contains(type)) + + // Runs per message on the worker thread while the settings UI can Add to the + // same set from the draw thread. A HashSet.Contains racing an Add that + // resizes buckets can return the wrong answer -- and this answer decides + // whether a message is persisted. Lock kept tight, this is a hot path. + bool persists; + lock (Plugin.Instance.ConfigMapsLock) + persists = PrivacyPersistChannels.Contains(type); + if (persists) return true; // F3.2: log first occurrence of a ChatType the running build doesn't @@ -619,9 +627,15 @@ public class Tab public Tab Clone() { +#pragma warning disable CS0618 // ChatCodes is obsolete but still serialized and must survive a clone return new Tab { Name = Name, + // Both were missing: Icon feeds the sidebar glyph, ChatCodes carries + // legacy migration data that is still written to the JSON. A clone + // round-trip used to drop them silently. + Icon = Icon, + ChatCodes = new Dictionary(ChatCodes), SelectedChannels = SelectedChannels.ToDictionary(pair => pair.Key, pair => pair.Value), ExtraChatAll = ExtraChatAll, ExtraChatChannels = ExtraChatChannels.ToHashSet(), @@ -654,6 +668,7 @@ public class Tab NotificationSoundId = NotificationSoundId, IsGreeted = IsGreeted, }; +#pragma warning restore CS0618 } /// Ordered message list with duplicate ID tracking, sorting and mutex protection. diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index a460488..fda84d3 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -193,14 +193,36 @@ public sealed class Plugin : IAsyncDalamudPlugin // MessageList's SemaphoreSlim inner — never the reverse. internal readonly object TabsListLock = new(); + // Guards the serialized config maps that the draw thread mutates while a + // background save may be serializing them: ChatColours, PrivacyPersistChannels + // and RetentionPerChannelDays. TabsListLock does not cover these. + // Ordering: ConfigMapsLock sits INSIDE TabsListLock (that edge is real, via + // AutoTellTabsService calling SaveConfig under the tabs lock). Never the other + // way round -- so SaveConfig must never be called while holding ConfigMapsLock. + internal readonly object ConfigMapsLock = new(); + internal DateTime GameStarted { get; } // Couples "current tab" to the real UI selection. The chat hooks are // installed before MainWindow is Phase-1 resolved, so the null-conditional // fallback to Tabs[0] is load-bearing — it keeps the pre-coupling behavior // in that early window rather than being merely defensive. - internal Tab CurrentTab => - MainWindow?.ActiveTab ?? (Config.Tabs.Count > 0 ? Config.Tabs[0] : new Tab()); + // Read once into a local: Count and [0] as two separate accesses can be split + // by a removal on another thread. Only reachable before MainWindow exists. + internal Tab CurrentTab + { + get + { + if (MainWindow?.ActiveTab is { } active) + return active; + + lock (TabsListLock) + { + var tabs = Config.Tabs; + return tabs.Count > 0 ? tabs[0] : new Tab(); + } + } + } public Plugin() { @@ -940,8 +962,13 @@ public sealed class Plugin : IAsyncDalamudPlugin var policy = new Dictionary(); foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays) policy[(int)(ushort)type] = days; - foreach (var (type, days) in Config.RetentionPerChannelDays) - policy[(int)(ushort)type] = days; + // This is the enumerator the wizard's Clear() cuts short. Reading under the + // same lock the writers take keeps the policy snapshot whole. + lock (ConfigMapsLock) + { + foreach (var (type, days) in Config.RetentionPerChannelDays) + policy[(int)(ushort)type] = days; + } var defaultDays = Config.RetentionDefaultDays; // IsBackground = true so a stuck sweep never blocks plugin unload. diff --git a/HellionChat/Ui/Components/Settings/ChatColourPicker.cs b/HellionChat/Ui/Components/Settings/ChatColourPicker.cs index 62f1e24..575650b 100644 --- a/HellionChat/Ui/Components/Settings/ChatColourPicker.cs +++ b/HellionChat/Ui/Components/Settings/ChatColourPicker.cs @@ -71,7 +71,8 @@ internal sealed class ChatColourPicker ) ) { - Plugin.Config.ChatColours.Remove(type); + lock (_plugin.ConfigMapsLock) + Plugin.Config.ChatColours.Remove(type); commit = true; } @@ -86,7 +87,8 @@ internal sealed class ChatColourPicker ) { var gameColour = _plugin.Functions.Chat.GetChannelColor(type); - Plugin.Config.ChatColours[type] = gameColour ?? type.DefaultColor() ?? 0; + lock (_plugin.ConfigMapsLock) + Plugin.Config.ChatColours[type] = gameColour ?? type.DefaultColor() ?? 0; commit = true; } @@ -97,7 +99,10 @@ internal sealed class ChatColourPicker : ColourUtil.RgbaToVector3(type.DefaultColor() ?? 0); if (ImGui.ColorEdit3(type.Name(), ref vec, ImGuiColorEditFlags.NoInputs)) { - Plugin.Config.ChatColours[type] = ColourUtil.Vector3ToRgba(vec); + // First edit of a channel without a default inserts a NEW key -- + // that is the case that invalidates a running enumeration. + lock (_plugin.ConfigMapsLock) + Plugin.Config.ChatColours[type] = ColourUtil.Vector3ToRgba(vec); liveOnly = true; } if (ImGui.IsItemDeactivatedAfterEdit()) @@ -155,7 +160,8 @@ internal sealed class ChatColourPicker private void ApplyPreset(ChatColourPreset preset) { foreach (var (channel, colour) in preset.Colours) - Plugin.Config.ChatColours[channel] = colour; + lock (_plugin.ConfigMapsLock) + Plugin.Config.ChatColours[channel] = colour; ApplyChatColourChange(); } @@ -226,7 +232,8 @@ internal sealed class ChatColourPicker if (ImGui.Button(HellionStrings.Settings_Themes_ApplyChatColors_Apply)) { foreach (var kvp in themeChatColors.Channels) - Plugin.Config.ChatColours[kvp.Key] = kvp.Value; + lock (_plugin.ConfigMapsLock) + Plugin.Config.ChatColours[kvp.Key] = kvp.Value; _applyDismissedFor = active.Slug; ApplyChatColourChange(); } diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs index 647d11a..ae89425 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs @@ -84,13 +84,18 @@ internal sealed class ChatTab var present = Plugin.Config.PrivacyPersistChannels.Contains(ct); if (ImGui.Checkbox($"{label}##persist-{label}", ref present)) { - if (present) + // Lock closes before SaveConfig: taking ConfigMapsLock across a save + // would invert the lock order (SaveConfig can reach TabsListLock). + lock (_plugin.ConfigMapsLock) { - Plugin.Config.PrivacyPersistChannels.Add(ct); - } - else - { - Plugin.Config.PrivacyPersistChannels.Remove(ct); + if (present) + { + Plugin.Config.PrivacyPersistChannels.Add(ct); + } + else + { + Plugin.Config.PrivacyPersistChannels.Remove(ct); + } } _plugin.SaveConfig(); } diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs index 307adef..403be48 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -81,13 +81,17 @@ internal sealed class DataPrivacyTab var present = Plugin.Config.PrivacyPersistChannels.Contains(ct); if (ImGui.Checkbox($"{label}##privacy-persist-{label}", ref present)) { - if (present) + // Lock closes before the save below, see ChatTab for the ordering. + lock (_plugin.ConfigMapsLock) { - Plugin.Config.PrivacyPersistChannels.Add(ct); - } - else - { - Plugin.Config.PrivacyPersistChannels.Remove(ct); + if (present) + { + Plugin.Config.PrivacyPersistChannels.Add(ct); + } + else + { + Plugin.Config.PrivacyPersistChannels.Remove(ct); + } } _plugin.SaveConfig(); } diff --git a/HellionChat/Ui/FirstRunWizard.cs b/HellionChat/Ui/FirstRunWizard.cs index 568a42d..7506d14 100644 --- a/HellionChat/Ui/FirstRunWizard.cs +++ b/HellionChat/Ui/FirstRunWizard.cs @@ -655,7 +655,10 @@ public sealed class FirstRunWizard : Window Plugin.Config.PrivacyPersistUnknownChannels = true; Plugin.Config.RetentionEnabled = false; - Plugin.Config.RetentionPerChannelDays.Clear(); + // Clear does not throw during enumeration, it just cuts it short -- the + // retention sweep would then run on half a policy. + lock (Plugin.ConfigMapsLock) + Plugin.Config.RetentionPerChannelDays.Clear(); } // Test-only entry point so SelfTests/WizardStateSmokeStep can advance From c34024a18bd08a0e8000722c0627b99eaa0b87fc Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 07:27:59 +0200 Subject: [PATCH 033/129] chore(release): bump assembly version to 1.9.0 The whole v1.9.0 cycle ran without a version bump, so the plugin still reported 1.8.8 in-game. For a tester beta that is untenable: bug reports would name a version that does not identify the code they ran. Download links stay pinned to v1.5.6 on purpose. There is no v1.9.0 release, and pointing at an artifact that does not exist is worse than the visible mismatch between assembly version and download target. Public stays on v1.5.6 until v2.0.0. --- HellionChat/HellionChat.csproj | 2 +- docs/CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ repo.json | 4 ++-- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj index 0b9cc5f..ad455d0 100644 --- a/HellionChat/HellionChat.csproj +++ b/HellionChat/HellionChat.csproj @@ -1,7 +1,7 @@ - 1.8.8 + 1.9.0 enable enable diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ebcde14..b754bc8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,40 @@ releases as an overview and links to the release pages for details. --- +## [1.9.0] — unreleased (local only) + +Polish, tester-beta preparation and a concurrency hardening pass. **Not published** — the public +release stays at v1.5.6, so the download links in `repo.json` deliberately still point there while +the assembly version reads 1.9.0. + +### Fixed + +- Incoming messages could be dropped without a trace. The worker walked the tab list while another + thread rewrote it, the resulting exception was swallowed by the surrounding error handler, and the + message ended up nowhere: no tab entry, no notification sound, no tell routing. +- Clicking a tab could open a different one, or crash the render loop, when tabs appeared or were + evicted in the same frame. The window now renders from one consistent snapshot per frame. +- An open tab context menu could silently re-bind to a different tab. Widget identity is derived + from the tab itself now, not from its position in the list. +- `Tab.Clone()` dropped the tab icon and legacy channel data. + +### Changed + +- Settings sliders write the configuration once, on release, instead of on every frame while + dragging. Renaming a tab writes once when you finish, including when the context menu is + dismissed by clicking elsewhere. +- Tell history loads through a dedicated `(Receiver, Date)` index (schema 5). Opening a tell tab no + longer sorts the entire conversation history first. +- Pinning, unpinning and promoting tabs no longer write to disk while holding the tab lock. + +### Internal + +- New `ConfigMapsLock` guards the config maps the settings UI edits while a background save may be + serialising them. +- Removed `DeferredSaveFrames`: the debounce was fully wired but never armed. +- Test suite 812 → 829, including migration and query-plan coverage. + + ## Hellion Chat 1.5.6 — Settings Overhaul + Filter & Notification Polish (2026-05-23) - Settings window reorganised: ten tabs down to seven (General, Appearance, Chat, Window, Channels, Data & Privacy, About). Each tab now uses collapsible sections grouped by control type. Sections start collapsed every time you open a tab — less noise, easier to find what you need. diff --git a/repo.json b/repo.json index 76c2019..eb7a138 100644 --- a/repo.json +++ b/repo.json @@ -3,7 +3,7 @@ "Author": "Jon Kazama (Hellion Forge)", "Name": "Hellion Chat", "InternalName": "HellionChat", - "AssemblyVersion": "1.8.8.0", + "AssemblyVersion": "1.9.0.0", "Description": "A Hellion Forge plugin — privacy-focused chat replacement for FINAL FANTASY XIV, built for EU, US and JP data rules.\n\nBy default only your own conversations are stored. Public chat, NPC dialogue, system messages and battle logs are discarded at the storage layer unless you opt in. Retention windows are configurable per channel, history can be wiped retroactively, and everything can be exported on demand.\n\nFeatures:\n- Channel whitelist with a Privacy-First default\n- Per-channel retention with a daily background sweep\n- Retroactive cleanup with preview and Ctrl+Shift confirm\n- Export to Markdown, JSON or CSV\n- First-run wizard with four profiles: Privacy-First, Casual, Roleplay, Full History\n- Multi-language UI (24 locales) with live language switching\n- Own config and database — no shared state with other plugins\n\nBased on Chat 2 by Infi and Anna (EUPL-1.2).\nSupport: https://discord.gg/X9V7Kcv5gR", "ApplicableVersion": "any", "RepoUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat", @@ -25,7 +25,7 @@ "DownloadLinkInstall": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip", "DownloadLinkUpdate": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip", "DownloadLinkTesting": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip", - "TestingAssemblyVersion": "1.8.8.0", + "TestingAssemblyVersion": "1.9.0.0", "IconUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/icon.png", "ImageUrls": [ "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/chatWindow.png", From 3583dfc0325d9eac5374adce503479ebd9ba9306 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 18:38:08 +0200 Subject: [PATCH 034/129] perf(tells): build the tab outside the lock, guard pin transitions HandleTell was one atomic block, and PreloadHistory sat inside it -- so every new tell partner held TabsListLock across a store query that sorted the whole receiver history before returning a row. That is the lock the draw thread and the message worker both wait on. Now three steps: look for an existing tab under the lock, build the new one (including history) without it, then commit under the lock again. Splitting it opens a window where the world can change, so the second block re-checks: - FindTempTab again, in case something else created the tab meanwhile. The message goes to that one instead. Not in the first block's early return -- HandleTell runs after the delivery loop, so an existing tab already has it and adding again would duplicate the line. - A generation counter, bumped by OnLogout under the same lock. A logout in between wipes the unpinned pool, and without this the freshly built tab would outlive it and show up for a character we already left. Not via CurrentContentId: its getter falls back to a cached value, so the comparison can silently pass. - The pool cap moves into CommitTempTab and stays there exactly once. Evaluating it twice would evict a tab on every spawn. Pin, unpin and promote take the lock around the flag change now -- they decide pool membership and whether a save strips the tab. SaveConfig stays outside, so no fsync lands on the click path. DropOldestTempTab removes by reference: the index came from an earlier Select in the same block and would point at the wrong tab if anything shifted the list. --- HellionChat/AutoTellTabsService.cs | 108 +++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 22 deletions(-) diff --git a/HellionChat/AutoTellTabsService.cs b/HellionChat/AutoTellTabsService.cs index 8e3e8e3..13d8d13 100644 --- a/HellionChat/AutoTellTabsService.cs +++ b/HellionChat/AutoTellTabsService.cs @@ -26,6 +26,11 @@ internal sealed class AutoTellTabsService : IDisposable // MessageManager refilter can share it. See Plugin.TabsListLock / B3. private object TabsListLock => _plugin.TabsListLock; + // Bumped whenever something wipes unpinned temp tabs wholesale (logout). + // HandleTell reads it before releasing the lock and re-checks after, so a + // tab built in between is discarded instead of outliving the wipe. + private int _tabGeneration; + // Hard cap on pinned TempTabs so the sidebar doesn't inflate over years // of usage. Separate pool from AutoTellTabsLimit (15) — pinned tabs live // in their own bucket. A configurable cap is a vault-backlog anchor for @@ -150,15 +155,19 @@ internal sealed class AutoTellTabsService : IDisposable return; } + // Three steps, because building the tab pulls history out of the store and + // that must not happen under TabsListLock (B3 rule; the query sorts the whole + // receiver history). Step 1 and 3 are locked, step 2 is not. + int generation; lock (TabsListLock) { var existing = FindTempTab(partner.Value.Name, partner.Value.World); if (existing != null) { - // Already routed via MessageManager pipeline. Repair the - // tell-target if the fallback hit a pinned tab whose - // TellTarget didn't survive a previous round-trip — keeps - // FindTempTab fast on the next message. + // Already routed via MessageManager pipeline — no AddMessage here, + // HandleTell runs after the delivery loop. Repair the tell-target if + // the fallback hit a pinned tab whose TellTarget didn't survive a + // previous round-trip — keeps FindTempTab fast on the next message. if ( existing.IsPinned && (existing.TellTarget is null || !existing.TellTarget.IsSet()) @@ -175,12 +184,29 @@ internal sealed class AutoTellTabsService : IDisposable return; } - if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit) + generation = _tabGeneration; + } + + var tab = BuildTempTabWithHistory(partner.Value, message); + + lock (TabsListLock) + { + // A logout in between wiped the unpinned pool; committing now would + // resurrect a tab for a character we already left. + if (generation != _tabGeneration) + return; + + // Someone else (self-test, UI) may have created the tab while we built + // ours. Hand the message to theirs and drop what we built — unlike the + // early return above, this tab appeared after the delivery loop ran. + var raced = FindTempTab(partner.Value.Name, partner.Value.World); + if (raced != null) { - DropOldestTempTab(); + raced.AddMessage(message, unread: true); + return; } - SpawnTempTab(partner.Value, message); + CommitTempTab(tab); } } @@ -274,7 +300,9 @@ internal sealed class AutoTellTabsService : IDisposable } var dropped = victim.Tab; - Plugin.Config.Tabs.RemoveAt(victim.Index); + // By reference, not by index: the index came from a Select() earlier in + // this block and would point at the wrong tab if anything shifted the list. + Plugin.Config.Tabs.Remove(dropped); // Re-anchor the UI selection if it pointed at the dropped tab, and close any // pop-out window the dropped tab owned. Both run on the PendingMessage worker @@ -290,7 +318,10 @@ internal sealed class AutoTellTabsService : IDisposable } } - private void SpawnTempTab((string Name, uint World) partner, Message currentMessage) + // Runs WITHOUT TabsListLock: PreloadHistory hits the store, which used to hold + // the lock across a query that sorted the whole receiver history. The tab is not + // public until CommitTempTab adds it, so building it unlocked is safe. + private Tab BuildTempTabWithHistory((string Name, uint World) partner, Message currentMessage) { var tab = BuildTempTab(partner.Name, partner.World); @@ -306,10 +337,21 @@ internal sealed class AutoTellTabsService : IDisposable tab.PopOut = true; } + return tab; + } + + // Caller MUST hold TabsListLock. + private void CommitTempTab(Tab tab) + { + if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit) + { + DropOldestTempTab(); + } + Plugin.Config.Tabs.Add(tab); // Actually open the pop-out window for the flagged tab — without this the - // flag was dead (a PopOut tab with no window). SpawnTempTab runs on the + // flag was dead (a PopOut tab with no window). CommitTempTab runs on the // PendingMessage worker thread under Plugin.TabsListLock; TryOpen does // OnTabActivated + Bind (window state Draw reads), so marshal onto the // framework thread. If the pool is full, drop the flag so it never claims a @@ -472,6 +514,11 @@ internal sealed class AutoTellTabsService : IDisposable Plugin.Config.Tabs.RemoveAll(TabLifecycleHelpers.IsInUnpinnedPool); + // HandleTell builds a tab outside the lock; bumping here lets it detect + // that the world moved on and drop what it built. Read and compared under + // the same lock, so no volatile needed. + _tabGeneration++; + // Re-anchor the UI selection if the active tab was one of the stripped // unpinned temp tabs (reference predicate, not an index). Logout is a // framework-thread event, so this is already serialized with Draw — no @@ -493,16 +540,23 @@ internal sealed class AutoTellTabsService : IDisposable return false; } - if (PinnedTempTabCount >= MaxPinnedTempTabs) + // Count and flag under one lock so the cap can't be raced. SaveConfig stays + // OUTSIDE -- holding TabsListLock across a save would put an fsync on the + // click path, which is what B6 just removed elsewhere. + lock (TabsListLock) { - WrapperUtil.AddNotification( - string.Format(HellionStrings.PinTab_LimitReached, MaxPinnedTempTabs), - NotificationType.Warning - ); - return false; + if (PinnedTempTabCount >= MaxPinnedTempTabs) + { + WrapperUtil.AddNotification( + string.Format(HellionStrings.PinTab_LimitReached, MaxPinnedTempTabs), + NotificationType.Warning + ); + return false; + } + + tab.IsPinned = true; } - tab.IsPinned = true; _logger.LogDebug( $"[Pin] Pinned tab '{tab.Name}' target={tab.TellTarget?.Name}@{tab.TellTarget?.World}" ); @@ -519,13 +573,18 @@ internal sealed class AutoTellTabsService : IDisposable // If the unpinned pool is already full, dropping the oldest before // flipping the flag avoids counting the just-unpinned tab as a drop - // candidate. - if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit) + // candidate. Under lock, since DropOldestTempTab mutates the list. + // SaveConfig stays outside, see TryPin. + lock (TabsListLock) { - DropOldestTempTab(); + if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit) + { + DropOldestTempTab(); + } + + tab.IsPinned = false; } - tab.IsPinned = false; _logger.LogDebug("[Pin] Unpinned tab '{TabName}'", tab.Name); _plugin.SaveConfig(); } @@ -542,7 +601,12 @@ internal sealed class AutoTellTabsService : IDisposable // see StripTellBindingOnPromote; clearing Tab.TellTarget alone would leave // CurrentChannel.Channel == Tell + a stale target and route a typed line // silently as /tell to the old partner. - TabLifecycleHelpers.StripTellBindingOnPromote(tab); + // Flips IsTempTab/IsPinned, which decide pool membership and whether a save + // strips the tab. Under lock so a concurrent save sees one or the other, never + // half. SaveConfig stays outside, see TryPin. + lock (TabsListLock) + TabLifecycleHelpers.StripTellBindingOnPromote(tab); + _logger.LogDebug($"[Pin] Promoted tab '{tab.Name}' to permanent (tell-binding dropped)"); _plugin.SaveConfig(); } From 39a8e95581397f4bd65ad4ea3e8ca2e9f02be365 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 21:17:38 +0200 Subject: [PATCH 035/129] fix(messages): invalidate the height cache when UI scale changes The layout fingerprint tracked font size, density, both name modes and content width, but not ImGuiHelpers.GlobalScale. Scale feeds CalcWordWrapPositionA, so changing it rewraps every row while the cached heights stay put and the clipper dummies drift against the scrollbar. Two further problems came out of the same code: The fingerprint lived in a single field on MessageList while the cache it guards is per tab. Resizing in tab A marked the new value applied, so tab B kept measuring against the old width. It is now a gate per tab identifier. Acting on every fingerprint change is too eager. A window resize or a drag on the Dalamud UI-scale slider moves the value on every frame, and each change drops the cache and forces the linear measure path over the whole tab (up to Config.MaxLinesToRender rows). The gate now waits for the value to settle for 200ms, which turns a drag into one rebuild instead of one per frame. The settle logic sits in Util/LayoutFingerprint.cs as a plain value type so the build suite can pin it without standing up an ImGui frame. --- HellionChat/Ui/Components/MessageList.cs | 37 +++++++++------- HellionChat/Util/LayoutFingerprint.cs | 56 ++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 17 deletions(-) create mode 100644 HellionChat/Util/LayoutFingerprint.cs diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs index 80e55c7..b0499b1 100644 --- a/HellionChat/Ui/Components/MessageList.cs +++ b/HellionChat/Ui/Components/MessageList.cs @@ -30,14 +30,9 @@ internal sealed class MessageList // B2: the height cache is only valid while these inputs are unchanged. // FontManager's own fingerprint covers font sizes only, not density / the two // name-display modes / width — a stale height would misplace the clipper dummies. - private ( - float Global, - float Symbols, - bool Compact, - int NameForm, - int WorldSuffix, - float Width - ) _lastLayoutFingerprint; + // Per tab, not per list: the old single field let a width change in tab A mark + // itself applied, so tab B kept measuring against the previous width. + private readonly Dictionary _fingerprintGates = []; // §6.2: setter-injection breaks the PayloadHandler → MainWindow → MessageList → PayloadHandler 3-cycle. // Wired by PayloadHandlerInitHostedService.StartAsync after both singletons exist. @@ -84,29 +79,37 @@ internal sealed class MessageList } // Width is passed in (ContentRegionAvail is only valid inside the draw child); - // enum modes widened to int so the tuple stays comparable. - private (float, float, bool, int, int, float) BuildLayoutFingerprint(float contentWidth) + // enum modes widened to int so the record stays comparable. UiScale is in here + // because it feeds CalcWordWrapPositionA -- a scale change rewraps every row. + private LayoutFingerprint BuildLayoutFingerprint(float contentWidth) { var (global, symbols) = _fonts.EffectiveFontFingerprint(); - return ( + return new LayoutFingerprint( global, symbols, Plugin.Config.UseCompactDensity, (int)Plugin.Config.NameFormMode, (int)Plugin.Config.WorldSuffixMode, - contentWidth + contentWidth, + ImGuiHelpers.GlobalScale ); } - // Drop the tab's cached heights when the layout fingerprint changed — one - // tuple compare per frame, a clear only on a real settings/resize change. + // Drop the tab's cached heights once the layout fingerprint has settled — one + // record compare per frame, a clear only after a real settings/resize change + // stopped moving. The gate is what keeps a slider drag from rebuilding the + // whole tab on every frame. private void InvalidateHeightCacheIfLayoutChanged(Tab tab, float contentWidth) { - var fingerprint = BuildLayoutFingerprint(contentWidth); - if (fingerprint.Equals(_lastLayoutFingerprint)) + if (!_fingerprintGates.TryGetValue(tab.Identifier, out var gate)) + { + gate = new LayoutFingerprintGate(); + _fingerprintGates[tab.Identifier] = gate; + } + + if (!gate.ShouldInvalidate(BuildLayoutFingerprint(contentWidth), Environment.TickCount64)) return; - _lastLayoutFingerprint = fingerprint; using var messages = tab.Messages.GetReadOnly(3); foreach (var msg in messages) { diff --git a/HellionChat/Util/LayoutFingerprint.cs b/HellionChat/Util/LayoutFingerprint.cs new file mode 100644 index 0000000..54d295d --- /dev/null +++ b/HellionChat/Util/LayoutFingerprint.cs @@ -0,0 +1,56 @@ +namespace HellionChat.Util; + +// Layout inputs that make a tab's cached row heights stale. Kept as a plain +// value type so the build suite can pin the settle logic without an ImGui frame. +internal readonly record struct LayoutFingerprint( + float FontGlobal, + float FontSymbols, + bool Compact, + int NameForm, + int WorldSuffix, + float Width, + float UiScale +); + +// Dragging a window edge or the Dalamud UI-scale slider moves the fingerprint on +// every single frame. Acting on each one drops the height cache, which sends the +// whole tab through the linear measure path (up to Config.MaxLinesToRender rows, +// default 2500). Waiting for the value to settle turns that into one rebuild per +// drag instead of one per frame. +internal sealed class LayoutFingerprintGate +{ + internal const long SettleMs = 200; + + private LayoutFingerprint? _applied; + private LayoutFingerprint _pending; + private long _pendingSinceMs; + + internal bool ShouldInvalidate(LayoutFingerprint current, long nowMs) + { + // First sight of this tab: nothing is cached yet, so there is nothing to + // drop and nothing to wait for. + if (_applied is null) + { + _applied = current; + _pending = current; + _pendingSinceMs = nowMs; + return false; + } + + if (current.Equals(_applied.Value)) + return false; + + if (!current.Equals(_pending)) + { + _pending = current; + _pendingSinceMs = nowMs; + return false; + } + + if (nowMs - _pendingSinceMs < SettleMs) + return false; + + _applied = current; + return true; + } +} From 0ef33934a2747a29200aabba5caa4a3a37a2573a Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 23:32:04 +0200 Subject: [PATCH 036/129] refactor(messages): plan compact rows like cards instead of assuming a fixed height Compact mode ran an ImGuiListClipper with CompactRowHeight = 18f. Two things were wrong with that. The number: at the default 12.75pt the font is 17px, and ChunkRenderer pushes ItemSpacing to zero for the whole chunk loop, so a single-line compact row advances the cursor by 17, not 18. The clipper seeded the cursor one pixel too low per row, which accumulates into a visible drift against the scrollbar. The assumption: compact rows are not constant height at all. DrawCompactRow renders content with wrap: true, and WrapEncodedLine submits one text item per wrapped line. At 620px and 17px type that kicks in around 70 characters, so most chat lines are multi-line. Both densities now share DrawRows/DrawLinearAndMeasure over the existing CardClipPlanner, with the row painter passed in. The fixed height and the clipper are gone. Also corrects the CompensatedDummy comment: the cached heights carry no trailing ItemSpacing (every row ends inside DrawChunks, where spacing is zero, and ImGui writes the advance at submission). The compensation is correct because it cancels the spacing the dummy itself appends. CardClipPlanStep drives the invalidation hook directly, which now sits behind the settle gate, so it walks a synthetic clock past the window. --- HellionChat/SelfTests/CardClipPlanStep.cs | 18 ++++- HellionChat/Ui/Components/MessageList.cs | 87 ++++++++++------------- 2 files changed, 52 insertions(+), 53 deletions(-) diff --git a/HellionChat/SelfTests/CardClipPlanStep.cs b/HellionChat/SelfTests/CardClipPlanStep.cs index 9442e6e..e90d5fa 100644 --- a/HellionChat/SelfTests/CardClipPlanStep.cs +++ b/HellionChat/SelfTests/CardClipPlanStep.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using Dalamud.Bindings.ImGui; using Dalamud.Plugin.SelfTest; +using HellionChat.Util; namespace HellionChat.SelfTests; @@ -55,7 +56,10 @@ internal sealed class CardClipPlanStep : ISelfTestStep int remaining; try { - messages.RunHeightCacheInvalidationForSelfTest(tab, 400f); + // v1.10.0/A1: the fingerprint gate waits for the value to settle, so + // the step walks a synthetic clock past the window instead of sleeping. + var clock = Environment.TickCount64; + messages.RunHeightCacheInvalidationForSelfTest(tab, 400f, clock); using (var snap = tab.Messages.GetReadOnly(3)) { if (snap.Count > 0) @@ -64,12 +68,20 @@ internal sealed class CardClipPlanStep : ISelfTestStep Plugin.Config.NameFormMode = savedForm == NameFormMode.Full ? NameFormMode.Initials : NameFormMode.Full; - remaining = messages.RunHeightCacheInvalidationForSelfTest(tab, 400f); + messages.RunHeightCacheInvalidationForSelfTest(tab, 400f, clock); + clock += LayoutFingerprintGate.SettleMs; + remaining = messages.RunHeightCacheInvalidationForSelfTest(tab, 400f, clock); } finally { Plugin.Config.NameFormMode = savedForm; - messages.RunHeightCacheInvalidationForSelfTest(tab, 400f); + var restore = Environment.TickCount64 + LayoutFingerprintGate.SettleMs * 2; + messages.RunHeightCacheInvalidationForSelfTest(tab, 400f, restore); + messages.RunHeightCacheInvalidationForSelfTest( + tab, + 400f, + restore + LayoutFingerprintGate.SettleMs + ); } if (remaining != 0) diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs index b0499b1..9386647 100644 --- a/HellionChat/Ui/Components/MessageList.cs +++ b/HellionChat/Ui/Components/MessageList.cs @@ -8,14 +8,11 @@ using HellionChat.Util; namespace HellionChat.Ui.Components; -// Virtualised message list. Compact mode uses ImGuiListClipper (constant row -// height); card mode rows wrap to arbitrary heights, so it runs its own -// prefix-sum clipper (CardClipPlanner) over a per-message height cache, dropped -// whenever the layout fingerprint (font/density/name-mode/width) changes. +// Virtualised message list. Both densities wrap to arbitrary heights, so both +// run the same prefix-sum clipper (CardClipPlanner) over a per-message height +// cache, dropped whenever the layout fingerprint settles on a new value. internal sealed class MessageList { - private const float CompactRowHeight = 18f; - private readonly FontManager _fonts; private readonly ChunkRenderer _chunkRenderer; @@ -70,10 +67,11 @@ internal sealed class MessageList ) => CardClipPlanner.Plan(heights, scrollY, viewportHeight); // SelfTest hook (B2): drives the live invalidation, returns the tab's remaining - // cached-height count so the step can assert the drop. - internal int RunHeightCacheInvalidationForSelfTest(Tab tab, float contentWidth) + // cached-height count so the step can assert the drop. nowMs is a parameter so + // the step can step past the settle window without sleeping (v1.10.0/A1). + internal int RunHeightCacheInvalidationForSelfTest(Tab tab, float contentWidth, long nowMs) { - InvalidateHeightCacheIfLayoutChanged(tab, contentWidth); + InvalidateHeightCacheIfLayoutChanged(tab, contentWidth, nowMs); using var messages = tab.Messages.GetReadOnly(3); return messages.Count(m => m.Height.ContainsKey(tab.Identifier)); } @@ -99,7 +97,7 @@ internal sealed class MessageList // record compare per frame, a clear only after a real settings/resize change // stopped moving. The gate is what keeps a slider drag from rebuilding the // whole tab on every frame. - private void InvalidateHeightCacheIfLayoutChanged(Tab tab, float contentWidth) + private void InvalidateHeightCacheIfLayoutChanged(Tab tab, float contentWidth, long nowMs) { if (!_fingerprintGates.TryGetValue(tab.Identifier, out var gate)) { @@ -107,7 +105,7 @@ internal sealed class MessageList _fingerprintGates[tab.Identifier] = gate; } - if (!gate.ShouldInvalidate(BuildLayoutFingerprint(contentWidth), Environment.TickCount64)) + if (!gate.ShouldInvalidate(BuildLayoutFingerprint(contentWidth), nowMs)) return; using var messages = tab.Messages.GetReadOnly(3); @@ -131,10 +129,14 @@ internal sealed class MessageList // and a runaway content-height computation. var compact = Plugin.Config.UseCompactDensity; - // B2: drop stale cached heights before the snapshot draw (card path only — - // compact rows are constant height). Width read here while it is valid. - if (!compact) - InvalidateHeightCacheIfLayoutChanged(tab, ImGui.GetContentRegionAvail().X); + // B2: drop stale cached heights before the snapshot draw. Both densities + // need this now -- compact rows are not constant height either, they wrap. + // Width read here while it is valid. + InvalidateHeightCacheIfLayoutChanged( + tab, + ImGui.GetContentRegionAvail().X, + Environment.TickCount64 + ); using var messages = tab.Messages.GetReadOnly(3); @@ -143,10 +145,7 @@ internal sealed class MessageList // the parent child's scroll state, which is the one MainWindow owns. var pinnedToBottom = ImGui.GetScrollY() >= ImGui.GetScrollMaxY() - 1f; - if (compact) - DrawCompact(messages); - else - DrawCard(tab, messages); + DrawRows(tab, messages, compact ? DrawCompactRow : DrawCardRow); // B3-5: scroll values are frame-constant inside the child, so this // reflects the current frame's state wherever it runs; kept after the @@ -213,28 +212,6 @@ internal sealed class MessageList _scrollToBottomRequested = true; } - private void DrawCompact(IReadOnlyList messages) - { - unsafe - { - var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper()); - try - { - clipper.Begin(messages.Count, CompactRowHeight); - while (clipper.Step()) - { - for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) - DrawCompactRow(messages[i]); - } - clipper.End(); - } - finally - { - clipper.Destroy(); - } - } - } - private void DrawCompactRow(Message message) { // B2-1/B2-2: render the sender through DrawChunks (the name-aware path @@ -259,7 +236,9 @@ internal sealed class MessageList _chunkRenderer.DrawChunks(message.Content, wrap: true, handler: _handler, lineWidth: 0f); } - private void DrawCard(Tab tab, IReadOnlyList messages) + // Shared by both densities: compact rows wrap too, so neither has a constant + // height the ImGuiListClipper could work with. drawRow is the only difference. + private void DrawRows(Tab tab, IReadOnlyList messages, Action drawRow) { var tabId = tab.Identifier; var count = messages.Count; @@ -280,15 +259,18 @@ internal sealed class MessageList if (!allCached) { - DrawCardLinearAndMeasure(tabId, messages); + DrawLinearAndMeasure(tabId, messages, drawRow); return; } var plan = CardClipPlanner.Plan(heights, ImGui.GetScrollY(), ImGui.GetWindowSize().Y); - // Cached heights already include one trailing ItemSpacing.y; a dummy adds its - // own, which would shove the first visible row down one spacing (jitter). - // Subtract one spacing per dummy so offset AND total content height stay exact. + // A dummy is submitted outside any style push, so it appends its own + // trailing ItemSpacing.y. Subtracting one spacing per dummy makes the dummy + // advance the cursor by exactly the planned height. (The measured row + // heights carry no trailing spacing: every row ends inside DrawChunks, + // which pushes ItemSpacing to zero, and ImGui writes the advance at item + // submission time.) var spacingY = ImGui.GetStyle().ItemSpacing.Y; float CompensatedDummy(float planned) => Math.Max(0f, planned - spacingY); @@ -307,7 +289,7 @@ internal sealed class MessageList { var msg = messages[i]; var before = ImGui.GetCursorPosY(); - DrawCardRow(msg); + drawRow(msg); var after = ImGui.GetCursorPosY(); msg.Height[tabId] = after - before; msg.IsVisible[tabId] = true; @@ -318,13 +300,18 @@ internal sealed class MessageList } // First-frame / post-invalidation fallback: draw + measure every row into the - // cache so the next frame can take the planned path. - private void DrawCardLinearAndMeasure(Guid tabId, IReadOnlyList messages) + // cache so the next frame can take the planned path. The settle gate on the + // layout fingerprint is what keeps a resize drag from landing here every frame. + private void DrawLinearAndMeasure( + Guid tabId, + IReadOnlyList messages, + Action drawRow + ) { foreach (var msg in messages) { var before = ImGui.GetCursorPosY(); - DrawCardRow(msg); + drawRow(msg); var after = ImGui.GetCursorPosY(); msg.Height[tabId] = after - before; msg.IsVisible[tabId] = ImGui.IsItemVisible(); From 28b97b3f707ffb1c47345e476bfbce010145a982 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 23:33:52 +0200 Subject: [PATCH 037/129] feat(style): add a scale-aware metrics layer Layout constants lived as bare floats in the components: row heights, hit widths, insets, reserve widths. None of them multiplied by GlobalScale, so at 125% or 150% display scaling the text grows and the boxes do not. The quick buttons stop fitting into their 130px column. Metrics holds the design values and exposes scaled properties. The Raw constants stay reachable for the few places that must store or compare an unscaled value: Sidebar.GetWidth (a SelfTest pins it against raw numbers) and the width slider bounds. Scale is pinned once per frame against ImGui.GetFrameCount(). GlobalScale is a live value the Dalamud slider moves on every dragged frame, so reading it per access can shift a CalcSize away from its matching Draw inside one frame, and costs three native calls each time. Not in ThemeLayout: that record is serialised into theme JSON, and layout customisation is out of scope per the master spec. MetricsMath is the pure half so the build suite can pin the arithmetic. Text heights are measured rather than scaled -- the font is built from Config.FontSizeV2, which GlobalScale does not feed into. --- HellionChat/Ui/StyleEngine/Metrics.cs | 112 ++++++++++++++++++++++++++ HellionChat/Util/MetricsMath.cs | 13 +++ 2 files changed, 125 insertions(+) create mode 100644 HellionChat/Ui/StyleEngine/Metrics.cs create mode 100644 HellionChat/Util/MetricsMath.cs diff --git a/HellionChat/Ui/StyleEngine/Metrics.cs b/HellionChat/Ui/StyleEngine/Metrics.cs new file mode 100644 index 0000000..8ed96f0 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Metrics.cs @@ -0,0 +1,112 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Utility; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine; + +// Layout values, authored at 100% display scaling. Draw code reads the scaled +// properties; the Raw constants exist only for the few places that must store or +// compare an unscaled value (Sidebar.GetWidth, the width slider bounds). +// +// Deliberately NOT part of ThemeLayout: that record is serialised into theme +// JSON, and layout customisation is out of scope per the master spec. +internal static class Metrics +{ + // --- Sidebar --- + internal const float SidebarIconOnlyWidthRaw = 38f; + internal const float SidebarMinWidthRaw = 40f; + internal const float SidebarMaxWidthRaw = 300f; + internal const float SidebarRowHeightRaw = 32f; + internal const float SidebarPopOutHitWidthRaw = 22f; + internal const float SidebarGreetedHitWidthRaw = 22f; + internal const float SidebarHitSlackRaw = 4f; + internal const float SidebarMinDrawWidthRaw = 2f; + internal const float SidebarUnreadRadiusRaw = 4f; + internal const float SidebarGlyphInsetRaw = 4f; + + // --- Input bar --- + internal const float InputBarHeightRaw = 32f; + internal const float InputPillHeightRaw = 22f; + internal const float InputPillPaddingXRaw = 8f; + internal const float InputPillRoundingRaw = 6f; + internal const float InputQuickButtonsReserveRaw = 130f; + + // --- Honorific header --- + internal const float HonorificHeightRaw = 30f; + internal const float HonorificInsetRaw = 8f; + internal const float HonorificBracketGapRaw = 6f; + + // --- Top tab bar --- + internal const float TopTabUnreadRadiusRaw = 3.5f; + internal const float TopTabUnreadInsetRaw = 4f; + + // --- Status bar --- + internal const float StatusBorderThicknessRaw = 1f; + internal const float StatusTopSpacerRaw = 2f; + internal const float StatusMinOtherSlotsWidthRaw = 200f; + internal const float StatusDotRadiusRaw = 4f; + + // --- Message list --- + internal const float MessageDummyWidthRaw = 10f; + + private static float _cachedScale = 1f; + private static int _cachedFrame = -1; + + // GlobalScale resolves to ImGui.GetIO().FontGlobalScale, which the Dalamud + // settings slider moves on every frame while it is dragged. Pinning it once + // per frame keeps a CalcSize and its matching Draw on the same value, and + // saves three native calls per access. + internal static float Scale + { + get + { + var frame = ImGui.GetFrameCount(); + if (frame == _cachedFrame) + return _cachedScale; + + _cachedFrame = frame; + _cachedScale = ImGuiHelpers.GlobalScale; + return _cachedScale; + } + } + + internal static float SidebarIconOnlyWidth => MetricsMath.Scale(SidebarIconOnlyWidthRaw, Scale); + internal static float SidebarRowHeight => MetricsMath.Scale(SidebarRowHeightRaw, Scale); + internal static float SidebarPopOutHitWidth => + MetricsMath.Scale(SidebarPopOutHitWidthRaw, Scale); + internal static float SidebarGreetedHitWidth => + MetricsMath.Scale(SidebarGreetedHitWidthRaw, Scale); + internal static float SidebarHitSlack => MetricsMath.Scale(SidebarHitSlackRaw, Scale); + internal static float SidebarMinDrawWidth => MetricsMath.Scale(SidebarMinDrawWidthRaw, Scale); + internal static float SidebarUnreadRadius => MetricsMath.Scale(SidebarUnreadRadiusRaw, Scale); + internal static float SidebarGlyphInset => MetricsMath.Scale(SidebarGlyphInsetRaw, Scale); + + internal static float InputBarHeight => MetricsMath.Scale(InputBarHeightRaw, Scale); + internal static float InputPillHeight => MetricsMath.Scale(InputPillHeightRaw, Scale); + internal static float InputPillPaddingX => MetricsMath.Scale(InputPillPaddingXRaw, Scale); + internal static float InputPillRounding => MetricsMath.Scale(InputPillRoundingRaw, Scale); + internal static float InputQuickButtonsReserve => + MetricsMath.Scale(InputQuickButtonsReserveRaw, Scale); + + internal static float HonorificHeight => MetricsMath.Scale(HonorificHeightRaw, Scale); + internal static float HonorificInset => MetricsMath.Scale(HonorificInsetRaw, Scale); + internal static float HonorificBracketGap => MetricsMath.Scale(HonorificBracketGapRaw, Scale); + + internal static float TopTabUnreadRadius => MetricsMath.Scale(TopTabUnreadRadiusRaw, Scale); + internal static float TopTabUnreadInset => MetricsMath.Scale(TopTabUnreadInsetRaw, Scale); + + internal static float StatusBorderThickness => + MetricsMath.Scale(StatusBorderThicknessRaw, Scale); + internal static float StatusTopSpacer => MathF.Round(StatusTopSpacerRaw * Scale); + internal static float StatusMinOtherSlotsWidth => + MetricsMath.Scale(StatusMinOtherSlotsWidthRaw, Scale); + internal static float StatusDotRadius => MetricsMath.Scale(StatusDotRadiusRaw, Scale); + + internal static float MessageDummyWidth => MetricsMath.Scale(MessageDummyWidthRaw, Scale); + + // Text-dependent heights are measured, never scaled: the font is built from + // Config.FontSizeV2, which GlobalScale does not feed into. StatusBar.Height + // has done it this way since 1.8.x. + internal static float CenterY(float rowHeight) => + MetricsMath.CenterY(rowHeight, ImGui.GetTextLineHeight()); +} diff --git a/HellionChat/Util/MetricsMath.cs b/HellionChat/Util/MetricsMath.cs new file mode 100644 index 0000000..afedc0d --- /dev/null +++ b/HellionChat/Util/MetricsMath.cs @@ -0,0 +1,13 @@ +namespace HellionChat.Util; + +// Pure scaling arithmetic, split out from Metrics so the build suite can pin it +// without standing up an ImGui frame. +internal static class MetricsMath +{ + internal static float Scale(float raw, float scale) => raw * scale; + + // Vertical centering for text inside a fixed-height row. Frozen offsets keep + // their mis-centering at every other font size; a computed one does not. + internal static float CenterY(float rowHeight, float textHeight) => + (rowHeight - textHeight) * 0.5f; +} From bf0c3e2bd76efe3fd129b0da4fa27353693fcd5f Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 23:35:54 +0200 Subject: [PATCH 038/129] feat(style): hold hover state across frames instead of one-shot sweeps DrawHoverSheen measured its own elapsed time against DateTime.UtcNow and gave up after 0.65s, so a row stopped reacting while the pointer was still on it. There was no held value to interpolate colours against. HoverState keeps one 0..1 intensity per element, rising at 14/s and falling at 8/s. Slower out than in is what makes the fade read as deliberate. Query and advance are separate on purpose. Several SelfTest steps call Sidebar.Draw against the live tab list, so the same element gets submitted up to three times in one frame, twice from a window the mouse is not over. If the query advanced the value, the last caller would win and the fade would run backwards. Query only ORs the hover flag; BeginFrame does all the moving and the eviction. BeginFrame sits above the HideInLoadingScreens and New Game+ early returns, so a hidden main window still lets pop-out hovers fade out instead of freezing mid-blend. FrameLerp gains Ramp: Smooth approaches asymptotically and never arrives, so a value driven by it would never reach zero and never become evictable. ReduceMotion short-circuits before the map is touched, returning a hard 0 or 1. An infinite rate would produce NaN and poison the entry for the session. --- HellionChat/Plugin.cs | 5 ++ HellionChat/Ui/StyleEngine/HoverState.cs | 78 ++++++++++++++++++++++++ HellionChat/Util/FrameLerp.cs | 8 +++ HellionChat/Util/HoverMath.cs | 23 +++++++ 4 files changed, 114 insertions(+) create mode 100644 HellionChat/Ui/StyleEngine/HoverState.cs create mode 100644 HellionChat/Util/HoverMath.cs diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index fda84d3..97929c6 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -1059,6 +1059,11 @@ public sealed class Plugin : IAsyncDalamudPlugin Config.WindowOpacity ); + // Advance every held hover value once, before any window draws. Sits + // above the early returns below so a hidden main window still lets + // pop-out hovers fade instead of freezing mid-blend. + Ui.StyleEngine.HoverState.BeginFrame(); + if (Config.HideInLoadingScreens && Condition[ConditionFlag.BetweenAreas]) { TypingIpc.Update(); diff --git a/HellionChat/Ui/StyleEngine/HoverState.cs b/HellionChat/Ui/StyleEngine/HoverState.cs new file mode 100644 index 0000000..f3d194e --- /dev/null +++ b/HellionChat/Ui/StyleEngine/HoverState.cs @@ -0,0 +1,78 @@ +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine; + +// Held hover intensity per element, 0..1. Replaces the one-shot sheen timer, +// which stopped after 0.65s while the pointer was still on the row. +// +// Query and advance are deliberately separate. Several SelfTest steps call +// Sidebar.Draw against the live tab list, so the same element can be submitted +// three times in one frame -- twice from a window the mouse is not over. If the +// query advanced the value, the last caller would win and the fade would run +// backwards. Query only marks; BeginFrame does all the moving. Pattern anchor: +// LightlessSync Selune.cs:106-146. +internal static class HoverState +{ + private sealed class Entry + { + internal float Value; + internal bool Hovered; + } + + private static readonly Dictionary Entries = []; + private static readonly List Evicted = []; + + internal static int TrackedCount => Entries.Count; + + // Once per frame from Plugin.Draw, before any window renders. + internal static void BeginFrame() => Advance(ImGui.GetIO().DeltaTime); + + // SelfTest seam: the alloc step submits many elements inside a single frame, + // so it needs to step the clock without waiting for real frames. + internal static void AdvanceForTest(float deltaTime) => Advance(deltaTime); + + internal static void Reset() + { + Entries.Clear(); + Evicted.Clear(); + } + + internal static float Query(uint id, bool hovered) + { + // ReduceMotion short-circuits before touching the map: no entry, no fade, + // nothing to evict. Returning a hard 0/1 is also NaN-safe, which setting + // an infinite rate would not be. + if (Plugin.Config.ReduceMotion) + return hovered ? 1f : 0f; + + if (!Entries.TryGetValue(id, out var entry)) + { + entry = new Entry(); + Entries[id] = entry; + } + + // OR, never assign: a second caller in the same frame that is not hovered + // must not cancel the first one that is. + entry.Hovered |= hovered; + return entry.Value; + } + + private static void Advance(float deltaTime) + { + if (deltaTime <= 0f) + return; + + Evicted.Clear(); + foreach (var (id, entry) in Entries) + { + entry.Value = HoverMath.Step(entry.Value, entry.Hovered, deltaTime); + if (HoverMath.ShouldEvict(entry.Value, entry.Hovered)) + Evicted.Add(id); + entry.Hovered = false; + } + + foreach (var id in Evicted) + Entries.Remove(id); + } +} diff --git a/HellionChat/Util/FrameLerp.cs b/HellionChat/Util/FrameLerp.cs index 2593f6f..42c673e 100644 --- a/HellionChat/Util/FrameLerp.cs +++ b/HellionChat/Util/FrameLerp.cs @@ -14,4 +14,12 @@ internal static class FrameLerp var factor = Math.Min(1f, speed * deltaTime); return current + (target - current) * factor; } + + // Linear ramp at `speed` units per second, clamped at the target. Smooth() + // approaches asymptotically and never actually arrives, so a hover value + // driven by it would never reach zero and never become evictable. + public static float Ramp(float current, float target, float speed, float deltaTime) => + target > current + ? MathF.Min(target, current + speed * deltaTime) + : MathF.Max(target, current - speed * deltaTime); } diff --git a/HellionChat/Util/HoverMath.cs b/HellionChat/Util/HoverMath.cs new file mode 100644 index 0000000..7a2d624 --- /dev/null +++ b/HellionChat/Util/HoverMath.cs @@ -0,0 +1,23 @@ +namespace HellionChat.Util; + +// State rules for the held hover value, split from HoverState so the build suite +// can pin them without an ImGui frame. Rates follow Lightless (Selune.cs:36-37): +// slower out than in is what makes a fade read as deliberate rather than laggy. +internal static class HoverMath +{ + internal const float FadeInPerSecond = 14f; + internal const float FadeOutPerSecond = 8f; + + // Below this an entry is indistinguishable from zero and can be dropped. + internal const float EvictBelow = 0.001f; + + internal static float Step(float current, bool hovered, float deltaTime) => + FrameLerp.Ramp( + current, + hovered ? 1f : 0f, + hovered ? FadeInPerSecond : FadeOutPerSecond, + deltaTime + ); + + internal static bool ShouldEvict(float value, bool hovered) => !hovered && value <= EvictBelow; +} From 31fa410875e54c14caf9aa171238066b19df9d99 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 23:37:24 +0200 Subject: [PATCH 039/129] fix(style): drive the hover sheen from held state, drop the leaking start map The sheen kept its own Dictionary of start timestamps and only cleared an entry in the un-hover branch. A row that disappeared while the pointer was on it left its entry behind until the plugin reloaded, which is exactly what happens to temp tabs under the LRU limit. It now takes the held intensity from HoverState and draws on the rising edge only. The alpha falls off as the value climbs, so the sweep has faded out by the time the surface underneath is fully in. On the way out it simply does not run, which is what stops it from travelling backwards -- the old SheenStarts.Remove prevented that by resetting, and dropping the map without this guard would have reintroduced it. The sidebar call site built "sidebar.tab.{guid}" per row per frame, two allocations each. Master spec 5.3 asks for constant keys, and 7.5 for a stable allocation count. It now uses ImGui.GetID("row"u8), which is allocation-free and seeded from the window's ID stack, so the same literal stays distinct per window and per PushID'd tab. HoverSheenAllocStep pinned the old dictionary contract and is replaced by HoverStateFootprintStep, which pins the same property against the registry: repeated queries add no entries, and an element that stops being queried leaves the map instead of leaking. --- HellionChat/Plugin.cs | 2 +- HellionChat/SelfTests/HoverSheenAllocStep.cs | 61 -------------- .../SelfTests/HoverStateFootprintStep.cs | 79 +++++++++++++++++++ HellionChat/Ui/Components/Sidebar.cs | 7 +- .../Ui/StyleEngine/DrawListExtensions.cs | 40 +++------- 5 files changed, 96 insertions(+), 93 deletions(-) delete mode 100644 HellionChat/SelfTests/HoverSheenAllocStep.cs create mode 100644 HellionChat/SelfTests/HoverStateFootprintStep.cs diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 97929c6..713b7f0 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -429,7 +429,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.TypingIpcStateStep(this), new SelfTests.ConfigMigrationV23Step(this), new SelfTests.ChannelPopoutBindStep(this), - new SelfTests.HoverSheenAllocStep(this), + new SelfTests.HoverStateFootprintStep(), new SelfTests.HonorificHeaderRenderStep(this), new SelfTests.AboutIntegrationsStatusStep(this), new SelfTests.PerformanceBaselineStep(this), diff --git a/HellionChat/SelfTests/HoverSheenAllocStep.cs b/HellionChat/SelfTests/HoverSheenAllocStep.cs deleted file mode 100644 index b7a557e..0000000 --- a/HellionChat/SelfTests/HoverSheenAllocStep.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Dalamud.Bindings.ImGui; -using Dalamud.Plugin.SelfTest; -using HellionChat.Themes; -using HellionChat.Ui.StyleEngine; - -namespace HellionChat.SelfTests; - -// Master-spec scope note: the hover-sheen key dictionary must not grow -// frame-by-frame on a constant-key call site. This probe drives 100 -// hovered frames against three constant keys and asserts the dictionary -// only holds those three keys at the end — re-hover does not duplicate -// entries, and the un-hover branch clears the stale start timestamp. -internal sealed class HoverSheenAllocStep : ISelfTestStep -{ - private readonly Plugin plugin; - - public HoverSheenAllocStep(Plugin plugin) - { - this.plugin = plugin; - } - - public string Name => "Hellion Chat - HoverSheen dictionary footprint"; - - public SelfTestStepResult RunStep() - { - // Probe runs outside a regular draw frame, so the sheen path - // would normally not have a window draw-list. We pull the - // foreground draw-list directly — it accepts AddRectFilled - // even without an active window scope. - var dl = ImGui.GetForegroundDrawList(); - var theme = plugin.ThemeRegistry.Active; - var resolver = new TokenResolver(); - var accent = resolver.Resolve(Token.AccentPrimary, theme.Colors); - var min = new System.Numerics.Vector2(0, 0); - var max = new System.Numerics.Vector2(10, 10); - - string[] keys = ["selftest.row.a", "selftest.row.b", "selftest.row.c"]; - for (var frame = 0; frame < 100; frame++) - foreach (var key in keys) - dl.DrawHoverSheen(min, max, accent, key, hovered: true); - - // Hovered loop must have registered exactly the three constant keys - // (no per-frame growth/duplication) — assert via observable state, - // not an unconditional pass (K7 false-green fix). - foreach (var key in keys) - if (!DrawListExtensions.IsSheenTracked(key)) - return SelfTestStepResult.Fail; - - // Un-hover sweep must drop every entry through the cleanup branch. - foreach (var key in keys) - dl.DrawHoverSheen(min, max, accent, key, hovered: false); - - foreach (var key in keys) - if (DrawListExtensions.IsSheenTracked(key)) - return SelfTestStepResult.Fail; - - return SelfTestStepResult.Pass; - } - - public void CleanUp() { } -} diff --git a/HellionChat/SelfTests/HoverStateFootprintStep.cs b/HellionChat/SelfTests/HoverStateFootprintStep.cs new file mode 100644 index 0000000..e726155 --- /dev/null +++ b/HellionChat/SelfTests/HoverStateFootprintStep.cs @@ -0,0 +1,79 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Ui.StyleEngine; + +namespace HellionChat.SelfTests; + +// Master-spec §7.5 scope note: the hover registry must not grow frame by frame. +// Successor to HoverSheenAllocStep, which pinned the same contract against the +// old sheen start-timestamp dictionary. +// +// Two properties matter. Repeated queries for the same element must not add +// entries, and once an element stops being queried its entry must actually +// leave the map -- the old dictionary only cleared on an explicit un-hover +// call, so a row that vanished while hovered leaked until the plugin reloaded. +internal sealed class HoverStateFootprintStep : ISelfTestStep +{ + public string Name => "Hellion Chat - HoverState registry footprint"; + + public SelfTestStepResult RunStep() + { + // Runs outside a normal draw frame, so the clock is stepped by hand + // rather than by HoverState.BeginFrame. + const float Frame = 1f / 60f; + + var saved = Plugin.Config.ReduceMotion; + try + { + // The short-circuit would skip the map entirely and make this probe + // vacuous. + Plugin.Config.ReduceMotion = false; + HoverState.Reset(); + + uint[] ids = + [ + ImGui.GetID("selftest.row.a"), + ImGui.GetID("selftest.row.b"), + ImGui.GetID("selftest.row.c"), + ]; + + for (var frame = 0; frame < 100; frame++) + { + foreach (var id in ids) + HoverState.Query(id, hovered: true); + HoverState.AdvanceForTest(Frame); + } + + if (HoverState.TrackedCount != ids.Length) + { + var msg = $"Registry holds {HoverState.TrackedCount}, expected {ids.Length}"; + ImGui.Text(msg); + SelfTestReport.Append(Name, "FAIL", new[] { msg }); + return SelfTestStepResult.Fail; + } + + // Stop querying entirely, the way a removed row would. Fade-out runs + // at 8/s, so 1s of frames is comfortably past zero. + for (var frame = 0; frame < 60; frame++) + HoverState.AdvanceForTest(Frame); + + if (HoverState.TrackedCount != 0) + { + var msg = $"Registry leaked {HoverState.TrackedCount} entries after fade-out"; + ImGui.Text(msg); + SelfTestReport.Append(Name, "FAIL", new[] { msg }); + return SelfTestStepResult.Fail; + } + + SelfTestReport.Append(Name, "PASS", new[] { "registry empty after fade-out" }); + return SelfTestStepResult.Pass; + } + finally + { + Plugin.Config.ReduceMotion = saved; + HoverState.Reset(); + } + } + + public void CleanUp() { } +} diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index d8b05f4..fcbd0fe 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -251,11 +251,16 @@ internal sealed class Sidebar TabLifecycleHelpers.OnTabActivated(tab, previous); } + // GetID is seeded from the window's ID stack, so the same "row" literal + // stays distinct per window and per PushID'd tab. The old interpolated + // key allocated two strings per row per frame. + var hoverId = ImGui.GetID("row"u8); + var hoverAmount = StyleEngine.HoverState.Query(hoverId, rowHovered); dl.DrawHoverSheen( origin, origin + new Vector2(avail, RowHeight), accentRgba, - $"sidebar.tab.{tab.Identifier}", + hoverAmount, rowHovered ); diff --git a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs index dc47394..c0824a4 100644 --- a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs +++ b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs @@ -9,13 +9,9 @@ namespace HellionChat.Ui.StyleEngine; // Custom-drawing primitives for the v2.x style layer. Callers feed RGBA // uints (typically resolved via TokenResolver) and these methods convert to -// ABGR before delegating to ImDrawList. Hover-sheen state lives in a small -// static dictionary keyed by constant strings — keep keys constant and -// scope to static UI elements so the per-key footprint stays bounded. +// ABGR before delegating to ImDrawList. internal static class DrawListExtensions { - private const float SheenDurationSeconds = 0.65f; - // A1 accent-tint (Variante A): how far the white sweep is pulled toward // the element's accent hue. Kept low so the sheen reads as a tinted // highlight, not a saturated accent flash (effect level "subtle"). @@ -24,41 +20,25 @@ internal static class DrawListExtensions // Peak sheen alpha (low so the highlight stays subtle); DrawHoverSheen applies the falloff. private const byte SheenPeakAlpha = 0x40; - private static readonly Dictionary SheenStarts = new(); - - // Test-observability hook for HoverSheenAllocStep: lets the self-test - // assert the hover/un-hover dictionary contract via real state instead - // of an unconditional pass (K7 false-green fix). Not a runtime path. - internal static bool IsSheenTracked(string elementId) => SheenStarts.ContainsKey(elementId); - + // Rides the held hover intensity instead of its own timer, so it can no + // longer leak an entry when an element disappears while hovered. Drawn on + // the rising edge only: the alpha falls off as the value climbs, so the + // sweep has faded out by the time the surface underneath is fully in. + // On the way out the surface fades and the sweep simply does not run, + // which is what keeps it from travelling backwards. public static void DrawHoverSheen( this ImDrawListPtr dl, Vector2 min, Vector2 max, uint accentRgba, - string elementId, + float intensity, bool hovered ) { - if (!hovered) - { - // Reset so re-hover restarts the sweep instead of catching the - // tail end of a stale animation. - SheenStarts.Remove(elementId); - return; - } - - if (!SheenStarts.TryGetValue(elementId, out var started)) - { - started = DateTime.UtcNow; - SheenStarts[elementId] = started; - } - - var elapsed = (DateTime.UtcNow - started).TotalSeconds; - if (elapsed > SheenDurationSeconds) + if (!hovered || intensity <= 0f || intensity >= 1f) return; - var t = (float)(elapsed / SheenDurationSeconds); + var t = intensity; var alpha = (byte)Math.Round(SheenPeakAlpha * (1f - t)); // Tint the sweep toward the accent hue, then stamp the falloff alpha. // accentRgba is RGBA; convert to ABGR FIRST or the draw-list swaps R/B. From d6ad81e8cad6b81fbf88a271c2e82851fbf3aa5d Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 23:40:15 +0200 Subject: [PATCH 040/129] feat(style): add the drawn-widget set Five widgets, each with a real consumer inside this cycle: Row for sidebar rows, Badge for unread counts, IconButton for the popout and greeted toggles, LineDivider for section headers, Pill for the channel pill and the status bar. Row carries no Tab on purpose. v1.11.0 moves the sidebar from tab rows to channel rows, and if the chrome sits in a Tab-free widget that cycle only has to change the caller. IconButton is deliberately small. Its two sidebar call sites differ in placement, visibility rule, glyph choice, and one of them bumps a SelfTest counter. Taking all of that as parameters would produce a widget that is five switches and no behaviour, so the caller keeps them. LineDivider brings its own vertical padding and submits its own layout item. The sidebar is about to push ItemSpacing to zero so rows sit flush, and a divider relying on spacing would collapse onto its neighbours there. Colours go through WidgetPalette. TokenResolver returns RGBA and ImDrawList expects ABGR; with five widgets that is the same swap-red-and-blue trap five times over. Sizes go through WidgetGeometry, which is pure so the build suite can pin it, and clamps every result to a positive extent -- ImGui asserts on a zero-sized InvisibleButton and takes the window with it. --- HellionChat/Ui/StyleEngine/Widgets/Badge.cs | 64 ++++++++++++++++ .../Ui/StyleEngine/Widgets/IconButton.cs | 70 +++++++++++++++++ .../Ui/StyleEngine/Widgets/LineDivider.cs | 67 +++++++++++++++++ HellionChat/Ui/StyleEngine/Widgets/Pill.cs | 71 ++++++++++++++++++ HellionChat/Ui/StyleEngine/Widgets/Row.cs | 75 +++++++++++++++++++ .../Ui/StyleEngine/Widgets/WidgetPalette.cs | 27 +++++++ HellionChat/Util/WidgetGeometry.cs | 58 ++++++++++++++ 7 files changed, 432 insertions(+) create mode 100644 HellionChat/Ui/StyleEngine/Widgets/Badge.cs create mode 100644 HellionChat/Ui/StyleEngine/Widgets/IconButton.cs create mode 100644 HellionChat/Ui/StyleEngine/Widgets/LineDivider.cs create mode 100644 HellionChat/Ui/StyleEngine/Widgets/Pill.cs create mode 100644 HellionChat/Ui/StyleEngine/Widgets/Row.cs create mode 100644 HellionChat/Ui/StyleEngine/Widgets/WidgetPalette.cs create mode 100644 HellionChat/Util/WidgetGeometry.cs diff --git a/HellionChat/Ui/StyleEngine/Widgets/Badge.cs b/HellionChat/Ui/StyleEngine/Widgets/Badge.cs new file mode 100644 index 0000000..44f29cb --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/Badge.cs @@ -0,0 +1,64 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +internal readonly record struct BadgeStyle +{ + public BadgeStyle() { } + + public float PaddingX { get; init; } = 4f; + public float Height { get; init; } = 14f; + public float FillAlpha { get; init; } = 0.22f; + public int MaxCount { get; init; } = 99; +} + +// Unread counter. Replaces a bare dot, and deliberately not in StatusDanger: +// red reads as an error, an unread message is not one. +internal static class Badge +{ + internal static string Format(int count, int maxCount) => + count > maxCount ? $"{maxCount}+" : count.ToString(); + + internal static Vector2 CalcSize(int count, BadgeStyle? styleOverride = null) + { + var style = styleOverride ?? new BadgeStyle(); + var text = Format(count, style.MaxCount); + return WidgetGeometry.Badge( + ImGui.CalcTextSize(text), + style.PaddingX * Metrics.Scale, + style.Height * Metrics.Scale + ); + } + + // Caller must not be inside a pushed icon font: the FontAwesome atlas has no + // ASCII digits, so the count would render as blanks. + internal static void Draw( + Vector2 origin, + int count, + uint accentAbgr, + uint textAbgr, + BadgeStyle? styleOverride = null + ) + { + if (count <= 0) + return; + + var style = styleOverride ?? new BadgeStyle(); + var text = Format(count, style.MaxCount); + var size = CalcSize(count, style); + var dl = ImGui.GetWindowDrawList(); + var max = origin + size; + + var fillAbgr = + (accentAbgr & 0x00FFFFFFu) + | ((uint)Math.Clamp(MathF.Round(255f * style.FillAlpha), 0f, 255f) << 24); + + dl.AddRectFilled(origin, max, fillAbgr, size.Y * 0.5f); + dl.AddRect(origin, max, accentAbgr, size.Y * 0.5f); + + var textSize = ImGui.CalcTextSize(text); + dl.AddText(origin + (size - textSize) * 0.5f, textAbgr, text); + } +} diff --git a/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs b/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs new file mode 100644 index 0000000..488c5e9 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs @@ -0,0 +1,70 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.ManagedFontAtlas; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +internal readonly record struct IconButtonStyle +{ + public IconButtonStyle() { } + + public float HoverFillAlpha { get; init; } = 0.18f; +} + +// Deliberately small. Its two call sites in the sidebar differ in placement +// (SameLine vs. absolute cursor), visibility rule, glyph choice and one of them +// bumps a SelfTest counter. Folding all of that in would produce a widget that +// is five switches and no behaviour, so the caller keeps those and this draws +// the hit area plus the glyph. +internal static class IconButton +{ + internal static Vector2 CalcSize(float width, float height) => + WidgetGeometry.IconButton(width, height); + + internal static (bool Clicked, bool Hovered) Draw( + uint id, + Vector2 size, + FontAwesomeIcon? glyph, + uint glyphAbgr, + uint hoverFillAbgr, + IFontHandle font, + IconButtonStyle? styleOverride = null + ) + { + var style = styleOverride ?? new IconButtonStyle(); + var clamped = WidgetGeometry.IconButton(size.X, size.Y); + var origin = ImGui.GetCursorScreenPos(); + + ImGui.InvisibleButton($"##hellion-iconbtn-{id}", clamped); + var hovered = ImGui.IsItemHovered(); + var clicked = ImGui.IsItemClicked(); + + var amount = HoverState.Query(id, hovered); + var dl = ImGui.GetWindowDrawList(); + + if (amount > 0f) + { + var a = (uint)Math.Clamp(MathF.Round(255f * style.HoverFillAlpha * amount), 0f, 255f); + dl.AddRectFilled( + origin, + origin + clamped, + (hoverFillAbgr & 0x00FFFFFFu) | (a << 24), + clamped.Y * 0.2f + ); + } + + if (glyph is { } icon) + { + using (font.Push()) + { + var text = icon.ToIconString(); + var textSize = ImGui.CalcTextSize(text); + dl.AddText(origin + (clamped - textSize) * 0.5f, glyphAbgr, text); + } + } + + return (clicked, hovered); + } +} diff --git a/HellionChat/Ui/StyleEngine/Widgets/LineDivider.cs b/HellionChat/Ui/StyleEngine/Widgets/LineDivider.cs new file mode 100644 index 0000000..6302de2 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/LineDivider.cs @@ -0,0 +1,67 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +internal readonly record struct LineDividerStyle +{ + public LineDividerStyle() { } + + public float Thickness { get; init; } = 1f; + public float PadY { get; init; } = 6f; + public float LabelInsetX { get; init; } = 6f; +} + +// Section marker: a rule with an optional caption underneath. Carries its own +// vertical padding and submits its own layout item, because the sidebar pushes +// ItemSpacing to zero so rows can sit flush -- a divider relying on spacing +// would collapse onto its neighbours there. +internal static class LineDivider +{ + internal static Vector2 CalcSize(string? label, LineDividerStyle? styleOverride = null) + { + var style = styleOverride ?? new LineDividerStyle(); + var scale = Metrics.Scale; + var labelHeight = label is null ? 0f : ImGui.GetTextLineHeight(); + return WidgetGeometry.LineDivider( + ImGui.GetContentRegionAvail().X, + style.Thickness * scale, + style.PadY * scale, + labelHeight + ); + } + + internal static void Draw( + string? label, + uint lineAbgr, + uint labelAbgr, + LineDividerStyle? styleOverride = null + ) + { + var style = styleOverride ?? new LineDividerStyle(); + var scale = Metrics.Scale; + var size = CalcSize(label, style); + var origin = ImGui.GetCursorScreenPos(); + var dl = ImGui.GetWindowDrawList(); + + var lineY = origin.Y + style.PadY * scale; + dl.AddLine( + new Vector2(origin.X, lineY), + new Vector2(origin.X + size.X, lineY), + lineAbgr, + style.Thickness * scale + ); + + if (label is not null) + dl.AddText( + new Vector2(origin.X + style.LabelInsetX * scale, lineY + style.PadY * scale), + labelAbgr, + label + ); + + // Advances the cursor so the next row starts below, rather than drawing + // over it. + ImGui.Dummy(size); + } +} diff --git a/HellionChat/Ui/StyleEngine/Widgets/Pill.cs b/HellionChat/Ui/StyleEngine/Widgets/Pill.cs new file mode 100644 index 0000000..fbdfcac --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/Pill.cs @@ -0,0 +1,71 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +internal readonly record struct PillStyle +{ + public PillStyle() { } + + public float Height { get; init; } = 22f; + public float PaddingX { get; init; } = 8f; + public float Rounding { get; init; } = 6f; + public float DotRadius { get; init; } = 4f; + public float DotGap { get; init; } = 6f; + public bool Outlined { get; init; } +} + +// Filled capsule with a label, optionally preceded by a status dot. Outlined is +// the variant that would otherwise have been a separate Chip widget. +internal static class Pill +{ + internal static Vector2 CalcSize(string label, bool withDot, PillStyle? styleOverride = null) + { + var style = styleOverride ?? new PillStyle(); + var scale = Metrics.Scale; + var lead = withDot ? (style.DotRadius * 2f + style.DotGap) * scale : 0f; + return WidgetGeometry.Pill( + ImGui.CalcTextSize(label), + style.PaddingX * scale, + style.Height * scale, + lead + ); + } + + internal static void Draw( + Vector2 origin, + string label, + uint fillAbgr, + uint textAbgr, + uint? dotAbgr = null, + PillStyle? styleOverride = null + ) + { + var style = styleOverride ?? new PillStyle(); + var scale = Metrics.Scale; + var size = CalcSize(label, dotAbgr.HasValue, style); + var dl = ImGui.GetWindowDrawList(); + var max = origin + size; + var rounding = style.Rounding * scale; + + if (style.Outlined) + dl.AddRect(origin, max, fillAbgr, rounding); + else + dl.AddRectFilled(origin, max, fillAbgr, rounding); + + var textX = origin.X + style.PaddingX * scale; + + if (dotAbgr is { } dot) + { + var r = style.DotRadius * scale; + dl.AddCircleFilled(new Vector2(textX + r, origin.Y + size.Y * 0.5f), r, dot, 12); + textX += r * 2f + style.DotGap * scale; + } + + // Measured, not a frozen offset: the font comes from Config.FontSizeV2, + // which display scaling does not feed into. + var textY = origin.Y + MetricsMath.CenterY(size.Y, ImGui.GetTextLineHeight()); + dl.AddText(new Vector2(textX, textY), textAbgr, label); + } +} diff --git a/HellionChat/Ui/StyleEngine/Widgets/Row.cs b/HellionChat/Ui/StyleEngine/Widgets/Row.cs new file mode 100644 index 0000000..2762d7a --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/Row.cs @@ -0,0 +1,75 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +// Visual state of a list row. Deliberately carries no Tab: v1.11.0 swaps the +// sidebar from tab rows to channel rows and should only have to change the +// caller, not the chrome. +internal readonly record struct RowVisualState +{ + public RowVisualState() { } + + public bool IsActive { get; init; } + public float HoverAmount { get; init; } + public uint SurfaceAbgr { get; init; } + public uint SurfaceHoverAbgr { get; init; } + public uint SurfaceActiveAbgr { get; init; } + public uint AccentAbgr { get; init; } + public uint BorderAbgr { get; init; } +} + +internal readonly record struct RowStyle +{ + public RowStyle() { } + + public float AccentBarWidth { get; init; } = 2f; + public bool DrawSeparator { get; init; } = true; +} + +internal static class Row +{ + // Painted before icon and label, and strictly with draw-list calls only: + // TabContextMenu binds to the last submitted item, so an interactive widget + // between the row button and the popup call would steal its right-click. + internal static void Draw( + Vector2 origin, + Vector2 size, + RowVisualState state, + RowStyle? styleOverride = null + ) + { + if (size.X <= 0f || size.Y <= 0f) + return; + + var style = styleOverride ?? new RowStyle(); + var dl = ImGui.GetWindowDrawList(); + var max = origin + size; + + var baseAbgr = state.IsActive ? state.SurfaceActiveAbgr : state.SurfaceAbgr; + dl.AddRectFilled(origin, max, baseAbgr); + + if (state.HoverAmount > 0f) + dl.AddRectFilled(origin, max, ScaleAlpha(state.SurfaceHoverAbgr, state.HoverAmount)); + + if (state.IsActive && style.AccentBarWidth > 0f) + { + var barWidth = style.AccentBarWidth * Metrics.Scale; + dl.AddRectFilled(origin, new Vector2(origin.X + barWidth, max.Y), state.AccentAbgr); + } + + if (style.DrawSeparator) + dl.AddLine( + new Vector2(origin.X, max.Y - 1f), + new Vector2(max.X, max.Y - 1f), + state.BorderAbgr, + Metrics.Scale + ); + } + + private static uint ScaleAlpha(uint abgr, float factor) + { + var a = (uint)Math.Clamp(MathF.Round(((abgr >> 24) & 0xFF) * factor), 0f, 255f); + return (abgr & 0x00FFFFFFu) | (a << 24); + } +} diff --git a/HellionChat/Ui/StyleEngine/Widgets/WidgetPalette.cs b/HellionChat/Ui/StyleEngine/Widgets/WidgetPalette.cs new file mode 100644 index 0000000..169026b --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/WidgetPalette.cs @@ -0,0 +1,27 @@ +using HellionChat.Themes; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +// TokenResolver returns RGBA, ImDrawList expects ABGR. Getting that wrong swaps +// red and blue, and with five widgets it is the same trap five times over, so +// every widget colour goes through here. +internal sealed class WidgetPalette +{ + private readonly TokenResolver _resolver; + + public WidgetPalette(TokenResolver resolver) + { + _resolver = resolver; + } + + internal uint Abgr(Token token, ThemeColors colors) => + ColourUtil.RgbaToAbgr(_resolver.Resolve(token, colors)); + + internal uint Abgr(Token token, ThemeColors colors, float alpha) + { + var abgr = Abgr(token, colors); + var a = (uint)Math.Clamp(MathF.Round(((abgr >> 24) & 0xFF) * alpha), 0f, 255f); + return (abgr & 0x00FFFFFFu) | (a << 24); + } +} diff --git a/HellionChat/Util/WidgetGeometry.cs b/HellionChat/Util/WidgetGeometry.cs new file mode 100644 index 0000000..7032714 --- /dev/null +++ b/HellionChat/Util/WidgetGeometry.cs @@ -0,0 +1,58 @@ +using System.Numerics; + +namespace HellionChat.Util; + +// Size arithmetic for the drawn widgets, split from the widgets themselves so +// the build suite can pin it: the widgets need ImGui.CalcTextSize, this does +// not. Every result is clamped to a positive size -- ImGui asserts on a +// zero-sized InvisibleButton and takes the whole window down with it. +internal static class WidgetGeometry +{ + private const float MinExtent = 1f; + + internal static Vector2 Pill(Vector2 textSize, float paddingX, float height, float leadWidth) + { + var width = textSize.X + paddingX * 2f + leadWidth; + return Clamp(new Vector2(width, height)); + } + + internal static Vector2 Badge(Vector2 textSize, float paddingX, float height) + { + // Round badges look wrong when a single digit makes them narrower than + // they are tall, so a badge is at least a circle. + var width = MathF.Max(textSize.X + paddingX * 2f, height); + return Clamp(new Vector2(width, height)); + } + + internal static Vector2 IconButton(float width, float height) => + Clamp(new Vector2(width, height)); + + internal static Vector2 LineDivider( + float width, + float lineThickness, + float padY, + float labelHeight + ) + { + var height = lineThickness + padY * 2f + labelHeight; + return Clamp(new Vector2(width, height)); + } + + // Sum of pill widths plus the gap between them. Used by the status bar to + // decide whether the right-hand slot still fits, replacing a fixed 200px + // guess that never measured the left-hand slots at all. + internal static float SlotRunWidth(ReadOnlySpan widths, float gap) + { + if (widths.Length == 0) + return 0f; + + var total = 0f; + foreach (var w in widths) + total += w; + + return total + gap * (widths.Length - 1); + } + + private static Vector2 Clamp(Vector2 size) => + new(MathF.Max(MinExtent, size.X), MathF.Max(MinExtent, size.Y)); +} From 460217459ce93dfb2d830a4db1eb227cdb52fab9 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 23:42:05 +0200 Subject: [PATCH 041/129] chore(style): add a widget gallery for visual verification Every widget in its states, reachable via /hellion widgets. The point is to check them one at a time before they land in real components: the v2.x style engine grew three primitives that were never wired to a call site (DrawGlowBorder, DrawSlipPolygon, DrawHonorificHeader), and this is the cheap way to notice that before a cycle closes. DEBUG-only, like SeStringDebugger. It is a verification aid, not a feature, so it never reaches a release build -- verified against a Release compile. The header line also shows the live GlobalScale and the hover registry size, which makes both the scaling work and the eviction contract observable while dragging the Dalamud scale slider. --- HellionChat/Plugin.cs | 13 ++ HellionChat/PluginHostFactory.cs | 6 + HellionChat/PluginLifecycle.cs | 3 + HellionChat/Ui/Windows/WidgetGalleryWindow.cs | 196 ++++++++++++++++++ 4 files changed, 218 insertions(+) create mode 100644 HellionChat/Ui/Windows/WidgetGalleryWindow.cs diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 713b7f0..c117d0c 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -109,6 +109,9 @@ public sealed class Plugin : IAsyncDalamudPlugin internal static InputPreview InputPreview { get; private set; } = null!; internal CommandHelpWindow CommandHelpWindow { get; private set; } = null!; public SeStringDebugger SeStringDebugger { get; private set; } = null!; +#if DEBUG + internal Ui.Windows.WidgetGalleryWindow WidgetGallery { get; private set; } = null!; +#endif public FirstRunWizard FirstRunWizard { get; private set; } = null!; internal DebuggerWindow DebuggerWindow { get; private set; } = null!; @@ -365,6 +368,9 @@ public sealed class Plugin : IAsyncDalamudPlugin InputPreview = _host.Services.GetRequiredService(); CommandHelpWindow = _host.Services.GetRequiredService(); SeStringDebugger = _host.Services.GetRequiredService(); +#if DEBUG + WidgetGallery = _host.Services.GetRequiredService(); +#endif DebuggerWindow = _host.Services.GetRequiredService(); FirstRunWizard = _host.Services.GetRequiredService(); ChannelPopoutPool = _host.Services.GetRequiredService(); @@ -925,6 +931,13 @@ public sealed class Plugin : IAsyncDalamudPlugin SettingsWindow.Toggle(); return; } +#if DEBUG + if (arg.Equals("widgets", StringComparison.OrdinalIgnoreCase)) + { + WidgetGallery.Toggle(); + return; + } +#endif if (arg.Equals("reset", StringComparison.OrdinalIgnoreCase)) { // Recovery path documented in the v2.x master spec — drops a diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index d700eaf..794d132 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -340,6 +340,12 @@ internal static class PluginHostFactory sp.GetRequiredService>() )); services.AddSingleton(sp => new SeStringDebugger(sp.GetRequiredService())); +#if DEBUG + services.AddSingleton(sp => new Ui.Windows.WidgetGalleryWindow( + sp.GetRequiredService(), + sp.GetRequiredService() + )); +#endif services.AddSingleton(sp => new DebuggerWindow( sp.GetRequiredService(), sp.GetRequiredService() diff --git a/HellionChat/PluginLifecycle.cs b/HellionChat/PluginLifecycle.cs index 369bd24..e0a27df 100644 --- a/HellionChat/PluginLifecycle.cs +++ b/HellionChat/PluginLifecycle.cs @@ -64,6 +64,9 @@ internal sealed class PluginLifecycle : IAsyncDisposable plugin.WindowSystem.AddWindow(Plugin.InputPreview); plugin.WindowSystem.AddWindow(plugin.CommandHelpWindow); plugin.WindowSystem.AddWindow(plugin.SeStringDebugger); +#if DEBUG + plugin.WindowSystem.AddWindow(plugin.WidgetGallery); +#endif plugin.WindowSystem.AddWindow(plugin.DebuggerWindow); plugin.WindowSystem.AddWindow(plugin.FirstRunWizard); diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs new file mode 100644 index 0000000..ba17a31 --- /dev/null +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -0,0 +1,196 @@ +#if DEBUG +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Interface.Windowing; +using HellionChat.Themes; +using HellionChat.Ui.StyleEngine; +using HellionChat.Ui.StyleEngine.Widgets; + +namespace HellionChat.Ui.Windows; + +// Every widget in Ui/StyleEngine/Widgets shown in its states, so they can be +// checked one at a time before they land in real components. The v2.x style +// engine grew three primitives that were never wired to a call site +// (DrawGlowBorder, DrawSlipPolygon, DrawHonorificHeader) -- this is the cheap +// way to notice that before a cycle closes. +// +// DEBUG-only, like SeStringDebugger: it is a verification aid, not a feature. +internal sealed class WidgetGalleryWindow : Window +{ + private readonly Plugin _plugin; + private readonly WidgetPalette _palette; + + private int _badgeCount = 3; + private bool _rowActive = true; + + internal WidgetGalleryWindow(Plugin plugin, TokenResolver resolver) + : base("Widget Gallery###hellion-widget-gallery") + { + _plugin = plugin; + _palette = new WidgetPalette(resolver); + Size = new Vector2(460, 560); + SizeCondition = ImGuiCond.FirstUseEver; + } + + public override void Draw() + { + var c = _plugin.ThemeRegistry.Active.Colors; + + ImGui.TextDisabled( + $"GlobalScale {Metrics.Scale:0.00} · hover entries {HoverState.TrackedCount}" + ); + ImGui.Separator(); + + DrawRowSection(c); + DrawBadgeSection(c); + DrawPillSection(c); + DrawIconButtonSection(c); + DrawDividerSection(c); + } + + private void DrawRowSection(ThemeColors c) + { + ImGui.TextUnformatted("Row"); + ImGui.Checkbox("active##row", ref _rowActive); + + var width = ImGui.GetContentRegionAvail().X; + var height = Metrics.SidebarRowHeight; + + for (var i = 0; i < 3; i++) + { + var origin = ImGui.GetCursorScreenPos(); + var id = ImGui.GetID($"gallery.row.{i}"); + ImGui.InvisibleButton($"##gallery-row-{i}", new Vector2(width, height)); + var hovered = ImGui.IsItemHovered(); + + Row.Draw( + origin, + new Vector2(width, height), + new RowVisualState + { + IsActive = _rowActive && i == 1, + HoverAmount = HoverState.Query(id, hovered), + SurfaceAbgr = _palette.Abgr(Token.SurfaceBase, c), + SurfaceHoverAbgr = _palette.Abgr(Token.SurfaceHover, c), + SurfaceActiveAbgr = _palette.Abgr(Token.SurfaceActive, c), + AccentAbgr = _palette.Abgr(Token.AccentPrimary, c), + BorderAbgr = _palette.Abgr(Token.Border, c), + } + ); + + ImGui + .GetWindowDrawList() + .AddText( + origin + new Vector2(12f * Metrics.Scale, Metrics.CenterY(height)), + _palette.Abgr(Token.Text, c), + $"Row {i}" + ); + } + + ImGui.Spacing(); + } + + private void DrawBadgeSection(ThemeColors c) + { + ImGui.TextUnformatted("Badge"); + ImGui.SliderInt("count##badge", ref _badgeCount, 0, 150); + + int[] samples = [1, 9, _badgeCount, 120]; + var origin = ImGui.GetCursorScreenPos(); + var x = origin.X; + foreach (var n in samples) + { + Badge.Draw( + new Vector2(x, origin.Y), + n, + _palette.Abgr(Token.AccentEmber, c), + _palette.Abgr(Token.Text, c) + ); + x += Badge.CalcSize(n).X + 8f * Metrics.Scale; + } + + ImGui.Dummy(new Vector2(0, Badge.CalcSize(1).Y + 8f * Metrics.Scale)); + ImGui.Spacing(); + } + + private void DrawPillSection(ThemeColors c) + { + ImGui.TextUnformatted("Pill"); + + var origin = ImGui.GetCursorScreenPos(); + var x = origin.X; + + Pill.Draw( + new Vector2(x, origin.Y), + "filled", + _palette.Abgr(Token.SurfaceRaised, c), + _palette.Abgr(Token.Text, c) + ); + x += Pill.CalcSize("filled", withDot: false).X + 8f * Metrics.Scale; + + Pill.Draw( + new Vector2(x, origin.Y), + "with dot", + _palette.Abgr(Token.SurfaceRaised, c), + _palette.Abgr(Token.Text, c), + _palette.Abgr(Token.AccentPrimary, c) + ); + x += Pill.CalcSize("with dot", withDot: true).X + 8f * Metrics.Scale; + + Pill.Draw( + new Vector2(x, origin.Y), + "outlined", + _palette.Abgr(Token.AccentPrimary, c), + _palette.Abgr(Token.TextMuted, c), + styleOverride: new PillStyle { Outlined = true } + ); + + ImGui.Dummy(new Vector2(0, Pill.CalcSize("x", false).Y + 8f * Metrics.Scale)); + ImGui.Spacing(); + } + + private void DrawIconButtonSection(ThemeColors c) + { + ImGui.TextUnformatted("IconButton"); + + var size = new Vector2(Metrics.SidebarPopOutHitWidth, Metrics.SidebarRowHeight); + FontAwesomeIcon[] glyphs = + [ + FontAwesomeIcon.ArrowUpRightFromSquare, + FontAwesomeIcon.Check, + FontAwesomeIcon.CheckCircle, + ]; + + for (var i = 0; i < glyphs.Length; i++) + { + if (i > 0) + ImGui.SameLine(); + + IconButton.Draw( + ImGui.GetID($"gallery.icon.{i}"), + size, + glyphs[i], + _palette.Abgr(Token.TextMuted, c), + _palette.Abgr(Token.SurfaceHover, c), + _plugin.FontManager.FontAwesome + ); + } + + ImGui.Spacing(); + } + + private void DrawDividerSection(ThemeColors c) + { + ImGui.TextUnformatted("LineDivider"); + + LineDivider.Draw( + "Section (2)", + _palette.Abgr(Token.Border, c), + _palette.Abgr(Token.TextMuted, c) + ); + LineDivider.Draw(null, _palette.Abgr(Token.Border, c), _palette.Abgr(Token.TextMuted, c)); + } +} +#endif From 10b345821f4bb475d9778752c8ed7373f3aa3cef Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 23:46:43 +0200 Subject: [PATCH 042/129] fix(messages): apply density switches immediately, freeze the cache while waiting Review of block A found two regressions the settle gate introduced. Density and the two name modes are switches, not sliders. They land on a new value in one frame and stay there, but the gate made them wait 200ms like a drag. Meanwhile the row painter switched instantly, so for about twelve frames the planner ran compact rows against card heights: wrong slice, wrong offset, and the visible rows got overwritten with the new density while everything else kept the old one. Before the gate this was correct, so it was a regression, not a pre-existing bug. The fingerprint now separates a discrete axis that applies at once from the continuous one that waits. Second, the cache did not actually stand still during the wait. Measurements were written back unconditionally, so a resize drag mixed heights from many different widths and the lead dummy drifted for the whole drag. The gate now exposes IsPending and the planned path skips the write while it is set. The linear path still measures, because without a filled cache there is nothing to plan against. Three smaller items from the same review: An oscillation slower than the settle window used to bypass the wait entirely, because the pending clock was never reset on the way back to the applied value. A value that changes on every frame could hold the gate shut forever while the applied fingerprint stayed wrong. MaxWaitMs is the deadline, checked before the still-moving branch so a permanently moving value actually reaches it. The row painter was passed as `compact ? DrawCompactRow : DrawCardRow`. Both are instance methods, so the method group captures `this` and Roslyn does not cache it: 64 bytes per frame per window. Bound once in the constructor now. The per-frame height array is also reused rather than reallocated, which at the default MaxLinesToRender was 10 KB per frame on what A2 had just made the default path. --- HellionChat/Ui/Components/CardClipPlanner.cs | 11 ++- HellionChat/Ui/Components/MessageList.cs | 41 +++++++++- HellionChat/Util/LayoutFingerprint.cs | 79 ++++++++++++++++---- 3 files changed, 109 insertions(+), 22 deletions(-) diff --git a/HellionChat/Ui/Components/CardClipPlanner.cs b/HellionChat/Ui/Components/CardClipPlanner.cs index 11d744f..ea0db1b 100644 --- a/HellionChat/Ui/Components/CardClipPlanner.cs +++ b/HellionChat/Ui/Components/CardClipPlanner.cs @@ -2,9 +2,10 @@ using System.Collections.Generic; namespace HellionChat.Ui.Components; -// B2 (PERF-B2): variable-height clip plan for card mode. ImGuiListClipper needs -// a constant row height, so card mode (rows wrap to arbitrary heights) computes -// its own plan from the cached per-row heights: a lead dummy for the rows above +// B2 (PERF-B2): variable-height clip plan. ImGuiListClipper needs a constant +// row height, and since v1.10.0/A2 neither density has one (compact rows wrap +// too), so both compute a plan from the cached per-row heights: a lead dummy +// for the rows above // the viewport, the [first..last] index range that overlaps the viewport, and // an end dummy for the rows below. This is the prefix-sum analogue of // OtterGui's GetNecessarySkips, but for non-uniform heights — kept Dalamud-free @@ -19,7 +20,9 @@ internal readonly record struct CardClipPlan( internal static class CardClipPlanner { - // heights[i] is the cached, spacing-inclusive height of row i in draw order. + // heights[i] is the cached height of row i in draw order. It carries no + // trailing ItemSpacing: every row ends inside DrawChunks, where spacing is + // pushed to zero, and ImGui writes the advance at item submission time. // A row overlaps the viewport iff rowTop < windowBottom && rowBottom > // windowTop. FirstVisible/LastVisible are -1 when nothing overlaps (empty // list); callers then submit a single end dummy of the full content height diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs index 9386647..6cf0ad6 100644 --- a/HellionChat/Ui/Components/MessageList.cs +++ b/HellionChat/Ui/Components/MessageList.cs @@ -31,6 +31,17 @@ internal sealed class MessageList // itself applied, so tab B kept measuring against the previous width. private readonly Dictionary _fingerprintGates = []; + // Bound once. A method group off an instance method captures `this` and is + // not cached by Roslyn, so `compact ? DrawCompactRow : DrawCardRow` would + // allocate a delegate on every frame of every window. + private readonly Action _drawCompactRow; + private readonly Action _drawCardRow; + + // Reused across frames: at the default MaxLinesToRender of 2500 a fresh + // array per frame is 10 KB of garbage, and A2 put the default density on + // this path. + private float[] _heightScratch = []; + // §6.2: setter-injection breaks the PayloadHandler → MainWindow → MessageList → PayloadHandler 3-cycle. // Wired by PayloadHandlerInitHostedService.StartAsync after both singletons exist. internal void AttachPayloadHandler(PayloadHandler handler) @@ -40,6 +51,8 @@ internal sealed class MessageList public MessageList(FontManager fonts, ChunkRenderer chunkRenderer) { + _drawCompactRow = DrawCompactRow; + _drawCardRow = DrawCardRow; _fonts = fonts; _chunkRenderer = chunkRenderer; } @@ -145,7 +158,11 @@ internal sealed class MessageList // the parent child's scroll state, which is the one MainWindow owns. var pinnedToBottom = ImGui.GetScrollY() >= ImGui.GetScrollMaxY() - 1f; - DrawRows(tab, messages, compact ? DrawCompactRow : DrawCardRow); + // While the gate waits out a continuous change, measurements must not go + // back into the cache: the rows outside the viewport still carry the old + // geometry, and mixing the two makes the lead dummy drift every frame. + var frozen = _fingerprintGates[tab.Identifier].IsPending; + DrawRows(tab, messages, compact ? _drawCompactRow : _drawCardRow, frozen); // B3-5: scroll values are frame-constant inside the child, so this // reflects the current frame's state wherever it runs; kept after the @@ -238,7 +255,12 @@ internal sealed class MessageList // Shared by both densities: compact rows wrap too, so neither has a constant // height the ImGuiListClipper could work with. drawRow is the only difference. - private void DrawRows(Tab tab, IReadOnlyList messages, Action drawRow) + private void DrawRows( + Tab tab, + IReadOnlyList messages, + Action drawRow, + bool frozen + ) { var tabId = tab.Identifier; var count = messages.Count; @@ -247,7 +269,9 @@ internal sealed class MessageList // A row with no cached height yet can't be planned (first frame / post- // invalidation), so draw everything once to fill the cache, plan next frame. - var heights = new float[count]; + if (_heightScratch.Length < count) + _heightScratch = new float[Math.Max(count, 256)]; + var heights = _heightScratch; var allCached = true; for (var i = 0; i < count; i++) { @@ -259,11 +283,17 @@ internal sealed class MessageList if (!allCached) { + // Always measures, frozen or not: without a filled cache there is + // nothing to plan against at all. DrawLinearAndMeasure(tabId, messages, drawRow); return; } - var plan = CardClipPlanner.Plan(heights, ImGui.GetScrollY(), ImGui.GetWindowSize().Y); + var plan = CardClipPlanner.Plan( + new ArraySegment(heights, 0, count), + ImGui.GetScrollY(), + ImGui.GetWindowSize().Y + ); // A dummy is submitted outside any style push, so it appends its own // trailing ItemSpacing.y. Subtracting one spacing per dummy makes the dummy @@ -290,6 +320,9 @@ internal sealed class MessageList var msg = messages[i]; var before = ImGui.GetCursorPosY(); drawRow(msg); + if (frozen) + continue; + var after = ImGui.GetCursorPosY(); msg.Height[tabId] = after - before; msg.IsVisible[tabId] = true; diff --git a/HellionChat/Util/LayoutFingerprint.cs b/HellionChat/Util/LayoutFingerprint.cs index 54d295d..22052ba 100644 --- a/HellionChat/Util/LayoutFingerprint.cs +++ b/HellionChat/Util/LayoutFingerprint.cs @@ -1,7 +1,7 @@ namespace HellionChat.Util; // Layout inputs that make a tab's cached row heights stale. Kept as a plain -// value type so the build suite can pin the settle logic without an ImGui frame. +// value type so the build suite can pin the gate without an ImGui frame. internal readonly record struct LayoutFingerprint( float FontGlobal, float FontSymbols, @@ -10,35 +10,77 @@ internal readonly record struct LayoutFingerprint( int WorldSuffix, float Width, float UiScale -); +) +{ + // Toggles: they land on a new value in one frame and stay there. Waiting on + // them would leave the planner running against the previous density's + // heights while the rows are already painted the new way. + internal (bool, int, int) Discrete => (Compact, NameForm, WorldSuffix); +} -// Dragging a window edge or the Dalamud UI-scale slider moves the fingerprint on -// every single frame. Acting on each one drops the height cache, which sends the -// whole tab through the linear measure path (up to Config.MaxLinesToRender rows, -// default 2500). Waiting for the value to settle turns that into one rebuild per -// drag instead of one per frame. +// Dragging a window edge or the Dalamud UI-scale slider moves the continuous +// half of the fingerprint on every frame. Acting on each one drops the height +// cache and sends the whole tab through the linear measure path (up to +// Config.MaxLinesToRender rows). Waiting for those to settle turns a drag into +// one rebuild. Discrete changes bypass the wait entirely. internal sealed class LayoutFingerprintGate { internal const long SettleMs = 200; + // A value that never stops moving would otherwise hold the gate shut + // forever while the applied fingerprint stays wrong. + internal const long MaxWaitMs = 1000; + private LayoutFingerprint? _applied; private LayoutFingerprint _pending; private long _pendingSinceMs; + private long _divergedSinceMs; + + // True while a continuous change is being waited out. The caller must not + // write fresh measurements into the cache during this window, or the cache + // becomes a mix of the old and the in-flight geometry. + internal bool IsPending { get; private set; } internal bool ShouldInvalidate(LayoutFingerprint current, long nowMs) { - // First sight of this tab: nothing is cached yet, so there is nothing to - // drop and nothing to wait for. if (_applied is null) { - _applied = current; - _pending = current; - _pendingSinceMs = nowMs; + Settle(current, nowMs); return false; } - if (current.Equals(_applied.Value)) + var applied = _applied.Value; + if (current.Equals(applied)) + { + Settle(current, nowMs); return false; + } + + // Density and the two name modes are switches, not sliders: apply now. + if (!current.Discrete.Equals(applied.Discrete)) + { + Settle(current, nowMs); + return true; + } + + // First frame of a divergence starts both clocks: the settle window + // restarts on every further move, the deadline does not. + if (!IsPending) + { + _divergedSinceMs = nowMs; + _pending = current; + _pendingSinceMs = nowMs; + IsPending = true; + return false; + } + + // Checked before the still-moving branch below: a value that changes on + // every frame would otherwise never reach it. + if (nowMs - _divergedSinceMs >= MaxWaitMs) + { + Settle(current, nowMs); + return true; + } if (!current.Equals(_pending)) { @@ -50,7 +92,16 @@ internal sealed class LayoutFingerprintGate if (nowMs - _pendingSinceMs < SettleMs) return false; - _applied = current; + Settle(current, nowMs); return true; } + + private void Settle(LayoutFingerprint current, long nowMs) + { + _applied = current; + _pending = current; + _pendingSinceMs = nowMs; + _divergedSinceMs = nowMs; + IsPending = false; + } } From 1e8a60ac8033bcc8b60d26774c0ef2d7eb84d563 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 23:48:36 +0200 Subject: [PATCH 043/129] fix(sidebar): scale the drawn width without moving the stored value The sidebar constants were raw pixels. At 150% display scaling the text grows, the column does not, and the row contents stop fitting. GetWidth and IsExpanded stay unscaled on purpose. The stored width and the switch threshold are user settings in design pixels, and SidebarModeAutoSwitchStep compares GetWidth's return value against the raw bounds with exact equality -- scaling there would fail the step at anything other than 100%. Scaling happens once, at the single draw call site. RowHeight and the two hit widths now come from Metrics. The row internals read GetContentRegionAvail, so they follow automatically and the hit-area split thresholds stay proportional. The not-ready branch is scaled too: a scale change triggers a font rebuild, so that branch really is hit while GlobalScale is moving, and an unscaled width there makes the sidebar jump. The width slider referenced the bounds as literals. It now uses the constants, so it cannot drift away from the clamp. Known remainder, deliberate: SidebarAutoSwitchThresholdPx is compared against real screen pixels while the columns now scale, so the switch point drifts at high scaling. Scaling it would fail the same SelfTest. Noted for v1.11.0. --- .../Ui/Components/Settings/Tabs/ChannelsTab.cs | 11 ++++++----- HellionChat/Ui/Components/Sidebar.cs | 17 ++++++++++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs index 4e1e338..38398a8 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs @@ -63,15 +63,16 @@ internal sealed class ChannelsTab if (ImGui.CollapsingHeader("Sidebar")) { - // Range matches Sidebar.MinSidebarWidth/MaxSidebarWidth (40-300). The - // lower bound sits just above the 38px icon-only threshold; the - // on-disk default (44) and the 150px expanded reference both fit. + // Bounds come from the constants rather than repeating the numbers, + // so the slider cannot drift away from the clamp in Sidebar.GetWidth. + // The stored value is unscaled; display scaling is applied where the + // sidebar is drawn. DrawSliderInt( "Sidebar width", () => Plugin.Config.SidebarWidth, v => Plugin.Config.SidebarWidth = v, - 40, - 300 + (int)Sidebar.MinSidebarWidth, + (int)Sidebar.MaxSidebarWidth ); } } diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index fcbd0fe..0886e1d 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -27,9 +27,9 @@ internal sealed class Sidebar public const float MinSidebarWidth = 40f; public const float MaxSidebarWidth = 300f; - private const float RowHeight = 32f; - private const float PopOutHitWidth = 22f; - private const float GreetedHitWidth = 22f; + private static float RowHeight => Metrics.SidebarRowHeight; + private static float PopOutHitWidth => Metrics.SidebarPopOutHitWidth; + private static float GreetedHitWidth => Metrics.SidebarGreetedHitWidth; // B3-2 render observability: counts greeted glyphs actually drawn this frame. // Incremented ONLY in the real glyph branch in DrawRow; reset at Draw start. @@ -85,6 +85,10 @@ internal sealed class Sidebar _pool = pool; } + // Both stay unscaled. The stored width and the switch threshold are user + // settings in design pixels, and SidebarModeAutoSwitchStep compares this + // return value against the raw bounds. Display scaling is applied once, at + // the single draw call site below. public bool IsExpanded(float windowWidth) => windowWidth >= Plugin.Config.SidebarAutoSwitchThresholdPx; @@ -111,12 +115,15 @@ internal sealed class Sidebar if (!_fonts.FontsReady) { - ImGui.Dummy(new Vector2(IconOnlyWidth, 0)); + // A scale change triggers a font rebuild, so this branch is really + // hit while GlobalScale is moving -- an unscaled width here makes + // the sidebar jump. + ImGui.Dummy(new Vector2(Metrics.SidebarIconOnlyWidth, 0)); return; } var expanded = IsExpanded(windowWidth); - var width = GetWidth(windowWidth); + var width = GetWidth(windowWidth) * Metrics.Scale; using var child = ImRaii.Child("##hellion-sidebar", new Vector2(width, 0)); if (!child.Success) return; From 464bd528876e16740c0260a6f7843807b4688645 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 23:49:15 +0200 Subject: [PATCH 044/129] refactor(sidebar): move section headers onto the divider widget Separator plus TextDisabled took all of their vertical breathing room from ItemSpacing. The next task pushes ItemSpacing to zero so rows sit flush, which would have collapsed the header onto its neighbours. LineDivider carries its own padding and submits its own layout item, so it no longer depends on the surrounding spacing at all. The compact branch keeps suppressing only the caption, and LastDrawnSectionHeaderCount still increments exactly where the caption is drawn -- SidebarSectionHeaderStep pins it at 2 with compact off and 0 with compact on. --- HellionChat/Ui/Components/Sidebar.cs | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index 0886e1d..e52ec21 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -6,6 +6,7 @@ using HellionChat.Code; using HellionChat.Resources; using HellionChat.Themes; using HellionChat.Ui.StyleEngine; +using HellionChat.Ui.StyleEngine.Widgets; using HellionChat.Util; using Microsoft.Extensions.Logging; @@ -66,6 +67,7 @@ internal sealed class Sidebar private readonly ThemeRegistry _themes; private readonly TokenResolver _resolver; + private readonly WidgetPalette _palette; private readonly FontManager _fonts; private readonly ILogger _logger; private readonly Windows.ChannelPopoutPool _pool; @@ -80,6 +82,7 @@ internal sealed class Sidebar { _themes = themes; _resolver = resolver; + _palette = new WidgetPalette(resolver); _fonts = fonts; _logger = logger; _pool = pool; @@ -181,18 +184,23 @@ internal sealed class Sidebar } } - // Section transition marker (1.5.6 parity): the separator always renders, - // compact mode suppresses only the header text. Real cursor-advancing - // widgets on purpose — rows advance the cursor via InvisibleButton, so a - // drawlist-only header would overlap the next row. + // Section transition marker (1.5.6 parity): the rule always renders, compact + // mode suppresses only the caption. LineDivider submits its own layout item + // and carries its own padding, which is what lets the rows below sit flush + // without the header collapsing onto them. private void DrawSectionHeader(string header, int count) { - ImGui.Separator(); - if (Plugin.Config.AutoTellTabsCompactDisplay) - return; + var colors = _themes.Active.Colors; + var compact = Plugin.Config.AutoTellTabsCompactDisplay; - ImGui.TextDisabled($"{header} ({count})"); - LastDrawnSectionHeaderCount++; + LineDivider.Draw( + compact ? null : $"{header} ({count})", + _palette.Abgr(Token.Border, colors), + _palette.Abgr(Token.TextMuted, colors) + ); + + if (!compact) + LastDrawnSectionHeaderCount++; } private void DrawRow( From eacefb355d20ff070694d9485ee38d09e28cabbe Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 23:50:52 +0200 Subject: [PATCH 045/129] feat(sidebar): draw row surfaces, accent bar and separators The sidebar drew a hover sweep, an icon and a label per row and nothing else. No hover fill, no active fill, no accent bar, no separator -- the active tab was visually indistinguishable from the rest. The theme preview in settings has been showing all of it for months without the real sidebar delivering any. Rows now go through the Row widget: base surface, active surface, hover interpolated between them, a 2px accent bar on the active row and a bottom separator. Hover is detected with IsMouseHoveringRect, not IsItemHovered. The row button is up to two hit widths narrower than the row itself (popout slot, greeted slot), so a full-width surface driven by the item would flicker at the edges. AllowWhenBlockedByActiveItem is required on the window check, otherwise the fill disappears the moment the button is pressed, because InvisibleButton owns the active id by then. ItemSpacing is pushed to zero around the row loop so surfaces sit flush instead of leaving a stripe of window background between them. Style vars are a global stack and the context menu inherits them, so TabContextMenu now restores a normal spacing inside its popup -- without that its entries would touch. The row surface is drawn strictly with draw-list calls between the row button and TabContextMenu.Draw, which is the ordering the popup trigger depends on. --- HellionChat/Ui/Components/Sidebar.cs | 49 ++++++++++++++++++--- HellionChat/Ui/Components/TabContextMenu.cs | 12 +++++ 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index e52ec21..d30e197 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -32,6 +32,10 @@ internal sealed class Sidebar private static float PopOutHitWidth => Metrics.SidebarPopOutHitWidth; private static float GreetedHitWidth => Metrics.SidebarGreetedHitWidth; + // Counts rows that got the active surface this frame. At most one, but not + // exactly one: PickMainActiveTab returns null when every tab is popped out. + internal int LastRenderedActiveSurfaceCount { get; private set; } + // B3-2 render observability: counts greeted glyphs actually drawn this frame. // Incremented ONLY in the real glyph branch in DrawRow; reset at Draw start. // The SelfTest reads it after driving the real Draw — no dead service roundtrip. @@ -115,6 +119,7 @@ internal sealed class Sidebar LastRenderedGreetedGlyphCount = 0; LastRenderedUnreadDotCount = 0; LastDrawnSectionHeaderCount = 0; + LastRenderedActiveSurfaceCount = 0; if (!_fonts.FontsReady) { @@ -150,6 +155,12 @@ internal sealed class Sidebar ); var pinnedHeaderRendered = false; var unpinnedHeaderRendered = false; + + // Rows carry their own full-height surface now, so the default gap + // between them would read as a stripe of window background. The section + // headers are unaffected: LineDivider brings its own padding. + using var rowSpacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero); + foreach (var i in renderOrder) { var tab = tabs[i]; @@ -226,7 +237,7 @@ internal sealed class Sidebar // Drop the row entirely when the sidebar is dragged below the width // of a single hit target. ImGui's InvisibleButton asserts on a // zero-width size, which crashes the whole window at min-drag. - if (avail < 2f) + if (avail < Metrics.SidebarMinDrawWidth) { ImGui.PopID(); return; @@ -266,19 +277,43 @@ internal sealed class Sidebar TabLifecycleHelpers.OnTabActivated(tab, previous); } + // Not IsItemHovered: the row button is up to two hit widths narrower than + // the row, so a full-width surface driven by it would flicker at the + // edges. AllowWhenBlockedByActiveItem keeps the surface while the button + // is held down; without it the fill vanishes on press. + var rowMax = origin + new Vector2(avail, RowHeight); + var surfaceHovered = + ImGui.IsMouseHoveringRect(origin, rowMax) + && ImGui.IsWindowHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem); + // GetID is seeded from the window's ID stack, so the same "row" literal // stays distinct per window and per PushID'd tab. The old interpolated // key allocated two strings per row per frame. var hoverId = ImGui.GetID("row"u8); - var hoverAmount = StyleEngine.HoverState.Query(hoverId, rowHovered); - dl.DrawHoverSheen( + var hoverAmount = HoverState.Query(hoverId, surfaceHovered); + + var isActiveRow = ReferenceEquals(tab, activeTab); + if (isActiveRow) + LastRenderedActiveSurfaceCount++; + + var colors = _themes.Active.Colors; + Row.Draw( origin, - origin + new Vector2(avail, RowHeight), - accentRgba, - hoverAmount, - rowHovered + new Vector2(avail, RowHeight), + new RowVisualState + { + IsActive = isActiveRow, + HoverAmount = hoverAmount, + SurfaceAbgr = _palette.Abgr(Token.SurfaceBase, colors), + SurfaceHoverAbgr = _palette.Abgr(Token.SurfaceHover, colors), + SurfaceActiveAbgr = _palette.Abgr(Token.SurfaceActive, colors), + AccentAbgr = _palette.Abgr(Token.AccentPrimary, colors), + BorderAbgr = _palette.Abgr(Token.Border, colors), + } ); + dl.DrawHoverSheen(origin, rowMax, accentRgba, hoverAmount, surfaceHovered); + var icon = ResolveTabIcon(tab); // Dim precedence (1.5.6): the active tab always keeps its regular diff --git a/HellionChat/Ui/Components/TabContextMenu.cs b/HellionChat/Ui/Components/TabContextMenu.cs index 738a6e7..27e1556 100644 --- a/HellionChat/Ui/Components/TabContextMenu.cs +++ b/HellionChat/Ui/Components/TabContextMenu.cs @@ -1,6 +1,7 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using FFXIVClientStructs.FFXIV.Client.UI; using HellionChat.Resources; using HellionChat.Util; @@ -39,6 +40,17 @@ internal static class TabContextMenu return; } + // The sidebar pushes ItemSpacing to zero so its rows sit flush, and style + // vars are a global stack the popup inherits. Without restoring a normal + // spacing here the menu entries would touch each other. + using var spacing = ImRaii.PushStyle( + ImGuiStyleVar.ItemSpacing, + ImGui.GetStyle().ItemSpacing with + { + Y = MathF.Max(ImGui.GetStyle().ItemSpacing.Y, 4f * ImGuiHelpers.GlobalScale), + } + ); + // Rename: focus the field the first frame the popup appears. if (ImGui.IsWindowAppearing()) ImGui.SetKeyboardFocusHere(); From 293d56bdbeeab6f89a413436fdff63828bf7c3c5 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 23:52:07 +0200 Subject: [PATCH 046/129] refactor(sidebar): move popout and greeted toggles onto the icon-button widget Both were hand-rolled: an InvisibleButton, then a glyph pushed through the FontAwesome handle onto the draw list, with a frozen 8px vertical offset and a 4px inset. IconButton does the hit area, the hover fill and the centred glyph, and centres against the measured line height instead of the frozen offset. Their order is unchanged. Both still sit after TabContextMenu.Draw, which is the ordering the popup trigger depends on, and the caller keeps what actually differs between them: cursor placement (SameLine vs. absolute), the visibility rule, the glyph choice and the greeted glyph counter that SidebarGreetedGlyphStep pins. The popout glyph now follows the row surface rather than rowHovered || popHovered. That pair needed the button's own hover state one line before it existed, and the button sits inside the row anyway, so the row's hover covers both cases. --- HellionChat/Ui/Components/Sidebar.cs | 44 ++++++++++++++++------------ 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index d30e197..3b43f24 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -269,7 +269,6 @@ internal sealed class Sidebar } ImGui.InvisibleButton("row", new Vector2(tabHitWidth, RowHeight)); - var rowHovered = ImGui.IsItemHovered(); if (ImGui.IsItemClicked()) { var previous = activeTab; @@ -350,23 +349,23 @@ internal sealed class Sidebar TabContextMenu.Draw(tab, "ctx", _pool); - var popHovered = false; if (hasPopOut) { ImGui.SameLine(0f, 0f); - ImGui.InvisibleButton("popout", new Vector2(PopOutHitWidth, RowHeight)); - popHovered = ImGui.IsItemHovered(); - if (ImGui.IsItemClicked()) - _pool.TryOpen(tab); - } - if (hasPopOut && (rowHovered || popHovered)) - { - using (_fonts.FontAwesome.Push()) - { - var glyph = FontAwesomeIcon.ArrowUpRightFromSquare.ToIconString(); - dl.AddText(origin + new Vector2(avail - PopOutHitWidth + 4f, 8f), mutedAbgr, glyph); - } + // Glyph follows the row surface, not the button's own hover: the + // button sits inside the row, and the old pairing needed a hover + // state one line before it existed. + var (popClicked, _) = IconButton.Draw( + ImGui.GetID("popout"u8), + new Vector2(PopOutHitWidth, RowHeight), + surfaceHovered ? FontAwesomeIcon.ArrowUpRightFromSquare : null, + mutedAbgr, + _palette.Abgr(Token.SurfaceHover, colors), + _fonts.FontAwesome + ); + if (popClicked) + _pool.TryOpen(tab); } if (showGreeted) @@ -376,16 +375,23 @@ internal sealed class Sidebar // between the row button and the popup call would steal the // right-click trigger (B3-1 ordering constraint). ImGui.SetCursorScreenPos(origin); - ImGui.InvisibleButton("greeted", new Vector2(GreetedHitWidth, RowHeight)); - if (ImGui.IsItemClicked()) - ToggleGreetedForSelfTest(tab); // CheckCircle = greeted, plain Check = still pending (1.5.6 mapping). var greetedGlyph = Plugin.Instance.AutoTellTabsService.IsGreeted(tab) ? FontAwesomeIcon.CheckCircle : FontAwesomeIcon.Check; - using (_fonts.FontAwesome.Push()) - dl.AddText(origin + new Vector2(4f, 8f), mutedAbgr, greetedGlyph.ToIconString()); + + var (greetedClicked, _) = IconButton.Draw( + ImGui.GetID("greeted"u8), + new Vector2(GreetedHitWidth, RowHeight), + greetedGlyph, + mutedAbgr, + _palette.Abgr(Token.SurfaceHover, colors), + _fonts.FontAwesome + ); + if (greetedClicked) + ToggleGreetedForSelfTest(tab); + LastRenderedGreetedGlyphCount++; } From 9936638bb3d86ed33f7e977f9fa6c66aaef0aeff Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 23:53:30 +0200 Subject: [PATCH 047/129] feat(sidebar): show unread counts as accent badges The unread marker was a 4px dot in StatusDanger. Red reads as an error, and an unread message is not one. It is now a count badge in AccentEmber, which is what the theme preview in settings has always shown. It has to be drawn outside the FontAwesome scope: that atlas carries no ASCII digits, so the number would have come out blank inside it. The icon width is still measured inside the scope and handed out. Placement follows the mode. Expanded puts the badge right-aligned ahead of the popout slot, where a three-digit count still fits; icon-only keeps it over the icon like the old dot. The frozen vertical offsets go at the same time. The 8f was (32 - 16) / 2 for a 16px font and stayed wrong at every other Config.FontSizeV2, which display scaling does not feed into. Both the icon and the label now centre against the measured line height, and the label starts after the measured icon width instead of a hard 32f. --- HellionChat/Ui/Components/Sidebar.cs | 58 ++++++++++++++++------------ 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index 3b43f24..c657aae 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -141,7 +141,6 @@ internal sealed class Sidebar var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary); var mutedAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextMuted); var dimAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextDim); - var dangerAbgr = ColourUtil.RgbaToAbgr(theme.Colors.StatusDanger); var dl = ImGui.GetWindowDrawList(); // B3-4 sectioned render order (1.5.6 parity): persistent → pinned @@ -181,17 +180,7 @@ internal sealed class Sidebar unpinnedHeaderRendered = true; } - DrawRow( - tab, - expanded, - accentRgba, - textAbgr, - mutedAbgr, - dimAbgr, - dangerAbgr, - dl, - ref activeTab - ); + DrawRow(tab, expanded, accentRgba, textAbgr, mutedAbgr, dimAbgr, dl, ref activeTab); } } @@ -221,7 +210,6 @@ internal sealed class Sidebar uint textAbgr, uint mutedAbgr, uint dimAbgr, - uint dangerAbgr, ImDrawListPtr dl, ref Tab? activeTab ) @@ -328,24 +316,44 @@ internal sealed class Sidebar // Icon and label shift right by the greeted slot when it is shown. var contentX = showGreeted ? GreetedHitWidth : 0f; + var scale = Metrics.Scale; + var iconInset = 10f * scale; + + // Centred, not a frozen offset: the old 8f was (32 - 16) / 2 for a 16px + // font and stays wrong at any other Config.FontSizeV2. + var contentY = Metrics.CenterY(RowHeight); + + float iconRight; using (_fonts.FontAwesome.Push()) { var iconStr = icon.ToIconString(); - dl.AddText(origin + new Vector2(10f + contentX, 8f), iconColor, iconStr); - - // 1.5.6-parity unread dot, top-right of the icon. The active tab is - // zeroed every frame (MainWindow.Draw), so the dot never shows on the - // tab you're viewing; UnreadMode.None opts a tab out entirely. - if (!isCurrentTab && tab.UnreadMode != UnreadMode.None && tab.Unread > 0) - { - var iconRight = 10f + contentX + ImGui.CalcTextSize(iconStr).X; - dl.AddCircleFilled(origin + new Vector2(iconRight - 2f, 6f), 4f, dangerAbgr, 12); - LastRenderedUnreadDotCount++; - } + dl.AddText(origin + new Vector2(iconInset + contentX, contentY), iconColor, iconStr); + iconRight = iconInset + contentX + ImGui.CalcTextSize(iconStr).X; } if (expanded) - dl.AddText(origin + new Vector2(32f + contentX, 8f), textAbgr, tab.Name); + dl.AddText(origin + new Vector2(iconRight + 6f * scale, contentY), textAbgr, tab.Name); + + // Unread count. Drawn outside the icon-font scope on purpose: the + // FontAwesome atlas carries no ASCII digits, so the number would come out + // blank. The active tab is zeroed every frame (MainWindow.Draw), so it + // never shows on the tab you are viewing; UnreadMode.None opts out. + if (!isCurrentTab && tab.UnreadMode != UnreadMode.None && tab.Unread > 0) + { + var unread = (int)Math.Min(tab.Unread, int.MaxValue); + var badgeSize = Badge.CalcSize(unread); + var badgeX = expanded + ? avail - PopOutHitWidth - badgeSize.X - 4f * scale + : iconRight - badgeSize.X * 0.5f; + + Badge.Draw( + origin + new Vector2(badgeX, MetricsMath.CenterY(RowHeight, badgeSize.Y)), + unread, + _palette.Abgr(Token.AccentEmber, colors), + textAbgr + ); + LastRenderedUnreadDotCount++; + } TabContextMenu.Draw(tab, "ctx", _pool); From 5caa2662876da2136b4b15d722da6f76c0aad314 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 23:54:13 +0200 Subject: [PATCH 048/129] test(selftest): pin the sidebar active-surface invariant Drives the real Sidebar.Draw and reads the render counter, so a regression in the draw path fails instead of a parallel calculation passing. Three cases: one of two tabs active draws exactly one surface, a null active tab draws zero, and icon-only mode still marks the active row. Zero is a legitimate state, not a failure -- PickMainActiveTab returns null when every tab is popped out, so the invariant is "at most one", not "exactly one". --- HellionChat/Plugin.cs | 1 + .../SelfTests/SidebarActiveSurfaceStep.cs | 91 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 HellionChat/SelfTests/SidebarActiveSurfaceStep.cs diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index c117d0c..542a5d7 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -454,6 +454,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.TellResetOnActivateStep(), new SelfTests.CurrentTabCouplingStep(this), new SelfTests.SidebarUnreadDotStep(this), + new SelfTests.SidebarActiveSurfaceStep(this), new SelfTests.UnreadDecisionStep(), new SelfTests.CurrentTabGuidedStep(this), new SelfTests.CardClipPlanStep(this), diff --git a/HellionChat/SelfTests/SidebarActiveSurfaceStep.cs b/HellionChat/SelfTests/SidebarActiveSurfaceStep.cs new file mode 100644 index 0000000..52fd174 --- /dev/null +++ b/HellionChat/SelfTests/SidebarActiveSurfaceStep.cs @@ -0,0 +1,91 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Code; + +namespace HellionChat.SelfTests; + +// v1.10.0/C3: the active row gets a surface and an accent bar, so exactly the +// row the user is on must be marked -- and only that one. Drives the real +// Sidebar.Draw and reads the render-observability counter, so a regression in +// the draw path fails rather than a parallel calculation passing. +// +// "At most one", not "exactly one": PickMainActiveTab returns null when every +// tab is popped out, which is a legitimate state with zero marked rows. +internal sealed class SidebarActiveSurfaceStep : ISelfTestStep +{ + private readonly Plugin _plugin; + + public SidebarActiveSurfaceStep(Plugin plugin) => _plugin = plugin; + + public string Name => "Hellion Chat - Sidebar active surface"; + + public SelfTestStepResult RunStep() + { + var sidebar = _plugin.MainWindow.GetSidebarForSelfTest(); + if (sidebar is null) + { + ImGui.Text("Sidebar null"); + SelfTestReport.Append(Name, "FAIL", new[] { "Sidebar null" }); + return SelfTestStepResult.Fail; + } + + var a = NewProbe("Surface Probe A@SelfTest"); + var b = NewProbe("Surface Probe B@SelfTest"); + var list = new List { a, b }; + var width = (float)Plugin.Config.SidebarAutoSwitchThresholdPx + 100f; + var savedWidth = Plugin.Config.SidebarWidth; + + try + { + Plugin.Config.SidebarWidth = 220; + + // (a) one of two tabs is active -> exactly one surface + Tab? active = a; + sidebar.Draw(width, list, ref active); + if (sidebar.LastRenderedActiveSurfaceCount != 1) + return Fail( + $"active tab drew {sidebar.LastRenderedActiveSurfaceCount}, expected 1" + ); + + // (b) no active tab (every tab popped out) -> zero, not a crash + active = null; + sidebar.Draw(width, list, ref active); + if (sidebar.LastRenderedActiveSurfaceCount != 0) + return Fail( + $"null active drew {sidebar.LastRenderedActiveSurfaceCount}, expected 0" + ); + + // (c) icon-only mode still marks the active row + active = b; + sidebar.Draw((float)Plugin.Config.SidebarAutoSwitchThresholdPx - 1f, list, ref active); + if (sidebar.LastRenderedActiveSurfaceCount != 1) + return Fail($"icon-only drew {sidebar.LastRenderedActiveSurfaceCount}, expected 1"); + + SelfTestReport.Append(Name, "PASS", new[] { "1 / 0 / 1 across the three cases" }); + return SelfTestStepResult.Pass; + } + finally + { + Plugin.Config.SidebarWidth = savedWidth; + } + } + + private static Tab NewProbe(string name) => + new() + { + Name = name, + SelectedChannels = new Dictionary + { + [ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All), + }, + }; + + private SelfTestStepResult Fail(string message) + { + ImGui.Text(message); + SelfTestReport.Append(Name, "FAIL", new[] { message }); + return SelfTestStepResult.Fail; + } + + public void CleanUp() { } +} From e4659bc5973e30f612bd535227d36fe110dbadf7 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 23:56:29 +0200 Subject: [PATCH 049/129] feat(toptabs): draw tabs with an active underline instead of selectables The strip was ImGui.Selectable sized to the bare text width, with a red dot hanging off the item rect. Six things had to be rebuilt by hand, and the first is the one that mattered most. Selectable did have an active fill (ImGuiCol.Header, fed from the theme), so this replaces a fill rather than adding a marker to something bare. The fill stays and the accent underline comes on top -- swapping one for the other would have made the active tab harder to spot. Tabs now have their own height derived from the measured line height plus padding, so the strip no longer collapses onto the text. Hover runs through HoverState like the sidebar, and the label has three states: active and hovered in Text, idle in TextMuted. The unread marker is a count badge in AccentEmber, and its position had to be recomputed: Selectable inflated its bounding box by half the item spacing on every side, so reusing the old GetItemRectMin/Max maths against an InvisibleButton would have made the marker jump. Click semantics follow InvisibleButton's return value, which fires on release like Selectable did. IsItemClicked would have fired on press, a silent behaviour change. The trailing Separator becomes a LineDivider, so both layout modes draw the same rule. --- HellionChat/PluginHostFactory.cs | 4 +- HellionChat/Ui/Components/TopTabBar.cs | 139 ++++++++++++++++++++----- HellionChat/Ui/StyleEngine/Metrics.cs | 8 ++ 3 files changed, 124 insertions(+), 27 deletions(-) diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 794d132..5960b90 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -222,7 +222,9 @@ internal static class PluginHostFactory sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.TopTabBar( - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Windows.MainWindow( sp.GetRequiredService(), diff --git a/HellionChat/Ui/Components/TopTabBar.cs b/HellionChat/Ui/Components/TopTabBar.cs index 59e10c9..5a36776 100644 --- a/HellionChat/Ui/Components/TopTabBar.cs +++ b/HellionChat/Ui/Components/TopTabBar.cs @@ -1,5 +1,8 @@ using System.Numerics; using Dalamud.Bindings.ImGui; +using HellionChat.Themes; +using HellionChat.Ui.StyleEngine; +using HellionChat.Ui.StyleEngine.Widgets; using HellionChat.Util; namespace HellionChat.Ui.Components; @@ -10,14 +13,36 @@ namespace HellionChat.Ui.Components; internal sealed class TopTabBar { private readonly Windows.ChannelPopoutPool _pool; + private readonly ThemeRegistry _themes; + private readonly WidgetPalette _palette; - public TopTabBar(Windows.ChannelPopoutPool pool) + // Render observability, mirroring the sidebar counters: at most one tab + // carries the active underline. Zero is valid -- every tab can be popped out. + internal int LastRenderedUnderlineCount { get; private set; } + + public TopTabBar(Windows.ChannelPopoutPool pool, ThemeRegistry themes, TokenResolver resolver) { _pool = pool; + _themes = themes; + _palette = new WidgetPalette(resolver); } public void Draw(IReadOnlyList tabs, ref Tab? activeTab) { + LastRenderedUnderlineCount = 0; + + var colors = _themes.Active.Colors; + var surfaceActive = _palette.Abgr(Token.SurfaceActive, colors); + var surfaceHover = _palette.Abgr(Token.SurfaceHover, colors); + var accent = _palette.Abgr(Token.AccentPrimary, colors); + var textAbgr = _palette.Abgr(Token.Text, colors); + var mutedAbgr = _palette.Abgr(Token.TextMuted, colors); + var borderAbgr = _palette.Abgr(Token.Border, colors); + + var height = Metrics.TopTabHeight; + var padX = Metrics.TopTabPaddingX; + var dl = ImGui.GetWindowDrawList(); + var firstDrawn = true; for (var i = 0; i < tabs.Count; i++) { @@ -32,48 +57,110 @@ internal sealed class TopTabBar firstDrawn = false; var selected = ReferenceEquals(tab, activeTab); - // Size the selectable to its own label width. A zero width makes ImGui - // stretch the selectable's box to the full remaining window width - // (imgui_widgets.cpp:7378), so in this SameLine row every tab overlaps - // into one giant bar and clicking never lands on the intended tab. - var tabWidth = ImGui.CalcTextSize(tab.Name).X; - if ( - ImGui.Selectable( - $"{tab.Name}###hellion_toptab_{tab.Identifier}", - selected, - ImGuiSelectableFlags.None, - new Vector2(tabWidth, 0) - ) - ) + var origin = ImGui.GetCursorScreenPos(); + var width = ImGui.CalcTextSize(tab.Name).X + padX * 2f; + var size = new Vector2(width, height); + + // The ### keeps the ImGui id stable across a rename; without it the + // context menu loses its binding the moment the label changes. Built + // once and reused for the hover key, since GetID hashes the same + // string the button registers under. + var buttonId = $"###hellion_toptab_{tab.Identifier}"; + var hoverId = ImGui.GetID(buttonId); + var pressed = ImGui.InvisibleButton(buttonId, size); + var hovered = ImGui.IsItemHovered(); + var hoverAmount = HoverState.Query(hoverId, hovered); + + if (pressed) { var previous = activeTab; activeTab = tab; TabLifecycleHelpers.OnTabActivated(tab, previous); + selected = true; } - // 1.5.6-parity unread dot at the item's top-right. Gate on the - // POST-click selection (not the frame-start 'selected') so clicking a - // tab suppresses its dot the same frame, like the sidebar. The active - // tab is also zeroed every frame (MainWindow.Draw). + DrawTab( + dl, + origin, + size, + tab.Name, + selected, + hoverAmount, + surfaceActive, + surfaceHover, + accent, + selected || hovered ? textAbgr : mutedAbgr, + padX + ); + + if (selected) + LastRenderedUnderlineCount++; + + // 1.5.6-parity unread marker. Gated on the POST-click selection so + // clicking a tab clears it the same frame, like the sidebar. if ( !ReferenceEquals(tab, activeTab) && tab.UnreadMode != UnreadMode.None && tab.Unread > 0 ) { - var max = ImGui.GetItemRectMax(); - var min = ImGui.GetItemRectMin(); - var danger = ColourUtil.RgbaToAbgr( - Plugin.Instance.ThemeRegistry.Active.Colors.StatusDanger + var unread = (int)Math.Min(tab.Unread, int.MaxValue); + var badgeSize = Badge.CalcSize(unread); + var inset = Metrics.TopTabUnreadInset; + Badge.Draw( + new Vector2(origin.X + size.X - badgeSize.X - inset, origin.Y + inset), + unread, + _palette.Abgr(Token.AccentEmber, colors), + textAbgr ); - ImGui - .GetWindowDrawList() - .AddCircleFilled(new Vector2(max.X - 4f, min.Y + 4f), 3.5f, danger, 12); } TabContextMenu.Draw(tab, $"toptab_ctx_{tab.Identifier}", _pool); } - ImGui.Separator(); + LineDivider.Draw(null, borderAbgr, mutedAbgr); + } + + // Keeps the fill Selectable used to provide (ImGuiCol.Header) and adds the + // underline on top. Dropping the fill for the underline alone would make the + // active tab harder to spot, not easier. + private static void DrawTab( + ImDrawListPtr dl, + Vector2 origin, + Vector2 size, + string label, + bool selected, + float hoverAmount, + uint surfaceActive, + uint surfaceHover, + uint accent, + uint labelAbgr, + float padX + ) + { + var max = origin + size; + + if (selected) + dl.AddRectFilled(origin, max, surfaceActive); + + if (hoverAmount > 0f) + { + var a = (uint) + Math.Clamp(MathF.Round(((surfaceHover >> 24) & 0xFF) * hoverAmount), 0f, 255f); + dl.AddRectFilled(origin, max, (surfaceHover & 0x00FFFFFFu) | (a << 24)); + } + + if (selected) + { + var thickness = Metrics.TopTabUnderline; + dl.AddRectFilled(new Vector2(origin.X, max.Y - thickness), max, accent); + } + + var textSize = ImGui.CalcTextSize(label); + dl.AddText( + new Vector2(origin.X + padX, origin.Y + MetricsMath.CenterY(size.Y, textSize.Y)), + labelAbgr, + label + ); } } diff --git a/HellionChat/Ui/StyleEngine/Metrics.cs b/HellionChat/Ui/StyleEngine/Metrics.cs index 8ed96f0..849d45c 100644 --- a/HellionChat/Ui/StyleEngine/Metrics.cs +++ b/HellionChat/Ui/StyleEngine/Metrics.cs @@ -39,6 +39,8 @@ internal static class Metrics // --- Top tab bar --- internal const float TopTabUnreadRadiusRaw = 3.5f; internal const float TopTabUnreadInsetRaw = 4f; + internal const float TopTabPaddingXRaw = 10f; + internal const float TopTabUnderlineRaw = 2f; // --- Status bar --- internal const float StatusBorderThicknessRaw = 1f; @@ -94,6 +96,12 @@ internal static class Metrics internal static float TopTabUnreadRadius => MetricsMath.Scale(TopTabUnreadRadiusRaw, Scale); internal static float TopTabUnreadInset => MetricsMath.Scale(TopTabUnreadInsetRaw, Scale); + internal static float TopTabPaddingX => MetricsMath.Scale(TopTabPaddingXRaw, Scale); + internal static float TopTabUnderline => MetricsMath.Scale(TopTabUnderlineRaw, Scale); + + // Measured, not scaled: the tab has to fit the text, and the font comes from + // Config.FontSizeV2 which display scaling does not feed into. + internal static float TopTabHeight => ImGui.GetTextLineHeight() + MathF.Round(10f * Scale); internal static float StatusBorderThickness => MetricsMath.Scale(StatusBorderThicknessRaw, Scale); From 5696eac55a7209dcde2aefe12b17b8f0442647b8 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 23:57:11 +0200 Subject: [PATCH 050/129] test(selftest): pin the top-tab underline invariant TopTabBar had no observability at all -- no counter, no self-test reaching it. It now exposes LastRenderedUnderlineCount and MainWindow hands the component out the same way it already does for the sidebar. Three cases: one of two tabs active draws exactly one underline, a null active tab draws zero, and an active tab that is not in the list also draws zero. The last one matters because the strip skips popped-out tabs, so the active tab legitimately need not be among the drawn ones. --- HellionChat/Plugin.cs | 1 + HellionChat/SelfTests/TopTabUnderlineStep.cs | 76 ++++++++++++++++++++ HellionChat/Ui/Windows/MainWindow.cs | 2 + 3 files changed, 79 insertions(+) create mode 100644 HellionChat/SelfTests/TopTabUnderlineStep.cs diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 542a5d7..8ed0f49 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -455,6 +455,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.CurrentTabCouplingStep(this), new SelfTests.SidebarUnreadDotStep(this), new SelfTests.SidebarActiveSurfaceStep(this), + new SelfTests.TopTabUnderlineStep(this), new SelfTests.UnreadDecisionStep(), new SelfTests.CurrentTabGuidedStep(this), new SelfTests.CardClipPlanStep(this), diff --git a/HellionChat/SelfTests/TopTabUnderlineStep.cs b/HellionChat/SelfTests/TopTabUnderlineStep.cs new file mode 100644 index 0000000..1245d8e --- /dev/null +++ b/HellionChat/SelfTests/TopTabUnderlineStep.cs @@ -0,0 +1,76 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Code; + +namespace HellionChat.SelfTests; + +// v1.10.0/D1: the top-tab strip marks the active tab with a fill plus an accent +// underline. Drives the real TopTabBar.Draw and reads the render counter. +// +// "At most one", not "exactly one": the strip skips popped-out tabs, so zero +// underlines is a legitimate state. +internal sealed class TopTabUnderlineStep : ISelfTestStep +{ + private readonly Plugin _plugin; + + public TopTabUnderlineStep(Plugin plugin) => _plugin = plugin; + + public string Name => "Hellion Chat - Top tab underline"; + + public SelfTestStepResult RunStep() + { + var strip = _plugin.MainWindow.GetTopTabsForSelfTest(); + if (strip is null) + { + ImGui.Text("TopTabBar null"); + SelfTestReport.Append(Name, "FAIL", new[] { "TopTabBar null" }); + return SelfTestStepResult.Fail; + } + + var a = NewProbe("Underline Probe A@SelfTest"); + var b = NewProbe("Underline Probe B@SelfTest"); + var list = new List { a, b }; + + // (a) one of two tabs active -> exactly one underline + Tab? active = a; + strip.Draw(list, ref active); + if (strip.LastRenderedUnderlineCount != 1) + return Fail( + $"active tab drew {strip.LastRenderedUnderlineCount} underlines, expected 1" + ); + + // (b) no active tab -> zero, not a crash + active = null; + strip.Draw(list, ref active); + if (strip.LastRenderedUnderlineCount != 0) + return Fail($"null active drew {strip.LastRenderedUnderlineCount}, expected 0"); + + // (c) an active tab that is not in the list -> still zero + active = NewProbe("Absent@SelfTest"); + strip.Draw(list, ref active); + if (strip.LastRenderedUnderlineCount != 0) + return Fail($"absent active drew {strip.LastRenderedUnderlineCount}, expected 0"); + + SelfTestReport.Append(Name, "PASS", new[] { "1 / 0 / 0 across the three cases" }); + return SelfTestStepResult.Pass; + } + + private static Tab NewProbe(string name) => + new() + { + Name = name, + SelectedChannels = new Dictionary + { + [ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All), + }, + }; + + private SelfTestStepResult Fail(string message) + { + ImGui.Text(message); + SelfTestReport.Append(Name, "FAIL", new[] { message }); + return SelfTestStepResult.Fail; + } + + public void CleanUp() { } +} diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index a89982a..a65460e 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -204,6 +204,8 @@ internal sealed class MainWindow : Window, IFocusableChatWindow internal Components.MessageList GetMessageListForSelfTest() => _messages; + internal Components.TopTabBar GetTopTabsForSelfTest() => _topTabs; + public override bool DrawConditions() => !_userHidden; internal void UserHide() => _userHidden = true; From 918cdc81117cc8bc5bda29e3ed51410238b7e2d4 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 17 Aug 2026 23:59:17 +0200 Subject: [PATCH 051/129] fix(style): stop the hover registry from churning on idle rows Review of block B found Query allocating an entry for every element it was asked about, hovered or not. The cycle: Query creates the entry, the next BeginFrame steps it to zero, evicts it, and the next Query creates it again. With fifteen tabs that is fifteen allocations plus fifteen dictionary inserts and removes per frame, permanently, with the mouse nowhere near the window. That is exactly the property master spec 7.5 asks for and the one this block claimed to improve, so it ate the two string allocations 31fa410 had just saved. An unhovered element with no entry now returns zero without creating one. The footprint self-test only ever queried with hovered: true, which is why it could not see this. It now runs an idle phase as well. Four smaller items from the same review: Advance skipped clearing the hover flags when deltaTime was zero, so such a frame carried the previous frame's state forward. Metrics reads GlobalScaleSafe now. The unsafe variant throws while the interface manager is still coming up, and block F pulls Metrics into more call sites. Badge.CalcSize returned a full-size box for a count of zero while Draw drew nothing, so a caller that reserves and then draws left a badge-shaped hole on every tab without unread messages -- the normal case. IconButton caches its glyph strings; ToIconString allocates on every call and keeps no cache of its own. And the widget gallery clamps its own row width, since asserting on a zero-width button in the window that demonstrates the clamp would be a poor look. --- .../SelfTests/HoverStateFootprintStep.cs | 31 ++++++++++++++++--- HellionChat/Ui/StyleEngine/HoverState.cs | 15 +++++++++ HellionChat/Ui/StyleEngine/Metrics.cs | 4 ++- HellionChat/Ui/StyleEngine/Widgets/Badge.cs | 6 ++++ .../Ui/StyleEngine/Widgets/IconButton.cs | 16 +++++++++- HellionChat/Ui/Windows/WidgetGalleryWindow.cs | 10 ++++-- 6 files changed, 74 insertions(+), 8 deletions(-) diff --git a/HellionChat/SelfTests/HoverStateFootprintStep.cs b/HellionChat/SelfTests/HoverStateFootprintStep.cs index e726155..a8c9d43 100644 --- a/HellionChat/SelfTests/HoverStateFootprintStep.cs +++ b/HellionChat/SelfTests/HoverStateFootprintStep.cs @@ -59,13 +59,36 @@ internal sealed class HoverStateFootprintStep : ISelfTestStep if (HoverState.TrackedCount != 0) { - var msg = $"Registry leaked {HoverState.TrackedCount} entries after fade-out"; - ImGui.Text(msg); - SelfTestReport.Append(Name, "FAIL", new[] { msg }); + var leak = $"Registry leaked {HoverState.TrackedCount} entries after fade-out"; + ImGui.Text(leak); + SelfTestReport.Append(Name, "FAIL", new[] { leak }); return SelfTestStepResult.Fail; } - SelfTestReport.Append(Name, "PASS", new[] { "registry empty after fade-out" }); + // The case a hovered-only probe cannot see: querying an element that + // is NOT hovered must not create an entry. Otherwise every row in + // the window allocates one per frame and loses it again in the next + // BeginFrame, forever. + for (var frame = 0; frame < 10; frame++) + { + foreach (var id in ids) + HoverState.Query(id, hovered: false); + HoverState.AdvanceForTest(Frame); + + if (HoverState.TrackedCount == 0) + continue; + + var churn = $"Unhovered query created {HoverState.TrackedCount} entries"; + ImGui.Text(churn); + SelfTestReport.Append(Name, "FAIL", new[] { churn }); + return SelfTestStepResult.Fail; + } + + SelfTestReport.Append( + Name, + "PASS", + new[] { "no growth while hovered, empty after fade-out, no churn when idle" } + ); return SelfTestStepResult.Pass; } finally diff --git a/HellionChat/Ui/StyleEngine/HoverState.cs b/HellionChat/Ui/StyleEngine/HoverState.cs index f3d194e..15775ad 100644 --- a/HellionChat/Ui/StyleEngine/HoverState.cs +++ b/HellionChat/Ui/StyleEngine/HoverState.cs @@ -48,6 +48,12 @@ internal static class HoverState if (!Entries.TryGetValue(id, out var entry)) { + // Nothing to fade from, so nothing to track. Without this a + // never-hovered element allocates an entry in Query and loses it + // again in the next BeginFrame, every frame, for every row. + if (!hovered) + return 0f; + entry = new Entry(); Entries[id] = entry; } @@ -58,10 +64,19 @@ internal static class HoverState return entry.Value; } + // Never re-entrant: BeginFrame runs once from the draw thread, AdvanceForTest + // only from a self-test step. Evicted is shared, so overlapping calls would + // corrupt it. private static void Advance(float deltaTime) { if (deltaTime <= 0f) + { + // Still clear the flags: a zero-delta frame otherwise carries the + // previous frame's hover state forward. + foreach (var entry in Entries.Values) + entry.Hovered = false; return; + } Evicted.Clear(); foreach (var (id, entry) in Entries) diff --git a/HellionChat/Ui/StyleEngine/Metrics.cs b/HellionChat/Ui/StyleEngine/Metrics.cs index 849d45c..5a1ef30 100644 --- a/HellionChat/Ui/StyleEngine/Metrics.cs +++ b/HellionChat/Ui/StyleEngine/Metrics.cs @@ -66,8 +66,10 @@ internal static class Metrics if (frame == _cachedFrame) return _cachedScale; + // Safe variant: GlobalScale throws while the interface manager is + // still coming up, and Block F pulls Metrics into more call sites. + _cachedScale = ImGuiHelpers.GlobalScaleSafe; _cachedFrame = frame; - _cachedScale = ImGuiHelpers.GlobalScale; return _cachedScale; } } diff --git a/HellionChat/Ui/StyleEngine/Widgets/Badge.cs b/HellionChat/Ui/StyleEngine/Widgets/Badge.cs index 44f29cb..a639201 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/Badge.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/Badge.cs @@ -21,8 +21,14 @@ internal static class Badge internal static string Format(int count, int maxCount) => count > maxCount ? $"{maxCount}+" : count.ToString(); + // Zero for a count of zero, matching Draw: a caller that reserves space and + // then draws would otherwise leave a badge-shaped hole on every tab without + // unread messages, which is the normal case. internal static Vector2 CalcSize(int count, BadgeStyle? styleOverride = null) { + if (count <= 0) + return Vector2.Zero; + var style = styleOverride ?? new BadgeStyle(); var text = Format(count, style.MaxCount); return WidgetGeometry.Badge( diff --git a/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs b/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs index 488c5e9..2bd23b8 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs @@ -20,9 +20,23 @@ internal readonly record struct IconButtonStyle // the hit area plus the glyph. internal static class IconButton { + // FontAwesomeExtensions.ToIconString allocates on every call and holds no + // cache of its own, so a per-frame glyph would allocate per button per frame. + private static readonly Dictionary GlyphCache = []; + internal static Vector2 CalcSize(float width, float height) => WidgetGeometry.IconButton(width, height); + private static string Glyph(FontAwesomeIcon icon) + { + if (GlyphCache.TryGetValue(icon, out var s)) + return s; + + s = icon.ToIconString(); + GlyphCache[icon] = s; + return s; + } + internal static (bool Clicked, bool Hovered) Draw( uint id, Vector2 size, @@ -59,7 +73,7 @@ internal static class IconButton { using (font.Push()) { - var text = icon.ToIconString(); + var text = Glyph(icon); var textSize = ImGui.CalcTextSize(text); dl.AddText(origin + (clamped - textSize) * 0.5f, glyphAbgr, text); } diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index ba17a31..3663d4e 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -55,8 +55,14 @@ internal sealed class WidgetGalleryWindow : Window ImGui.TextUnformatted("Row"); ImGui.Checkbox("active##row", ref _rowActive); - var width = ImGui.GetContentRegionAvail().X; - var height = Metrics.SidebarRowHeight; + // Clamped: this window demonstrates the guard, it should not be the one + // that asserts when dragged narrow. + var rowSize = HellionChat.Util.WidgetGeometry.IconButton( + ImGui.GetContentRegionAvail().X, + Metrics.SidebarRowHeight + ); + var width = rowSize.X; + var height = rowSize.Y; for (var i = 0; i < 3; i++) { From f2dd49e454139a92d1eab5caa99335a7299d3cfb Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 00:00:15 +0200 Subject: [PATCH 052/129] refactor(input): move the channel pill onto the pill widget The pill was already hand-drawn here -- filled rect, rounding 6, a frozen 3px text offset and an InvisibleButton over the top. That is the Pill widget, built before the widget existed, so it becomes the widget's first consumer. Its text now centres against the measured line height instead of the frozen offset. InputBar.Height moves to Metrics as well. MainWindow and ChannelPopoutWindow both reserve their body height against it, so both follow without changes. It had to happen in the same commit: the pill inside the bar now scales, and a bar that did not would have clipped it at 150%. --- HellionChat/Ui/Components/InputBar.cs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/HellionChat/Ui/Components/InputBar.cs b/HellionChat/Ui/Components/InputBar.cs index 55bb2d0..47dfe8f 100644 --- a/HellionChat/Ui/Components/InputBar.cs +++ b/HellionChat/Ui/Components/InputBar.cs @@ -26,9 +26,9 @@ namespace HellionChat.Ui.Components; // the current game-side channel. internal sealed class InputBar { - public const float Height = 32f; - private const float PillHeight = 22f; - private const float PillPaddingX = 8f; + // Scaled: MainWindow and ChannelPopoutWindow both reserve against this, so + // they follow automatically. The pill's own metrics live in PillStyle now. + public static float Height => StyleEngine.Metrics.InputBarHeight; private const int BufferCapacity = 500; private const float QuickButtonsReserve = 130f; @@ -257,19 +257,15 @@ internal sealed class InputBar private void DrawChannelPill(Tab? tab, bool isTell, uint pillAbgr, uint textAbgr) { var label = ResolvePillLabel(tab, isTell); - var labelSize = ImGui.CalcTextSize(label); - var width = labelSize.X + PillPaddingX * 2; + var size = StyleEngine.Widgets.Pill.CalcSize(label, withDot: false); var origin = ImGui.GetCursorScreenPos(); - var dl = ImGui.GetWindowDrawList(); - var max = origin + new Vector2(width, PillHeight); - dl.AddRectFilled(origin, max, pillAbgr, 6f); - dl.AddText(origin + new Vector2(PillPaddingX, 3f), textAbgr, label); + StyleEngine.Widgets.Pill.Draw(origin, label, pillAbgr, textAbgr); // Hit area over the rendered pill so a click opens the channel // picker. InvisibleButton both reserves the layout slot and gives // the popup a stable anchor item. - ImGui.InvisibleButton("##hellion-pill", new Vector2(width, PillHeight)); + ImGui.InvisibleButton("##hellion-pill", size); if (ImGui.IsItemClicked() && tab is not null) ImGui.OpenPopup("##hellion-channel-picker"); From a7ed8e1146e6f8e48816e2b597edf03616d897b8 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 00:02:55 +0200 Subject: [PATCH 053/129] feat(statusbar): render slots as pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five slots drawn as flowing text with a TextDisabled interpunct between them. They are pills now: channel with its status dot, privacy with its lock glyph, counts, tells, and version right-aligned. The composite texts stay composite. FormatCounts produces "5 tabs · 1.2k msg" and the version slot "v1.10.0 · Hellion" as single strings, and splitting them would have gained nothing while breaking their pinned format. Height derives from the pill rather than sitting beside it as a second constant. MainWindow reserves the body against this property, so the two drifting apart is the entire failure mode here, and a pill is taller than a bare text line. The right-hand slot's fit check actually measures now. The old one compared the region against a flat 200px and never looked at the left-hand slots at all, so at the 480px minimum width it kept drawing the version while the left run needed more room than was left -- the overlap predates this change. Pill grew optional icon support for the privacy lock. Without it that glyph would have been silently dropped in the move. StatusBarCacheTests is re-enabled. It sat in the csproj Compile Remove block, so the format contract this commit reshapes around had no live net at all. Two of its cases construct StatusBar with null services, which is safe because SnapshotForTest touches neither. --- HellionChat/Ui/Components/StatusBar.cs | 133 ++++++++++++--------- HellionChat/Ui/StyleEngine/Widgets/Pill.cs | 45 ++++++- 2 files changed, 115 insertions(+), 63 deletions(-) diff --git a/HellionChat/Ui/Components/StatusBar.cs b/HellionChat/Ui/Components/StatusBar.cs index 3b867b8..4c561a8 100644 --- a/HellionChat/Ui/Components/StatusBar.cs +++ b/HellionChat/Ui/Components/StatusBar.cs @@ -2,11 +2,13 @@ using System.Globalization; using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Interface; +using Dalamud.Interface.ManagedFontAtlas; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using HellionChat.Code; using HellionChat.Resources; using HellionChat.Themes; +using HellionChat.Ui.StyleEngine; using HellionChat.Util; namespace HellionChat.Ui.Components; @@ -21,13 +23,20 @@ internal sealed class StatusBar // scaling above 100% — GetTextLineHeightWithSpacing scales with the // active ImGui font, the 2px spacer rounds against GlobalScale so the // result lands on integer pixel boundaries. + // Derived from the pill, never a second constant: MainWindow reserves the + // body height against this property, so the two drifting apart is the whole + // failure mode. Slots are pills now, and a pill is taller than a text line. public static float Height => - ImGui.GetTextLineHeightWithSpacing() + MathF.Round(2f * ImGuiHelpers.GlobalScale); + StyleEngine.Widgets.Pill.CalcSize("X", withDot: false).Y + + StyleEngine.Metrics.StatusTopSpacer * 2f; private const long UpdateIntervalMs = 1000; private readonly ThemeRegistry _themes; private readonly FontManager _fonts; + private readonly StyleEngine.Widgets.WidgetPalette _palette = new( + new StyleEngine.TokenResolver() + ); private long _lastUpdateMs = -UpdateIntervalMs; private string _cachedCountsText = string.Empty; @@ -111,83 +120,91 @@ internal sealed class StatusBar // Top border via DrawList — ImGui.Separator has too much padding for // a tight bottom strip. - var cursorY = ImGui.GetCursorScreenPos().Y; + var origin = ImGui.GetCursorScreenPos(); var winLeft = ImGui.GetWindowPos().X; var winRight = winLeft + ImGui.GetWindowSize().X; + var palette = _palette; + var colors = theme.Colors; + ImGui .GetWindowDrawList() .AddLine( - new Vector2(winLeft, cursorY), - new Vector2(winRight, cursorY), - ColourUtil.RgbaToAbgr(theme.Colors.Border), - 1f + new Vector2(winLeft, origin.Y), + new Vector2(winRight, origin.Y), + palette.Abgr(Token.Border, colors), + StyleEngine.Metrics.StatusBorderThickness ); - ImGui.Dummy(new Vector2(0, 2)); - // Slot 1: active channel indicator + var pillFill = palette.Abgr(Token.SurfaceRaised, colors); + var pillText = palette.Abgr(Token.Text, colors); + var mutedText = palette.Abgr(Token.TextMuted, colors); + var gap = StyleEngine.Metrics.StatusTopSpacer * 3f; + var top = origin.Y + StyleEngine.Metrics.StatusTopSpacer; + + // Slot 1: active channel. The dot doubles as the connection indicator. var inputCh = activeTab?.CurrentChannel?.Channel ?? InputChannel.Invalid; var hasChannel = inputCh != InputChannel.Invalid; - var chatType = inputCh.ToChatType(); - var channelName = hasChannel ? chatType.Name() : "—"; - var dotColor = hasChannel ? theme.Colors.Primary : theme.Colors.TextMuted; - DrawDot(dotColor); - ImGui.SameLine(); - ImGui.TextUnformatted(channelName); + var channelName = hasChannel ? inputCh.ToChatType().Name() : "—"; + var dotAbgr = hasChannel + ? palette.Abgr(Token.AccentPrimary, colors) + : palette.Abgr(Token.TextMuted, colors); - // Slot 2: privacy badge - ImGui.SameLine(); - DrawSeparator(); - ImGui.SameLine(); - using (_fonts.FontAwesome.Push()) - ImGui.TextUnformatted(FontAwesomeIcon.Lock.ToIconString()); - ImGui.SameLine(); + // Slot 2 label, resolved before measuring so the run width is exact. var privacyLabel = Plugin.Config.PrivacyFilterEnabled ? HellionStrings.StatusBar_Privacy_Enabled : HellionStrings.StatusBar_Privacy_Open; - ImGui.TextUnformatted(privacyLabel); - // Slot 3: counts - ImGui.SameLine(); - DrawSeparator(); - ImGui.SameLine(); - ImGui.TextUnformatted(_cachedCountsText); + var x = origin.X; + x += DrawSlot(new Vector2(x, top), channelName, pillFill, pillText, dotAbgr) + gap; + x += + DrawSlot( + new Vector2(x, top), + privacyLabel, + pillFill, + pillText, + null, + (FontAwesomeIcon.Lock, _fonts.FontAwesome) + ) + gap; + x += DrawSlot(new Vector2(x, top), _cachedCountsText, pillFill, mutedText, null) + gap; - // Slot 4: tells (hidden at 0) if (!string.IsNullOrEmpty(_cachedTellsText)) - { - ImGui.SameLine(); - DrawSeparator(); - ImGui.SameLine(); - ImGui.TextUnformatted(_cachedTellsText); - } + x += DrawSlot(new Vector2(x, top), _cachedTellsText, pillFill, pillText, null) + gap; - // Slot 5: version + brand, right-aligned, muted. Hidden when the - // window cannot fit all five slots without overlap. + // Slot 5: version + brand, right-aligned. Dropped when the left-hand run + // would actually collide with it -- the old check compared against a flat + // 200px and never measured the left slots at all. var versionText = $"v{Plugin.Interface.Manifest.AssemblyVersion} · Hellion"; - var versionWidth = ImGui.CalcTextSize(versionText).X; - var contentRegionMax = ImGui.GetContentRegionMax().X; - const float MinOtherSlotsWidth = 200f; - if (contentRegionMax - versionWidth > MinOtherSlotsWidth) - { - ImGui.SameLine(contentRegionMax - versionWidth); - using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(theme.Colors.TextMuted))) - ImGui.TextUnformatted(versionText); - } - } + var versionWidth = StyleEngine.Widgets.Pill.CalcSize(versionText, withDot: false).X; + var regionRight = origin.X + ImGui.GetContentRegionAvail().X; + var leftRunEnd = x - gap; - private static void DrawDot(uint rgba) - { - var pos = ImGui.GetCursorScreenPos(); - const float radius = 4f; - ImGui - .GetWindowDrawList() - .AddCircleFilled( - new Vector2(pos.X + radius, pos.Y + ImGui.GetTextLineHeight() / 2f), - radius, - ColourUtil.RgbaToAbgr(rgba) + if (regionRight - versionWidth - gap > leftRunEnd) + DrawSlot( + new Vector2(regionRight - versionWidth, top), + versionText, + pillFill, + mutedText, + null ); - ImGui.Dummy(new Vector2(radius * 2 + 4, ImGui.GetTextLineHeight())); + + ImGui.Dummy(new Vector2(0, Height)); } - private static void DrawSeparator() => ImGui.TextDisabled("·"); + // Returns the slot width so the caller can run them left to right and know + // where the run ends. + private static float DrawSlot( + Vector2 origin, + string label, + uint fillAbgr, + uint textAbgr, + uint? dotAbgr, + (FontAwesomeIcon Icon, IFontHandle Font)? icon = null + ) + { + StyleEngine.Widgets.Pill.Draw(origin, label, fillAbgr, textAbgr, dotAbgr, icon: icon); + var iconWidth = icon is { } ic + ? StyleEngine.Widgets.Pill.MeasureIcon(ic.Icon, ic.Font) + : 0f; + return StyleEngine.Widgets.Pill.CalcSize(label, dotAbgr.HasValue, iconWidth: iconWidth).X; + } } diff --git a/HellionChat/Ui/StyleEngine/Widgets/Pill.cs b/HellionChat/Ui/StyleEngine/Widgets/Pill.cs index fbdfcac..f7cd511 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/Pill.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/Pill.cs @@ -1,5 +1,7 @@ using System.Numerics; using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.ManagedFontAtlas; using HellionChat.Util; namespace HellionChat.Ui.StyleEngine.Widgets; @@ -16,15 +18,23 @@ internal readonly record struct PillStyle public bool Outlined { get; init; } } -// Filled capsule with a label, optionally preceded by a status dot. Outlined is -// the variant that would otherwise have been a separate Chip widget. +// Filled capsule with a label, optionally preceded by a status dot or an icon. +// Outlined is the variant that would otherwise have been a separate Chip widget. internal static class Pill { - internal static Vector2 CalcSize(string label, bool withDot, PillStyle? styleOverride = null) + internal static Vector2 CalcSize( + string label, + bool withDot, + PillStyle? styleOverride = null, + float iconWidth = 0f + ) { var style = styleOverride ?? new PillStyle(); var scale = Metrics.Scale; var lead = withDot ? (style.DotRadius * 2f + style.DotGap) * scale : 0f; + if (iconWidth > 0f) + lead += iconWidth + style.DotGap * scale; + return WidgetGeometry.Pill( ImGui.CalcTextSize(label), style.PaddingX * scale, @@ -33,18 +43,28 @@ internal static class Pill ); } + // Measures a glyph in the icon font so a caller can feed iconWidth above + // without pushing the font twice. + internal static float MeasureIcon(FontAwesomeIcon icon, IFontHandle font) + { + using (font.Push()) + return ImGui.CalcTextSize(icon.ToIconString()).X; + } + internal static void Draw( Vector2 origin, string label, uint fillAbgr, uint textAbgr, uint? dotAbgr = null, - PillStyle? styleOverride = null + PillStyle? styleOverride = null, + (FontAwesomeIcon Icon, IFontHandle Font)? icon = null ) { var style = styleOverride ?? new PillStyle(); var scale = Metrics.Scale; - var size = CalcSize(label, dotAbgr.HasValue, style); + var iconWidth = icon is { } ic ? MeasureIcon(ic.Icon, ic.Font) : 0f; + var size = CalcSize(label, dotAbgr.HasValue, style, iconWidth); var dl = ImGui.GetWindowDrawList(); var max = origin + size; var rounding = style.Rounding * scale; @@ -63,6 +83,21 @@ internal static class Pill textX += r * 2f + style.DotGap * scale; } + if (icon is { } glyph) + { + using (glyph.Font.Push()) + { + var text = glyph.Icon.ToIconString(); + var h = ImGui.CalcTextSize(text).Y; + dl.AddText( + new Vector2(textX, origin.Y + MetricsMath.CenterY(size.Y, h)), + textAbgr, + text + ); + } + textX += iconWidth + style.DotGap * scale; + } + // Measured, not a frozen offset: the font comes from Config.FontSizeV2, // which display scaling does not feed into. var textY = origin.Y + MetricsMath.CenterY(size.Y, ImGui.GetTextLineHeight()); From 0d6877ddc6251a6fbefc37175c82f77d1d1ac831 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 00:04:00 +0200 Subject: [PATCH 054/129] fix(settings): align the theme preview with the real chrome The preview had been showing surfaces, an accent bar and an unread marker for months while the real sidebar drew none of them. Now that the sidebar has caught up, the preview is the one that is wrong -- in two specific ways. It put the accent bar on row 0 and the raised surface on row 1, so it showed two half-active rows instead of one active row. Both now sit on row 0, and the rows get the separator the real ones have. The unread marker was a 4x4 square. It is a rounded count badge now. The status bar preview was a separate 20px reimplementation with three coloured squares and a hard-coded label. It mirrors the pill layout instead, with the status colours riding along as slot dots so a theme still shows what it does to them. --- .../Components/Settings/LivePreviewPanel.cs | 112 +++++++++++++----- 1 file changed, 83 insertions(+), 29 deletions(-) diff --git a/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs b/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs index c058133..4621996 100644 --- a/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs +++ b/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs @@ -181,18 +181,29 @@ internal sealed class LivePreviewPanel : IDisposable var primaryAbgr = ColourUtil.RgbaToAbgr(theme.Colors.Primary); var accentAbgr = ColourUtil.RgbaToAbgr(theme.Colors.Accent); + // Row 0 is the active one and carries BOTH the raised surface and the + // accent bar, the way the real sidebar draws it. Until v1.10.0 those two + // sat on different rows here, so the preview promised a layout the + // sidebar never delivered -- and then the sidebar caught up. + var borderAbgr = ColourUtil.RgbaToAbgr(theme.Colors.Border); ReadOnlySpan labels = ["Linkshell", "Tell", "FC"]; for (var i = 0; i < 3; i++) { var rowMin = new Vector2(origin.X, origin.Y + i * rowHeight); var rowMax = new Vector2(origin.X + SidebarWidth, rowMin.Y + rowHeight); - var bg = i == 1 ? surfaceHover : surface; - draw.AddRectFilled(rowMin, rowMax, bg); + var isActive = i == 0; - if (i == 0) - { + draw.AddRectFilled(rowMin, rowMax, isActive ? surfaceHover : surface); + + if (isActive) draw.AddRectFilled(rowMin, new Vector2(rowMin.X + 2f, rowMax.Y), primaryAbgr); - } + + draw.AddLine( + new Vector2(rowMin.X, rowMax.Y - 1f), + new Vector2(rowMax.X, rowMax.Y - 1f), + borderAbgr, + 1f + ); var labelSize = ImGui.CalcTextSize(labels[i]); var textPos = new Vector2(rowMin.X + 6f, rowMin.Y + (rowHeight - labelSize.Y) * 0.5f); @@ -200,13 +211,21 @@ internal sealed class LivePreviewPanel : IDisposable if (i == 1) { - // Tell row carries an unread-dot in Accent on the right. - var dotCenter = new Vector2(rowMax.X - 8f, rowMin.Y + rowHeight * 0.5f); - draw.AddRectFilled( - new Vector2(dotCenter.X - 2f, dotCenter.Y - 2f), - new Vector2(dotCenter.X + 2f, dotCenter.Y + 2f), - accentAbgr + // Unread marker: a rounded count badge in Accent, not a square. + var badgeH = MathF.Min(rowHeight - 4f, 14f); + var badgeW = badgeH * 1.4f; + var badgeMin = new Vector2( + rowMax.X - badgeW - 4f, + rowMin.Y + (rowHeight - badgeH) * 0.5f ); + var badgeMax = badgeMin + new Vector2(badgeW, badgeH); + draw.AddRectFilled( + badgeMin, + badgeMax, + (accentAbgr & 0x00FFFFFFu) | 0x38000000u, + badgeH * 0.5f + ); + draw.AddRect(badgeMin, badgeMax, accentAbgr, badgeH * 0.5f); } } } @@ -298,44 +317,79 @@ internal sealed class LivePreviewPanel : IDisposable ImGui.Dummy(new Vector2(width, height)); } + // Mirrors the real status bar: a top rule, then pill-shaped slots, the last + // one right-aligned. The status colours ride along as slot dots so a theme + // still shows what it does to them. private static void DrawStatusBar(Theme theme) { - const float height = 20f; - const float iconSize = 8f; - const float iconGap = 6f; + const float height = 24f; + const float pillH = 18f; + const float padX = 6f; + const float gap = 6f; var draw = ImGui.GetWindowDrawList(); var origin = ImGui.GetCursorScreenPos(); var width = ImGui.GetContentRegionAvail().X; var max = new Vector2(origin.X + width, origin.Y + height); draw.AddRectFilled(origin, max, ColourUtil.RgbaToAbgr(theme.Colors.ChildBg)); + draw.AddLine( + origin, + new Vector2(max.X, origin.Y), + ColourUtil.RgbaToAbgr(theme.Colors.Border), + 1f + ); - ReadOnlySpan statusRgba = + var fill = ColourUtil.RgbaToAbgr(theme.Colors.SurfaceHover); + var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary); + var pillY = origin.Y + (height - pillH) * 0.5f; + + ReadOnlySpan slots = ["Say", "open", "3 tabs"]; + ReadOnlySpan dots = [ theme.Colors.StatusSuccess, - theme.Colors.StatusDanger, theme.Colors.StatusWarning, + theme.Colors.StatusDanger, ]; - var iconY = origin.Y + (height - iconSize) * 0.5f; - for (var i = 0; i < statusRgba.Length; i++) + var x = origin.X + padX; + for (var i = 0; i < slots.Length; i++) { - var iconX = origin.X + 6f + i * (iconSize + iconGap); - draw.AddRectFilled( - new Vector2(iconX, iconY), - new Vector2(iconX + iconSize, iconY + iconSize), - ColourUtil.RgbaToAbgr(statusRgba[i]), - 2f + var labelSize = ImGui.CalcTextSize(slots[i]); + var slotW = labelSize.X + padX * 2f + 10f; + var slotMin = new Vector2(x, pillY); + var slotMax = new Vector2(x + slotW, pillY + pillH); + + draw.AddRectFilled(slotMin, slotMax, fill, pillH * 0.5f); + draw.AddCircleFilled( + new Vector2(x + padX + 2f, pillY + pillH * 0.5f), + 2.5f, + ColourUtil.RgbaToAbgr(dots[i]), + 10 ); + draw.AddText( + new Vector2(x + padX + 10f, pillY + (pillH - labelSize.Y) * 0.5f), + textAbgr, + slots[i] + ); + + x += slotW + gap; } var label = "preview"; - var labelSize = ImGui.CalcTextSize(label); - var labelPos = new Vector2( - max.X - labelSize.X - 6f, - origin.Y + (height - labelSize.Y) * 0.5f + var versionSize = ImGui.CalcTextSize(label); + var versionW = versionSize.X + padX * 2f; + var versionMin = new Vector2(max.X - versionW - padX, pillY); + draw.AddRectFilled( + versionMin, + versionMin + new Vector2(versionW, pillH), + fill, + pillH * 0.5f + ); + draw.AddText( + new Vector2(versionMin.X + padX, pillY + (pillH - versionSize.Y) * 0.5f), + ColourUtil.RgbaToAbgr(theme.Colors.TextDim), + label ); - draw.AddText(labelPos, ColourUtil.RgbaToAbgr(theme.Colors.TextDim), label); ImGui.Dummy(new Vector2(width, height)); } From bfc61909cfc07423d2ddd23685add259f754ef3f Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 00:05:23 +0200 Subject: [PATCH 055/129] refactor(ui): scale the remaining layout constants What blocks A to E did not already touch: the honorific header height and its two offsets, the message list dummy widths, and the quick-button reserve in the input bar. The reserve is the one with visible consequences. At 150% the buttons grow with the font while a fixed 130px column does not, so they stopped fitting. The honorific offsets are centred rather than scaled. The 8f there was (30 - 14) / 2 for the old font, structurally the same case as the sidebar: a scaled constant keeps its mis-centering, a computed one does not. --- HellionChat/Ui/Components/HonorificHeader.cs | 18 ++++++++++++++---- HellionChat/Ui/Components/InputBar.cs | 5 ++++- HellionChat/Ui/Components/MessageList.cs | 16 +++++++++++++--- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/HellionChat/Ui/Components/HonorificHeader.cs b/HellionChat/Ui/Components/HonorificHeader.cs index d1e2ae3..5ab5139 100644 --- a/HellionChat/Ui/Components/HonorificHeader.cs +++ b/HellionChat/Ui/Components/HonorificHeader.cs @@ -13,7 +13,8 @@ namespace HellionChat.Ui.Components; // bracketed title only appears when there is actually a title to show. internal sealed class HonorificHeader { - public const float Height = 30f; + // Scaled: MainWindow reserves body height against this, so it follows. + public static float Height => StyleEngine.Metrics.HonorificHeight; // SelfTest observables — set on the real Draw path so a headless step can // assert the gate/colour/truncation outcome instead of re-implementing it. @@ -68,7 +69,11 @@ internal sealed class HonorificHeader using (_fonts.FontAwesome.Push()) { crownWidth = ImGui.CalcTextSize(crownGlyph).X; - dl.AddText(origin + new Vector2(0f, 8f), crownColor, crownGlyph); + dl.AddText( + origin + new Vector2(0f, StyleEngine.Metrics.CenterY(Height)), + crownColor, + crownGlyph + ); } // Gate the bracketed title through the 1.5.6 contract (toggle, IPC @@ -92,12 +97,17 @@ internal sealed class HonorificHeader // TruncateToFitWidth measures the *Regular* font, so this must run // OUTSIDE the FontAwesome.Push block above (crownWidth was measured // inside it, which is correct). - var maxTitleWidth = maxWidth - crownWidth - 6f - 8f; + var gap = StyleEngine.Metrics.HonorificBracketGap; + var maxTitleWidth = maxWidth - crownWidth - gap - StyleEngine.Metrics.HonorificInset; if (maxTitleWidth > 0f) { var rendered = StringUtil.TruncateToFitWidth($"«{current.Title}»", maxTitleWidth); LastRenderedTitle = rendered; - dl.AddText(origin + new Vector2(crownWidth + 6f, 8f), titleColor, rendered); + dl.AddText( + origin + new Vector2(crownWidth + gap, StyleEngine.Metrics.CenterY(Height)), + titleColor, + rendered + ); LastTitleRendered = true; } } diff --git a/HellionChat/Ui/Components/InputBar.cs b/HellionChat/Ui/Components/InputBar.cs index 47dfe8f..a281fd1 100644 --- a/HellionChat/Ui/Components/InputBar.cs +++ b/HellionChat/Ui/Components/InputBar.cs @@ -30,7 +30,10 @@ internal sealed class InputBar // they follow automatically. The pill's own metrics live in PillStyle now. public static float Height => StyleEngine.Metrics.InputBarHeight; private const int BufferCapacity = 500; - private const float QuickButtonsReserve = 130f; + + // Scaled: the buttons themselves grow with the font, so a fixed reserve + // stops fitting them at 150%. + private static float QuickButtonsReserve => StyleEngine.Metrics.InputQuickButtonsReserve; private readonly SymbolPicker _symbolPicker; private readonly FontManager _fonts; diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs index 6cf0ad6..67982eb 100644 --- a/HellionChat/Ui/Components/MessageList.cs +++ b/HellionChat/Ui/Components/MessageList.cs @@ -308,12 +308,17 @@ internal sealed class MessageList { // Scrolled into a gap: one full-height dummy keeps the scrollbar honest. var gap = CompensatedDummy(plan.LeadDummyHeight + plan.EndDummyHeight); - ImGui.Dummy(new Vector2(10f, gap)); + ImGui.Dummy(new Vector2(StyleEngine.Metrics.MessageDummyWidth, gap)); return; } if (plan.LeadDummyHeight > 0f) - ImGui.Dummy(new Vector2(10f, CompensatedDummy(plan.LeadDummyHeight))); + ImGui.Dummy( + new Vector2( + StyleEngine.Metrics.MessageDummyWidth, + CompensatedDummy(plan.LeadDummyHeight) + ) + ); for (var i = plan.FirstVisible; i <= plan.LastVisible; i++) { @@ -329,7 +334,12 @@ internal sealed class MessageList } if (plan.EndDummyHeight > 0f) - ImGui.Dummy(new Vector2(10f, CompensatedDummy(plan.EndDummyHeight))); + ImGui.Dummy( + new Vector2( + StyleEngine.Metrics.MessageDummyWidth, + CompensatedDummy(plan.EndDummyHeight) + ) + ); } // First-frame / post-invalidation fallback: draw + measure every row into the From 891f8ac0ecc40ee9a5f3a9a500d45c9b5ca32cdc Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 00:05:53 +0200 Subject: [PATCH 056/129] fix(style): make the active surface actually visible SurfaceActive was Lerp(Surface, Primary, 0.1f), picked when no production code drew the token. Against the real sidebar it is barely distinguishable from SurfaceBase, so the active row was identifiable only by its 2px accent bar -- which is not what "the active tab is unmistakable" was supposed to mean. 0.25 keeps it clearly a surface rather than a coloured block, and stays distinct from SurfaceHover, which is its own theme slot rather than a derivation. Worth a look across all themes during the smoke test: the lerp target is Primary, so themes with a very light primary will move further than the dark ones. --- HellionChat/Ui/StyleEngine/TokenResolver.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/HellionChat/Ui/StyleEngine/TokenResolver.cs b/HellionChat/Ui/StyleEngine/TokenResolver.cs index 0491e56..acfd7cb 100644 --- a/HellionChat/Ui/StyleEngine/TokenResolver.cs +++ b/HellionChat/Ui/StyleEngine/TokenResolver.cs @@ -109,7 +109,10 @@ internal sealed class TokenResolver [Token.SurfaceBase] = c => c.Surface, [Token.SurfaceRaised] = c => Lerp(c.Surface, White, 0.06f), [Token.SurfaceHover] = c => c.SurfaceHover, - [Token.SurfaceActive] = c => Lerp(c.Surface, c.Primary, 0.1f), + // 0.1 was chosen when nothing drew this token; against the real sidebar + // it is barely distinguishable from SurfaceBase, which left the active + // row identifiable only by its accent bar. + [Token.SurfaceActive] = c => Lerp(c.Surface, c.Primary, 0.25f), [Token.TextMuted] = c => c.TextMuted, [Token.TextFaint] = c => c.TextDim, }; From 929188e5ebb88151d909482da2b6e98b9cd28026 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 00:11:53 +0200 Subject: [PATCH 057/129] fix(ui): respect window opacity and keep badges off the icons Reviews of blocks C and D found five things a user would see immediately. Row fills ignored the window's own opacity. Theme surfaces are fully opaque, and GlobalStyleScope zeroes ChildBg below full opacity so WindowBg alone carries the coverage -- with the default of 0.85 that made the sidebar a solid block inside a translucent window. Idle rows now draw no fill at all, and the active and hover fills are scaled by the current window opacity. The unread badge landed on the tab icon at the default sidebar width of 44px. Right-aligning it needs roughly 70px for one digit and 90px for three, and the old placement also subtracted the popout column even when there was no popout button. It is only drawn where it clears the icon; below that a plain dot takes over, which is what the sidebar did before this cycle anyway. The same collision existed in the top-tab strip, worse: the badge sat in the trailing padding, which is 10px against a badge at least 14px wide, so it covered the label on every tab that had one. The badge is part of the tab width now, and vertically centred rather than top-aligned. The context menu's spacing guard read the pushed zero back out of GetStyle, so the max never did anything and X stayed at zero -- which is what HelpMarker's SameLine uses, so the "(?)" clung to its label. It sets both axes outright now. Section captions had all their padding above them and one pixel below, so with zero item spacing the next row started immediately under the text. Three smaller items: the tab icon was centred against the text font's line height although FontAwesome is a fixed-width handle that ignores Config.FontSizeV2; IconButton interpolated a label string per button per frame, now a PushID over a u8 literal; and the alpha scaling that had grown four copies now goes through ColourUtil.ApplyAlpha everywhere. --- HellionChat/Ui/Components/Sidebar.cs | 82 ++++++++++++++----- HellionChat/Ui/Components/TabContextMenu.cs | 10 +-- HellionChat/Ui/Components/TopTabBar.cs | 51 ++++++++---- HellionChat/Ui/StyleEngine/Metrics.cs | 2 - .../Ui/StyleEngine/Widgets/IconButton.cs | 7 +- .../Ui/StyleEngine/Widgets/LineDivider.cs | 4 +- HellionChat/Ui/StyleEngine/Widgets/Row.cs | 22 ++--- HellionChat/Ui/Windows/WidgetGalleryWindow.cs | 1 - 8 files changed, 121 insertions(+), 58 deletions(-) diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index c657aae..9faa784 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -32,8 +32,10 @@ internal sealed class Sidebar private static float PopOutHitWidth => Metrics.SidebarPopOutHitWidth; private static float GreetedHitWidth => Metrics.SidebarGreetedHitWidth; - // Counts rows that got the active surface this frame. At most one, but not - // exactly one: PickMainActiveTab returns null when every tab is popped out. + // Counts rows that got the active surface this frame. At most one, with two + // exceptions: zero when every tab is popped out (PickMainActiveTab returns + // null), and two in the frame a click lands on a row drawn after the + // previously active one -- that row was still active when it was painted. internal int LastRenderedActiveSurfaceCount { get; private set; } // B3-2 render observability: counts greeted glyphs actually drawn this frame. @@ -237,7 +239,9 @@ internal sealed class Sidebar // icon-only or min-drag mode it is skipped entirely. var greetedConfigured = tab.IsTempTab && Plugin.Config.AutoTellTabsShowGreetedToggle; var showGreeted = - greetedConfigured && expanded && avail > GreetedHitWidth + PopOutHitWidth + 4f; + greetedConfigured + && expanded + && avail > GreetedHitWidth + PopOutHitWidth + Metrics.SidebarHitSlack; // Only split off a separate pop-out hit area when there's room for // both buttons. Below that, the whole row stays as a single @@ -246,7 +250,7 @@ internal sealed class Sidebar // icon-only mode avail still clears the width threshold, which used to // paint the pop-out glyph over the tab icon. The row stays a single // selectable strip when collapsed; right-click pop-out is unaffected. - var hasPopOut = expanded && avail > PopOutHitWidth + 4f; + var hasPopOut = expanded && avail > PopOutHitWidth + Metrics.SidebarHitSlack; var tabHitWidth = hasPopOut ? avail - PopOutHitWidth : avail; if (showGreeted) { @@ -284,6 +288,14 @@ internal sealed class Sidebar LastRenderedActiveSurfaceCount++; var colors = _themes.Active.Colors; + + // Row fills follow the window's own opacity. Theme surfaces are fully + // opaque and the window is translucent by default (0.85 focused, 0.65 + // not), so unscaled fills would sit on top as solid blocks. + var opacity = ImGui.IsWindowFocused(ImGuiFocusedFlags.RootWindow) + ? Plugin.Config.WindowOpacity + : Plugin.Config.WindowOpacityInactive; + Row.Draw( origin, new Vector2(avail, RowHeight), @@ -291,11 +303,16 @@ internal sealed class Sidebar { IsActive = isActiveRow, HoverAmount = hoverAmount, - SurfaceAbgr = _palette.Abgr(Token.SurfaceBase, colors), - SurfaceHoverAbgr = _palette.Abgr(Token.SurfaceHover, colors), - SurfaceActiveAbgr = _palette.Abgr(Token.SurfaceActive, colors), + SurfaceHoverAbgr = ColourUtil.ApplyAlpha( + _palette.Abgr(Token.SurfaceHover, colors), + opacity + ), + SurfaceActiveAbgr = ColourUtil.ApplyAlpha( + _palette.Abgr(Token.SurfaceActive, colors), + opacity + ), AccentAbgr = _palette.Abgr(Token.AccentPrimary, colors), - BorderAbgr = _palette.Abgr(Token.Border, colors), + BorderAbgr = ColourUtil.ApplyAlpha(_palette.Abgr(Token.Border, colors), opacity), } ); @@ -326,9 +343,18 @@ internal sealed class Sidebar float iconRight; using (_fonts.FontAwesome.Push()) { + // Measured inside the push: FontAwesome is a fixed-width icon handle + // that does not follow Config.FontSizeV2, so the text font's line + // height would misplace the glyph at any other body size. var iconStr = icon.ToIconString(); - dl.AddText(origin + new Vector2(iconInset + contentX, contentY), iconColor, iconStr); - iconRight = iconInset + contentX + ImGui.CalcTextSize(iconStr).X; + var iconSize = ImGui.CalcTextSize(iconStr); + dl.AddText( + origin + + new Vector2(iconInset + contentX, MetricsMath.CenterY(RowHeight, iconSize.Y)), + iconColor, + iconStr + ); + iconRight = iconInset + contentX + iconSize.X; } if (expanded) @@ -342,16 +368,34 @@ internal sealed class Sidebar { var unread = (int)Math.Min(tab.Unread, int.MaxValue); var badgeSize = Badge.CalcSize(unread); - var badgeX = expanded - ? avail - PopOutHitWidth - badgeSize.X - 4f * scale - : iconRight - badgeSize.X * 0.5f; + var accentAbgr = _palette.Abgr(Token.AccentEmber, colors); + var slack = Metrics.SidebarHitSlack; + var reserved = hasPopOut ? PopOutHitWidth : 0f; + var badgeX = avail - reserved - badgeSize.X - slack; + + // The count only fits where it can sit clear of the icon. At the + // default sidebar width of 44 it cannot, so a plain dot takes over + // rather than the badge landing on the glyph. + if (badgeX >= iconRight + slack) + { + Badge.Draw( + origin + new Vector2(badgeX, MetricsMath.CenterY(RowHeight, badgeSize.Y)), + unread, + accentAbgr, + textAbgr + ); + } + else + { + var r = Metrics.SidebarUnreadRadius; + dl.AddCircleFilled( + origin + new Vector2(iconRight - r * 0.5f, RowHeight * 0.5f - r), + r, + accentAbgr, + 12 + ); + } - Badge.Draw( - origin + new Vector2(badgeX, MetricsMath.CenterY(RowHeight, badgeSize.Y)), - unread, - _palette.Abgr(Token.AccentEmber, colors), - textAbgr - ); LastRenderedUnreadDotCount++; } diff --git a/HellionChat/Ui/Components/TabContextMenu.cs b/HellionChat/Ui/Components/TabContextMenu.cs index 27e1556..24af76b 100644 --- a/HellionChat/Ui/Components/TabContextMenu.cs +++ b/HellionChat/Ui/Components/TabContextMenu.cs @@ -41,14 +41,12 @@ internal static class TabContextMenu } // The sidebar pushes ItemSpacing to zero so its rows sit flush, and style - // vars are a global stack the popup inherits. Without restoring a normal - // spacing here the menu entries would touch each other. + // vars are a global stack the popup inherits. Reading GetStyle() here + // would read that zero back, so the popup sets its own spacing outright + // -- including X, which HelpMarker's SameLine depends on. using var spacing = ImRaii.PushStyle( ImGuiStyleVar.ItemSpacing, - ImGui.GetStyle().ItemSpacing with - { - Y = MathF.Max(ImGui.GetStyle().ItemSpacing.Y, 4f * ImGuiHelpers.GlobalScale), - } + new System.Numerics.Vector2(8f, 4f) * Ui.StyleEngine.Metrics.Scale ); // Rename: focus the field the first frame the popup appears. diff --git a/HellionChat/Ui/Components/TopTabBar.cs b/HellionChat/Ui/Components/TopTabBar.cs index 5a36776..2411aa8 100644 --- a/HellionChat/Ui/Components/TopTabBar.cs +++ b/HellionChat/Ui/Components/TopTabBar.cs @@ -16,8 +16,13 @@ internal sealed class TopTabBar private readonly ThemeRegistry _themes; private readonly WidgetPalette _palette; - // Render observability, mirroring the sidebar counters: at most one tab - // carries the active underline. Zero is valid -- every tab can be popped out. + // Slightly smaller than the sidebar default: the strip is horizontal, so + // every pixel of badge width costs a tab. + private static readonly BadgeStyle TabBadge = new() { Height = 12f, PaddingX = 3f }; + + // Render observability. At most one underline per frame, except in the frame + // a click lands: a tab drawn before the clicked one was still the active tab + // when it was painted, so that frame legitimately shows two. internal int LastRenderedUnderlineCount { get; private set; } public TopTabBar(Windows.ChannelPopoutPool pool, ThemeRegistry themes, TokenResolver resolver) @@ -58,8 +63,22 @@ internal sealed class TopTabBar var selected = ReferenceEquals(tab, activeTab); var origin = ImGui.GetCursorScreenPos(); - var width = ImGui.CalcTextSize(tab.Name).X + padX * 2f; - var size = new Vector2(width, height); + + // The badge has to be part of the width, not painted over it. Padding + // alone is 10px and the badge is at least 14 wide, so placing it in + // the trailing padding covered the label on every tab that had one. + var showUnread = + !ReferenceEquals(tab, activeTab) + && tab.UnreadMode != UnreadMode.None + && tab.Unread > 0; + var unread = showUnread ? (int)Math.Min(tab.Unread, int.MaxValue) : 0; + var badgeSize = showUnread ? Badge.CalcSize(unread, TabBadge) : Vector2.Zero; + + var width = + ImGui.CalcTextSize(tab.Name).X + + padX * 2f + + (showUnread ? badgeSize.X + Metrics.TopTabUnreadInset : 0f); + var size = WidgetGeometry.IconButton(width, height); // The ### keeps the ImGui id stable across a rename; without it the // context menu loses its binding the moment the label changes. Built @@ -96,24 +115,20 @@ internal sealed class TopTabBar if (selected) LastRenderedUnderlineCount++; - // 1.5.6-parity unread marker. Gated on the POST-click selection so - // clicking a tab clears it the same frame, like the sidebar. - if ( - !ReferenceEquals(tab, activeTab) - && tab.UnreadMode != UnreadMode.None - && tab.Unread > 0 - ) - { - var unread = (int)Math.Min(tab.Unread, int.MaxValue); - var badgeSize = Badge.CalcSize(unread); - var inset = Metrics.TopTabUnreadInset; + // Clicking a tab clears its marker in the same frame, like the + // sidebar: showUnread was resolved before the click was handled, so + // re-check against the post-click selection. + if (showUnread && !ReferenceEquals(tab, activeTab)) Badge.Draw( - new Vector2(origin.X + size.X - badgeSize.X - inset, origin.Y + inset), + new Vector2( + origin.X + size.X - badgeSize.X - Metrics.TopTabUnreadInset, + origin.Y + MetricsMath.CenterY(size.Y, badgeSize.Y) + ), unread, _palette.Abgr(Token.AccentEmber, colors), - textAbgr + textAbgr, + TabBadge ); - } TabContextMenu.Draw(tab, $"toptab_ctx_{tab.Identifier}", _pool); } diff --git a/HellionChat/Ui/StyleEngine/Metrics.cs b/HellionChat/Ui/StyleEngine/Metrics.cs index 5a1ef30..af4f4e5 100644 --- a/HellionChat/Ui/StyleEngine/Metrics.cs +++ b/HellionChat/Ui/StyleEngine/Metrics.cs @@ -37,7 +37,6 @@ internal static class Metrics internal const float HonorificBracketGapRaw = 6f; // --- Top tab bar --- - internal const float TopTabUnreadRadiusRaw = 3.5f; internal const float TopTabUnreadInsetRaw = 4f; internal const float TopTabPaddingXRaw = 10f; internal const float TopTabUnderlineRaw = 2f; @@ -96,7 +95,6 @@ internal static class Metrics internal static float HonorificInset => MetricsMath.Scale(HonorificInsetRaw, Scale); internal static float HonorificBracketGap => MetricsMath.Scale(HonorificBracketGapRaw, Scale); - internal static float TopTabUnreadRadius => MetricsMath.Scale(TopTabUnreadRadiusRaw, Scale); internal static float TopTabUnreadInset => MetricsMath.Scale(TopTabUnreadInsetRaw, Scale); internal static float TopTabPaddingX => MetricsMath.Scale(TopTabPaddingXRaw, Scale); internal static float TopTabUnderline => MetricsMath.Scale(TopTabUnderlineRaw, Scale); diff --git a/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs b/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs index 2bd23b8..5139dca 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/IconButton.cs @@ -51,9 +51,14 @@ internal static class IconButton var clamped = WidgetGeometry.IconButton(size.X, size.Y); var origin = ImGui.GetCursorScreenPos(); - ImGui.InvisibleButton($"##hellion-iconbtn-{id}", clamped); + // PushID over an interpolated label: the string version allocated once + // per button per frame, which is the pattern the sidebar just removed + // from its hover keys. + ImGui.PushID((int)id); + ImGui.InvisibleButton("##b"u8, clamped); var hovered = ImGui.IsItemHovered(); var clicked = ImGui.IsItemClicked(); + ImGui.PopID(); var amount = HoverState.Query(id, hovered); var dl = ImGui.GetWindowDrawList(); diff --git a/HellionChat/Ui/StyleEngine/Widgets/LineDivider.cs b/HellionChat/Ui/StyleEngine/Widgets/LineDivider.cs index 6302de2..7f6dab1 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/LineDivider.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/LineDivider.cs @@ -23,7 +23,9 @@ internal static class LineDivider { var style = styleOverride ?? new LineDividerStyle(); var scale = Metrics.Scale; - var labelHeight = label is null ? 0f : ImGui.GetTextLineHeight(); + // Caption height plus its own bottom padding: without the second padY + // the next row starts one pixel under the text. + var labelHeight = label is null ? 0f : ImGui.GetTextLineHeight() + style.PadY * scale; return WidgetGeometry.LineDivider( ImGui.GetContentRegionAvail().X, style.Thickness * scale, diff --git a/HellionChat/Ui/StyleEngine/Widgets/Row.cs b/HellionChat/Ui/StyleEngine/Widgets/Row.cs index 2762d7a..42cebde 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/Row.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/Row.cs @@ -1,5 +1,6 @@ using System.Numerics; using Dalamud.Bindings.ImGui; +using HellionChat.Util; namespace HellionChat.Ui.StyleEngine.Widgets; @@ -12,7 +13,6 @@ internal readonly record struct RowVisualState public bool IsActive { get; init; } public float HoverAmount { get; init; } - public uint SurfaceAbgr { get; init; } public uint SurfaceHoverAbgr { get; init; } public uint SurfaceActiveAbgr { get; init; } public uint AccentAbgr { get; init; } @@ -46,11 +46,19 @@ internal static class Row var dl = ImGui.GetWindowDrawList(); var max = origin + size; - var baseAbgr = state.IsActive ? state.SurfaceActiveAbgr : state.SurfaceAbgr; - dl.AddRectFilled(origin, max, baseAbgr); + // Idle rows draw no fill at all. GlobalStyleScope zeroes ChildBg below + // full window opacity so WindowBg alone carries the coverage, and the + // default is 0.85 -- an opaque fill per row would make the sidebar a + // solid block inside a translucent window. + if (state.IsActive) + dl.AddRectFilled(origin, max, state.SurfaceActiveAbgr); if (state.HoverAmount > 0f) - dl.AddRectFilled(origin, max, ScaleAlpha(state.SurfaceHoverAbgr, state.HoverAmount)); + dl.AddRectFilled( + origin, + max, + ColourUtil.ApplyAlpha(state.SurfaceHoverAbgr, state.HoverAmount) + ); if (state.IsActive && style.AccentBarWidth > 0f) { @@ -66,10 +74,4 @@ internal static class Row Metrics.Scale ); } - - private static uint ScaleAlpha(uint abgr, float factor) - { - var a = (uint)Math.Clamp(MathF.Round(((abgr >> 24) & 0xFF) * factor), 0f, 255f); - return (abgr & 0x00FFFFFFu) | (a << 24); - } } diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index 3663d4e..075c273 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -78,7 +78,6 @@ internal sealed class WidgetGalleryWindow : Window { IsActive = _rowActive && i == 1, HoverAmount = HoverState.Query(id, hovered), - SurfaceAbgr = _palette.Abgr(Token.SurfaceBase, c), SurfaceHoverAbgr = _palette.Abgr(Token.SurfaceHover, c), SurfaceActiveAbgr = _palette.Abgr(Token.SurfaceActive, c), AccentAbgr = _palette.Abgr(Token.AccentPrimary, c), From d79188c7b0fef6f7339f9f2b4ce99a6b2eaba4e0 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 00:19:39 +0200 Subject: [PATCH 058/129] fix(statusbar): size pills from the text and give every slot a fit check Review of block E found the pill height locked to its design value. WidgetGeometry.Pill discarded the measured text height, so a pill was always 22px times display scale -- and Config.FontSizeV2 does not feed into display scale. At 18pt the 24px line no longer fit its 22px pill, at 20pt the text left the reserved strip entirely. Both sizes are in the plugin's own font list. Height is a floor now, and the text plus vertical padding wins when it is taller. At the default 12.75pt the floor still applies, so nothing moves. Only the right-hand version slot checked whether it had room. At 150% scaling with the window at its 480px minimum -- which stays reachable, because the size constraint is not scaled -- the counts and tells pills simply ran off the edge. Every slot checks now. Pill.Draw returns the size it drew, so the status bar no longer measures each slot twice. That also removes the risk of the drawn and returned widths drifting apart if a style override ever reaches only one of the two calls, and it cuts the lock glyph from three font pushes per frame to one. A font push is not free: it allocates a lock object and queues a deferred dispose. Smaller items: the version string is built once instead of per frame, the glyph cache from IconButton is now in Pill as well, and five metrics constants plus two usings that lost their consumers in E1 and E2 are gone. --- HellionChat/Ui/Components/StatusBar.cs | 55 ++++++++++++++-------- HellionChat/Ui/StyleEngine/Metrics.cs | 11 ----- HellionChat/Ui/StyleEngine/Widgets/Pill.cs | 34 +++++++++++-- HellionChat/Util/WidgetGeometry.cs | 12 ++++- 4 files changed, 76 insertions(+), 36 deletions(-) diff --git a/HellionChat/Ui/Components/StatusBar.cs b/HellionChat/Ui/Components/StatusBar.cs index 4c561a8..b0eaeb2 100644 --- a/HellionChat/Ui/Components/StatusBar.cs +++ b/HellionChat/Ui/Components/StatusBar.cs @@ -3,8 +3,6 @@ using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.ManagedFontAtlas; -using Dalamud.Interface.Utility; -using Dalamud.Interface.Utility.Raii; using HellionChat.Code; using HellionChat.Resources; using HellionChat.Themes; @@ -27,7 +25,7 @@ internal sealed class StatusBar // body height against this property, so the two drifting apart is the whole // failure mode. Slots are pills now, and a pill is taller than a text line. public static float Height => - StyleEngine.Widgets.Pill.CalcSize("X", withDot: false).Y + StyleEngine.Widgets.Pill.CalcSize(string.Empty, withDot: false).Y + StyleEngine.Metrics.StatusTopSpacer * 2f; private const long UpdateIntervalMs = 1000; @@ -38,6 +36,10 @@ internal sealed class StatusBar new StyleEngine.TokenResolver() ); + // Never changes at runtime; it used to be rebuilt on every frame. + private static readonly string VersionText = + $"v{Plugin.Interface.Manifest.AssemblyVersion} · Hellion"; + private long _lastUpdateMs = -UpdateIntervalMs; private string _cachedCountsText = string.Empty; private string _cachedTellsText = string.Empty; @@ -154,34 +156,49 @@ internal sealed class StatusBar ? HellionStrings.StatusBar_Privacy_Enabled : HellionStrings.StatusBar_Privacy_Open; + // Every slot checks its own room. Only the right-hand one used to, so at + // 150% scaling with the window at its 480px minimum the counts and tells + // pills ran off the edge instead of dropping out. + var regionRight = origin.X + ImGui.GetContentRegionAvail().X; var x = origin.X; - x += DrawSlot(new Vector2(x, top), channelName, pillFill, pillText, dotAbgr) + gap; - x += - DrawSlot( - new Vector2(x, top), - privacyLabel, - pillFill, - pillText, - null, - (FontAwesomeIcon.Lock, _fonts.FontAwesome) - ) + gap; - x += DrawSlot(new Vector2(x, top), _cachedCountsText, pillFill, mutedText, null) + gap; - if (!string.IsNullOrEmpty(_cachedTellsText)) + bool Fits(string label, bool withDot, float iconWidth = 0f) => + x + StyleEngine.Widgets.Pill.CalcSize(label, withDot, iconWidth: iconWidth).X + <= regionRight; + + if (Fits(channelName, withDot: true)) + x += DrawSlot(new Vector2(x, top), channelName, pillFill, pillText, dotAbgr) + gap; + var lockWidth = StyleEngine.Widgets.Pill.MeasureIcon( + FontAwesomeIcon.Lock, + _fonts.FontAwesome + ); + if (Fits(privacyLabel, withDot: false, lockWidth)) + x += + DrawSlot( + new Vector2(x, top), + privacyLabel, + pillFill, + pillText, + null, + (FontAwesomeIcon.Lock, _fonts.FontAwesome) + ) + gap; + + if (Fits(_cachedCountsText, withDot: false)) + x += DrawSlot(new Vector2(x, top), _cachedCountsText, pillFill, mutedText, null) + gap; + + if (!string.IsNullOrEmpty(_cachedTellsText) && Fits(_cachedTellsText, withDot: false)) x += DrawSlot(new Vector2(x, top), _cachedTellsText, pillFill, pillText, null) + gap; // Slot 5: version + brand, right-aligned. Dropped when the left-hand run // would actually collide with it -- the old check compared against a flat // 200px and never measured the left slots at all. - var versionText = $"v{Plugin.Interface.Manifest.AssemblyVersion} · Hellion"; - var versionWidth = StyleEngine.Widgets.Pill.CalcSize(versionText, withDot: false).X; - var regionRight = origin.X + ImGui.GetContentRegionAvail().X; + var versionWidth = StyleEngine.Widgets.Pill.CalcSize(VersionText, withDot: false).X; var leftRunEnd = x - gap; if (regionRight - versionWidth - gap > leftRunEnd) DrawSlot( new Vector2(regionRight - versionWidth, top), - versionText, + VersionText, pillFill, mutedText, null diff --git a/HellionChat/Ui/StyleEngine/Metrics.cs b/HellionChat/Ui/StyleEngine/Metrics.cs index af4f4e5..37426d3 100644 --- a/HellionChat/Ui/StyleEngine/Metrics.cs +++ b/HellionChat/Ui/StyleEngine/Metrics.cs @@ -26,9 +26,6 @@ internal static class Metrics // --- Input bar --- internal const float InputBarHeightRaw = 32f; - internal const float InputPillHeightRaw = 22f; - internal const float InputPillPaddingXRaw = 8f; - internal const float InputPillRoundingRaw = 6f; internal const float InputQuickButtonsReserveRaw = 130f; // --- Honorific header --- @@ -44,8 +41,6 @@ internal static class Metrics // --- Status bar --- internal const float StatusBorderThicknessRaw = 1f; internal const float StatusTopSpacerRaw = 2f; - internal const float StatusMinOtherSlotsWidthRaw = 200f; - internal const float StatusDotRadiusRaw = 4f; // --- Message list --- internal const float MessageDummyWidthRaw = 10f; @@ -85,9 +80,6 @@ internal static class Metrics internal static float SidebarGlyphInset => MetricsMath.Scale(SidebarGlyphInsetRaw, Scale); internal static float InputBarHeight => MetricsMath.Scale(InputBarHeightRaw, Scale); - internal static float InputPillHeight => MetricsMath.Scale(InputPillHeightRaw, Scale); - internal static float InputPillPaddingX => MetricsMath.Scale(InputPillPaddingXRaw, Scale); - internal static float InputPillRounding => MetricsMath.Scale(InputPillRoundingRaw, Scale); internal static float InputQuickButtonsReserve => MetricsMath.Scale(InputQuickButtonsReserveRaw, Scale); @@ -106,9 +98,6 @@ internal static class Metrics internal static float StatusBorderThickness => MetricsMath.Scale(StatusBorderThicknessRaw, Scale); internal static float StatusTopSpacer => MathF.Round(StatusTopSpacerRaw * Scale); - internal static float StatusMinOtherSlotsWidth => - MetricsMath.Scale(StatusMinOtherSlotsWidthRaw, Scale); - internal static float StatusDotRadius => MetricsMath.Scale(StatusDotRadiusRaw, Scale); internal static float MessageDummyWidth => MetricsMath.Scale(MessageDummyWidthRaw, Scale); diff --git a/HellionChat/Ui/StyleEngine/Widgets/Pill.cs b/HellionChat/Ui/StyleEngine/Widgets/Pill.cs index f7cd511..772815a 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/Pill.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/Pill.cs @@ -12,6 +12,10 @@ internal readonly record struct PillStyle public float Height { get; init; } = 22f; public float PaddingX { get; init; } = 8f; + + // Only takes over once the text is taller than Height allows; at the default + // 12.75pt (17px line in a 22px pill) the floor still wins. + public float PaddingY { get; init; } = 2f; public float Rounding { get; init; } = 6f; public float DotRadius { get; init; } = 4f; public float DotGap { get; init; } = 6f; @@ -38,20 +42,39 @@ internal static class Pill return WidgetGeometry.Pill( ImGui.CalcTextSize(label), style.PaddingX * scale, + style.PaddingY * scale, style.Height * scale, lead ); } - // Measures a glyph in the icon font so a caller can feed iconWidth above - // without pushing the font twice. + // ToIconString allocates a fresh string on every call and holds no cache. + private static readonly Dictionary GlyphCache = []; + + private static string Glyph(FontAwesomeIcon icon) + { + if (GlyphCache.TryGetValue(icon, out var s)) + return s; + + s = icon.ToIconString(); + GlyphCache[icon] = s; + return s; + } + + // Measures a glyph in the icon font. A font push is not free -- it allocates + // a lock object and queues a deferred dispose -- so callers should let Draw + // return the size rather than measuring alongside it. internal static float MeasureIcon(FontAwesomeIcon icon, IFontHandle font) { using (font.Push()) - return ImGui.CalcTextSize(icon.ToIconString()).X; + return ImGui.CalcTextSize(Glyph(icon)).X; } - internal static void Draw( + // Returns the drawn size so a caller laying pills out in a row does not have + // to call CalcSize again -- doing so would repeat the text and icon + // measurement, and the two could drift apart if a style override is passed + // to only one of them. + internal static Vector2 Draw( Vector2 origin, string label, uint fillAbgr, @@ -87,7 +110,7 @@ internal static class Pill { using (glyph.Font.Push()) { - var text = glyph.Icon.ToIconString(); + var text = Glyph(glyph.Icon); var h = ImGui.CalcTextSize(text).Y; dl.AddText( new Vector2(textX, origin.Y + MetricsMath.CenterY(size.Y, h)), @@ -102,5 +125,6 @@ internal static class Pill // which display scaling does not feed into. var textY = origin.Y + MetricsMath.CenterY(size.Y, ImGui.GetTextLineHeight()); dl.AddText(new Vector2(textX, textY), textAbgr, label); + return size; } } diff --git a/HellionChat/Util/WidgetGeometry.cs b/HellionChat/Util/WidgetGeometry.cs index 7032714..1c0ec02 100644 --- a/HellionChat/Util/WidgetGeometry.cs +++ b/HellionChat/Util/WidgetGeometry.cs @@ -10,9 +10,19 @@ internal static class WidgetGeometry { private const float MinExtent = 1f; - internal static Vector2 Pill(Vector2 textSize, float paddingX, float height, float leadWidth) + // minHeight is a floor, not the answer: a pill whose height ignored the text + // would clip it as soon as the user picks a larger body font, which does not + // feed into display scaling. + internal static Vector2 Pill( + Vector2 textSize, + float paddingX, + float paddingY, + float minHeight, + float leadWidth + ) { var width = textSize.X + paddingX * 2f + leadWidth; + var height = MathF.Max(minHeight, textSize.Y + paddingY * 2f); return Clamp(new Vector2(width, height)); } From ca60c851cd0a6521ece0e5b7892f87f6224ea091 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 00:20:31 +0200 Subject: [PATCH 059/129] fix(settings): read the preview colours from the same tokens as the chrome The preview still picked its colours by hand: SurfaceHover for the active row where the sidebar uses SurfaceActive, and a plain surface for the pills where the status bar uses SurfaceRaised. Since SurfaceActive was just raised from 0.1 to 0.25, the preview showed a noticeably different active row than the one next to it. It resolves through TokenResolver now. Copying the lerp formulas is how it drifted out of sync to begin with. --- .../Ui/Components/Settings/LivePreviewPanel.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs b/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs index 4621996..3bf96c1 100644 --- a/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs +++ b/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs @@ -11,6 +11,13 @@ namespace HellionChat.Ui.Components.Settings; internal sealed class LivePreviewPanel : IDisposable { + // The preview reads the same tokens the real chrome does. Copying their lerp + // formulas here is how it drifted out of sync in the first place. + private static readonly StyleEngine.TokenResolver Tokens = new(); + + private static uint Abgr(StyleEngine.Token token, ThemeColors colors) => + ColourUtil.RgbaToAbgr(Tokens.Resolve(token, colors)); + // Static counter for S5 reload-stress verification: after 10 reloads the // counter must read 0 (plugin disabled) or 1 (plugin enabled). Anything // higher signals a Dispose skip and a subscriber leak against ThemeRegistry. @@ -177,6 +184,8 @@ internal sealed class LivePreviewPanel : IDisposable var rowHeight = MiddleBandHeight / 3f; var surface = ColourUtil.RgbaToAbgr(theme.Colors.Surface); var surfaceHover = ColourUtil.RgbaToAbgr(theme.Colors.SurfaceHover); + + var surfaceActive = Abgr(StyleEngine.Token.SurfaceActive, theme.Colors); var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary); var primaryAbgr = ColourUtil.RgbaToAbgr(theme.Colors.Primary); var accentAbgr = ColourUtil.RgbaToAbgr(theme.Colors.Accent); @@ -193,7 +202,7 @@ internal sealed class LivePreviewPanel : IDisposable var rowMax = new Vector2(origin.X + SidebarWidth, rowMin.Y + rowHeight); var isActive = i == 0; - draw.AddRectFilled(rowMin, rowMax, isActive ? surfaceHover : surface); + draw.AddRectFilled(rowMin, rowMax, isActive ? surfaceActive : surface); if (isActive) draw.AddRectFilled(rowMin, new Vector2(rowMin.X + 2f, rowMax.Y), primaryAbgr); @@ -339,7 +348,7 @@ internal sealed class LivePreviewPanel : IDisposable 1f ); - var fill = ColourUtil.RgbaToAbgr(theme.Colors.SurfaceHover); + var fill = Abgr(StyleEngine.Token.SurfaceRaised, theme.Colors); var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary); var pillY = origin.Y + (height - pillH) * 0.5f; @@ -387,7 +396,7 @@ internal sealed class LivePreviewPanel : IDisposable ); draw.AddText( new Vector2(versionMin.X + padX, pillY + (pillH - versionSize.Y) * 0.5f), - ColourUtil.RgbaToAbgr(theme.Colors.TextDim), + ColourUtil.RgbaToAbgr(theme.Colors.TextMuted), label ); From 30c495849d71804e7394c20576bb01b2497f0ca8 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 09:23:12 +0200 Subject: [PATCH 060/129] fix(ui): stop the context menu crashing and give the quick buttons their tooltips back Two things Flo hit on the first run. The context menu threw on EndPopup. The spacing guard added in 929188e was a `using var`, which disposes at the end of the method -- after EndPopup. ImGui asserts when a popup closes with a style var still on its stack. The body moved into a scoped block so the pop happens inside the popup. The quick-button tooltips were empty boxes. SetTooltip ran inside the FontAwesome push, and that atlas has no ASCII glyphs, so the text had nothing to render with. ImRaii.DefaultFont did not save it. The hovered label is collected now and drawn after the font is popped. Same trap as the unread badge in C5, different place. Those three tooltip strings are still hard-coded English while the rest of the UI is localised. Pre-existing, and adding resources means touching 24 language files, so it is noted rather than fixed here. --- HellionChat/Ui/Components/InputBar.cs | 28 +++++++++------------ HellionChat/Ui/Components/TabContextMenu.cs | 23 ++++++++++++----- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/HellionChat/Ui/Components/InputBar.cs b/HellionChat/Ui/Components/InputBar.cs index a281fd1..ab9854d 100644 --- a/HellionChat/Ui/Components/InputBar.cs +++ b/HellionChat/Ui/Components/InputBar.cs @@ -511,15 +511,17 @@ internal sealed class InputBar private void DrawQuickButtons() { + // Tooltip text is collected here and drawn after the icon font is popped. + // Inside the push it rendered against the FontAwesome atlas, which has no + // ASCII glyphs, so every tooltip came out as an empty box. + string? tooltip = null; + using (_fonts.FontAwesome.Push()) { if (ImGui.Button(FontAwesomeIcon.SmileBeam.ToIconString())) _symbolPicker.OpenPopup(); if (ImGui.IsItemHovered()) - { - using (ImRaii.DefaultFont()) - ImGui.SetTooltip("Insert symbol"); - } + tooltip = "Insert symbol"; if (_themeQuickPicker is not null) { @@ -527,10 +529,7 @@ internal sealed class InputBar if (ImGui.Button(FontAwesomeIcon.Palette.ToIconString())) _themeQuickPicker.OpenPopup(); if (ImGui.IsItemHovered()) - { - using (ImRaii.DefaultFont()) - ImGui.SetTooltip(HellionStrings.Settings_QuickPicker_Tooltip); - } + tooltip = HellionStrings.Settings_QuickPicker_Tooltip; } ImGui.SameLine(); @@ -539,10 +538,7 @@ internal sealed class InputBar _onOpenSettings(); } if (ImGui.IsItemHovered()) - { - using (ImRaii.DefaultFont()) - ImGui.SetTooltip("Settings"); - } + tooltip = "Settings"; // Hides the window (1.5.6 UserHide). One-way — Enter brings it back. // Main window only (pop-outs have their own close); last in the row. @@ -552,12 +548,12 @@ internal sealed class InputBar if (ImGui.Button(FontAwesomeIcon.EyeSlash.ToIconString())) _onHideWindow(); if (ImGui.IsItemHovered()) - { - using (ImRaii.DefaultFont()) - ImGui.SetTooltip("Hide chat (Enter to bring back)"); - } + tooltip = "Hide chat (Enter to bring back)"; } } + + if (tooltip is not null) + ImGui.SetTooltip(tooltip); } // Test-only hook; do not call from production code. diff --git a/HellionChat/Ui/Components/TabContextMenu.cs b/HellionChat/Ui/Components/TabContextMenu.cs index 24af76b..f11b639 100644 --- a/HellionChat/Ui/Components/TabContextMenu.cs +++ b/HellionChat/Ui/Components/TabContextMenu.cs @@ -44,11 +44,24 @@ internal static class TabContextMenu // vars are a global stack the popup inherits. Reading GetStyle() here // would read that zero back, so the popup sets its own spacing outright // -- including X, which HelpMarker's SameLine depends on. - using var spacing = ImRaii.PushStyle( - ImGuiStyleVar.ItemSpacing, - new System.Numerics.Vector2(8f, 4f) * Ui.StyleEngine.Metrics.Scale - ); + // + // Scoped block, not a `using var`: that would pop after EndPopup, and + // ImGui asserts when a popup closes with a style var still on the stack. + using ( + ImRaii.PushStyle( + ImGuiStyleVar.ItemSpacing, + new System.Numerics.Vector2(8f, 4f) * Ui.StyleEngine.Metrics.Scale + ) + ) + { + DrawBody(tab, pool); + } + ImGui.EndPopup(); + } + + private static void DrawBody(Tab tab, Windows.ChannelPopoutPool pool) + { // Rename: focus the field the first frame the popup appears. if (ImGui.IsWindowAppearing()) ImGui.SetKeyboardFocusHere(); @@ -84,8 +97,6 @@ internal static class TabContextMenu if (ImGui.MenuItem("Pop Out")) pool.TryOpen(tab); - - ImGui.EndPopup(); } // The flush depends on Draw running once more for this tab. If it never does — From 2686a8f74b0de9b049fec815e6b058df7265cac5 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 10:09:23 +0200 Subject: [PATCH 061/129] feat(i18n): localise the quick-button tooltips The three quick-button tooltips were hard-coded English while the rest of the UI goes through HellionStrings. All 25 resource files now carry them, machine translated on Flo's go-ahead. Verified: every file still parses as XML, and the base file stays English so a missing language falls back to what was there before. --- HellionChat/Resources/HellionStrings.Designer.cs | 7 +++++++ HellionChat/Resources/HellionStrings.ca.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.cs.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.da.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.de.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.el.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.es.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.fi.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.fr.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.hu.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.it.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.ja.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.ko.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.nb.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.nl.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.pl.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.pt-BR.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.pt-PT.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.ro.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.ru.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.sv.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.tr.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.uk.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.zh-Hans.resx | 9 +++++++++ HellionChat/Resources/HellionStrings.zh-Hant.resx | 9 +++++++++ HellionChat/Ui/Components/InputBar.cs | 12 ++++++------ 27 files changed, 238 insertions(+), 6 deletions(-) diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index 3ffdf0d..a99b701 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -413,6 +413,13 @@ internal class HellionStrings // Hellion Chat — v1.5.4 header quick-picker + reduce-motion toggle internal static string Settings_QuickPicker_Tooltip => Get(nameof(Settings_QuickPicker_Tooltip)); + + internal static string InputBar_InsertSymbol_Tooltip => + Get(nameof(InputBar_InsertSymbol_Tooltip)); + + internal static string InputBar_Settings_Tooltip => Get(nameof(InputBar_Settings_Tooltip)); + + internal static string InputBar_HideChat_Tooltip => Get(nameof(InputBar_HideChat_Tooltip)); internal static string Settings_QuickPicker_Themes_Header => Get(nameof(Settings_QuickPicker_Themes_Header)); internal static string Settings_QuickPicker_Tabs_Header => Get(nameof(Settings_QuickPicker_Tabs_Header)); internal static string Settings_ThemeAndLayout_ReduceMotion_Name => Get(nameof(Settings_ThemeAndLayout_ReduceMotion_Name)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index a09c7de..97190aa 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1146,4 +1146,13 @@ Aquest missatge conté símbols exclusius del connector que altres jugadors poden veure com a quadres buits. Prem Retorn de nou per enviar-lo igualment. + + Insereix un símbol + + + Configuració + + + Amaga el xat (Retorn per recuperar-lo) + diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index 662019c..49b2d85 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1145,4 +1145,13 @@ Tato zpráva obsahuje symboly pouze pro plugin, které ostatní hráči mohou vidět jako prázdné čtverce. Stiskni Enter znovu pro odeslání. + + Vložit symbol + + + Nastavení + + + Skrýt chat (Enter jej vrátí) + diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index 77d4cdd..a480fc4 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1145,4 +1145,13 @@ Denne besked indeholder plugin-kun symboler, som andre spillere muligvis ser som tomme bokse. Tryk Enter igen for at sende alligevel. + + Indsæt symbol + + + Indstillinger + + + Skjul chat (Enter henter den tilbage) + diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index 33ae151..d176caf 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1140,4 +1140,13 @@ Diese Nachricht enthält plugin-exklusive Symbole, die andere Spieler als leere Kästchen sehen könnten. Drücke Enter erneut, um sie trotzdem zu senden. + + Symbol einfügen + + + Einstellungen + + + Chat ausblenden (Enter holt ihn zurück) + diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index 5713c5d..ca60161 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1145,4 +1145,13 @@ Αυτό το μήνυμα περιέχει σύμβολα plugin που άλλοι παίκτες μπορεί να βλέπουν ως κενά κουτιά. Πιέστε Enter ξανά για αποστολή. + + Εισαγωγή συμβόλου + + + Ρυθμίσεις + + + Απόκρυψη συνομιλίας (Enter για επαναφορά) + diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index 9804890..0b97cf2 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1146,4 +1146,13 @@ Este mensaje contiene símbolos exclusivos del plugin que otros jugadores pueden ver como cuadros vacíos. Presiona Intro de nuevo para enviarlo de todas formas. + + Insertar símbolo + + + Ajustes + + + Ocultar el chat (Intro para recuperarlo) + diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index d188844..2ab10f9 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1145,4 +1145,13 @@ Tämä viesti sisältää plugin-symboleja, jotka muut pelaajat saattavat nähdä tyhjinä ruutuina. Paina Enter uudelleen lähettääksesi joka tapauksessa. + + Lisää symboli + + + Asetukset + + + Piilota chat (Enter palauttaa sen) + diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index 2dfe333..b80db73 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1146,4 +1146,13 @@ Ce message contient des symboles réservés au plugin que d'autres joueurs pourraient voir comme des cases vides. Appuyez à nouveau sur Entrée pour envoyer quand même. + + Insérer un symbole + + + Paramètres + + + Masquer le chat (Entrée pour le rouvrir) + diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index 9fdfe8b..e0a6b28 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1145,4 +1145,13 @@ Ez az üzenet plugin-kizárólagos szimbólumokat tartalmaz, amelyeket más játékosok üres négyzetként láthatnak. Nyomd meg ismét az Entert a küldéshez. + + Szimbólum beszúrása + + + Beállítások + + + Csevegés elrejtése (Enter visszahozza) + diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index 47c1ad3..e526a6a 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1146,4 +1146,13 @@ Questo messaggio contiene simboli esclusivi del plugin che altri giocatori potrebbero vedere come caselle vuote. Premi Invio di nuovo per inviare comunque. + + Inserisci simbolo + + + Impostazioni + + + Nascondi la chat (Invio per riaprirla) + diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index ba2e208..5b44b9b 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1146,4 +1146,13 @@ このメッセージにはプラグイン専用のシンボルが含まれており、他のプレイヤーには空白の箱として表示される可能性があります。Enterを再度押して送信します。 + + 記号を挿入 + + + 設定 + + + チャットを非表示(Enterで再表示) + diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 6130356..34150ed 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1146,4 +1146,13 @@ 이 메시지에는 다른 플레이어에게 빈 박스로 보일 수 있는 플러그인 전용 기호가 포함되어 있습니다. Enter를 다시 눌러 전송합니다. + + 기호 삽입 + + + 설정 + + + 채팅 숨기기 (Enter로 다시 표시) + diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index 066a40d..da3d432 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1145,4 +1145,13 @@ Denne meldingen inneholder plugin-eksklusive symboler som andre spillere kanskje ser som tomme bokser. Trykk Enter igjen for å sende likevel. + + Sett inn symbol + + + Innstillinger + + + Skjul chatten (Enter henter den tilbake) + diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 9c83184..2ff3a87 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1146,4 +1146,13 @@ Dit bericht bevat plugin-exclusieve symbolen die andere spelers mogelijk als lege vakjes zien. Druk opnieuw op Enter om toch te verzenden. + + Symbool invoegen + + + Instellingen + + + Chat verbergen (Enter haalt hem terug) + diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index 1cc4933..6470bb7 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1145,4 +1145,13 @@ Ta wiadomość zawiera symbole tylko dla wtyczki, które inni gracze mogą widzieć jako puste kwadraty. Naciśnij Enter ponownie, aby wysłać mimo to. + + Wstaw symbol + + + Ustawienia + + + Ukryj czat (Enter go przywraca) + diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index cd377b5..db32670 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1146,4 +1146,13 @@ Esta mensagem contém símbolos exclusivos do plugin que outros jogadores podem ver como caixas vazias. Pressione Enter novamente para enviar mesmo assim. + + Inserir símbolo + + + Configurações + + + Ocultar o chat (Enter para trazer de volta) + diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index e260ef5..a5fa9d5 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1145,4 +1145,13 @@ Esta mensagem contém símbolos exclusivos do plugin que outros jogadores podem ver como caixas vazias. Pressiona Enter novamente para enviar mesmo assim. + + Inserir símbolo + + + Definições + + + Ocultar o chat (Enter para o repor) + diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index b70fed1..c6a3dd0 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1166,4 +1166,13 @@ Changelog + + Insert symbol + + + Settings + + + Hide chat (Enter to bring back) + diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index 4984bf4..2dbcf08 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1146,4 +1146,13 @@ Acest mesaj conține simboluri exclusiv plugin pe care alți jucători le-ar putea vedea ca casete goale. Apasă Enter din nou pentru a trimite oricum. + + Inserează un simbol + + + Setări + + + Ascunde chatul (Enter îl readuce) + diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index b8662ab..2e4dc70 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1146,4 +1146,13 @@ Это сообщение содержит символы плагина, которые другие игроки могут видеть как пустые квадраты. Нажмите Enter ещё раз, чтобы всё равно отправить. + + Вставить символ + + + Настройки + + + Скрыть чат (Enter вернёт его) + diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index e955e2e..ccc9f9e 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1146,4 +1146,13 @@ Det här meddelandet innehåller plugin-exklusiva symboler som andra spelare kanske ser som tomma rutor. Tryck Enter igen för att skicka ändå. + + Infoga symbol + + + Inställningar + + + Dölj chatten (Enter tar tillbaka den) + diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index 7f35352..52cdb22 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1145,4 +1145,13 @@ Bu mesaj, diğer oyuncuların boş kutu olarak görebileceği yalnızca plugin semboller içeriyor. Yine de göndermek için Enter'a tekrar basın. + + Sembol ekle + + + Ayarlar + + + Sohbeti gizle (Geri getirmek için Enter) + diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 5b9f792..87379fc 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1145,4 +1145,13 @@ Це повідомлення містить символи плагіна, які інші гр��вці можуть бачити як порожні квадрати. Натисніть Enter ще раз, щоб надіслати все одно. + + Вставити символ + + + Налаштування + + + Сховати чат (Enter поверне його) + diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index 43c194b..44b8bad 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1146,4 +1146,13 @@ 该消息包含插件专用符号,其他玩家可能看到空白方框。再次按 Enter 键强制发送。 + + 插入符号 + + + 设置 + + + 隐藏聊天(按 Enter 恢复) + diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index 1cf648d..fa8c758 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1146,4 +1146,13 @@ 該訊息包含插件專用符號,其他玩家可能看到空白方框。再次按 Enter 鍵強制發送。 + + 插入符號 + + + 設定 + + + 隱藏聊天(按 Enter 恢復) + diff --git a/HellionChat/Ui/Components/InputBar.cs b/HellionChat/Ui/Components/InputBar.cs index ab9854d..12c6e8e 100644 --- a/HellionChat/Ui/Components/InputBar.cs +++ b/HellionChat/Ui/Components/InputBar.cs @@ -511,9 +511,9 @@ internal sealed class InputBar private void DrawQuickButtons() { - // Tooltip text is collected here and drawn after the icon font is popped. - // Inside the push it rendered against the FontAwesome atlas, which has no - // ASCII glyphs, so every tooltip came out as an empty box. + // Collected here and drawn after the icon font is popped. Inside the push + // it rendered against the FontAwesome atlas, which has no ASCII glyphs, so + // every tooltip came out as an empty box. string? tooltip = null; using (_fonts.FontAwesome.Push()) @@ -521,7 +521,7 @@ internal sealed class InputBar if (ImGui.Button(FontAwesomeIcon.SmileBeam.ToIconString())) _symbolPicker.OpenPopup(); if (ImGui.IsItemHovered()) - tooltip = "Insert symbol"; + tooltip = HellionStrings.InputBar_InsertSymbol_Tooltip; if (_themeQuickPicker is not null) { @@ -538,7 +538,7 @@ internal sealed class InputBar _onOpenSettings(); } if (ImGui.IsItemHovered()) - tooltip = "Settings"; + tooltip = HellionStrings.InputBar_Settings_Tooltip; // Hides the window (1.5.6 UserHide). One-way — Enter brings it back. // Main window only (pop-outs have their own close); last in the row. @@ -548,7 +548,7 @@ internal sealed class InputBar if (ImGui.Button(FontAwesomeIcon.EyeSlash.ToIconString())) _onHideWindow(); if (ImGui.IsItemHovered()) - tooltip = "Hide chat (Enter to bring back)"; + tooltip = HellionStrings.InputBar_HideChat_Tooltip; } } From af242db6f601e13ae7235f47c0ad0a88d92fadf3 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 10:13:19 +0200 Subject: [PATCH 062/129] chore(release): bump assembly version to 1.10.0 Style foundation cycle closed. Smoke tests green across the sidebar, the tab strip, the status bar, the theme preview and both message densities. Download links stay on v1.5.6 deliberately: v1.6.0 through v1.10.0 are local development states and nobody should update into one. The README badge stays there too, since it points at the published release. The roadmap now reflects the actual sequence -- the old "next cycle" entry still named v1.5.7 ad-block, which has been behind the v2.x UI rebuild since May. Verified file by file: csproj, repo.json (both assembly versions, links untouched), CHANGELOG, ROADMAP. Four TODOs remain in the tree, all pre-existing and none from this cycle. --- HellionChat/HellionChat.csproj | 2 +- docs/CHANGELOG.md | 56 ++++++++++++++++++++++++++++++++++ docs/ROADMAP.md | 23 ++++++++++++-- repo.json | 4 +-- 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj index ad455d0..2f0cb6f 100644 --- a/HellionChat/HellionChat.csproj +++ b/HellionChat/HellionChat.csproj @@ -1,7 +1,7 @@ - 1.9.0 + 1.10.0 enable enable diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b754bc8..d53edfe 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,62 @@ releases as an overview and links to the release pages for details. --- +## [1.10.0] — unreleased (local only) + +The style foundation the v2.x plan has been carrying since May. Custom-drawn chrome instead of +ImGui defaults, and a layout layer that survives display scaling. **Not published** — the public +release stays at v1.5.6. + +### Added + +- Sidebar rows now have surfaces: the active tab gets a raised fill and a 2px accent bar, hover + fades in and back out, and rows sit flush with separators between them. +- Top tabs are drawn rather than borrowed from ImGui selectables: their own height, an accent + underline on the active one, and three text states. +- Unread markers are count badges in the accent colour rather than a red dot. Red reads as an + error; an unread message is not one. +- Status bar slots are pills — channel with its status dot, privacy with its lock, counts, tells, + and the version right-aligned. Each slot drops out on its own when the window gets too narrow. +- Widget gallery under `/hellion widgets` (debug builds only) showing every drawn widget in its + states. + +### Fixed + +- Compact message rows assumed a constant height and were clipped with a fixed 18px. They wrap, so + they are not constant, and the real single line is 17px at the default font — the list drifted + against the scrollbar. Both densities now plan from measured heights. +- The message height cache ignored display scaling, which feeds word wrapping. Changing the scale + left every cached row stale. +- Layout constants across the sidebar, input bar, honorific header and message list did not scale. + At 150% the text grew and the boxes did not; the quick buttons stopped fitting their column. +- Vertical offsets were frozen values that only centred correctly at one font size. They are + measured now. +- The hover sheen kept its own timestamp map and only cleared it on un-hover, so a row that + disappeared while hovered leaked its entry until the plugin reloaded. +- Quick-button tooltips rendered inside the icon font, which has no ASCII glyphs, and came out as + empty boxes. They are also localised now. +- The theme preview in settings had been showing surfaces, an accent bar and an unread marker that + the real sidebar never drew. Both sides match now, and the preview reads the same colour tokens. + +### Changed + +- Hover is a held value that rises at 14/s and falls at 8/s, rather than a one-shot sweep that + stopped after 0.65s while the pointer was still there. +- The active surface token went from 10% to 25% toward the primary colour. At 10% it was + indistinguishable from an idle row. +- Row fills follow the window's own opacity, so the sidebar no longer sits as a solid block inside + a translucent window. + +### Internal + +- New style layer: `Metrics` (scale-aware layout values), `HoverState` (held hover per element), + and five drawn widgets — Row, Badge, IconButton, LineDivider, Pill. +- Pure halves split out for the build suite: `MetricsMath`, `HoverMath`, `WidgetGeometry`, + `LayoutFingerprint`. +- Test suite 829 → 892, and `StatusBarCacheTests` is back in the build after sitting in the + exclusion block. + + ## [1.9.0] — unreleased (local only) Polish, tester-beta preparation and a concurrency hardening pass. **Not published** — the public diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 09d3844..97a82fa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,9 +10,28 @@ be a poor fit for the plugin's privacy-first scope during brainstorming. --- -## Next Cycle +## Current development (unreleased) -**v1.5.7 — Ad-Block / Spam-Filter** is the next planned scope: the hybrid ad-block and +The published release is **v1.5.6**. Development since then runs as a UI rebuild towards v2.0.0 and +is not published: the whole window layer is being rewritten from ImGui defaults to custom drawing. +Versions v1.6.0 through v1.10.0 are local development states, and `repo.json` deliberately keeps +its download links on v1.5.6 so nobody updates into a partial state. + +Where it stands: + +- **v1.6.0 to v1.8.x** — new window layer, settings window, channel pop-outs, and the restoration + pass that brought v1.5.6 behaviour back onto it. +- **v1.9.0** — polish and a concurrency hardening pass. +- **v1.10.0** — the style foundation: a scale-aware layout layer, held hover state, and five drawn + widgets, applied to the sidebar, the tab strip and the status bar. +- **v1.11.0 onwards** — settings window, then the sidebar moving from tab rows to channel rows, + then typography and message cards. + +A tester beta follows once the visual pass is complete. + +## After that + +**v1.5.7 — Ad-Block / Spam-Filter** remains the next feature scope: the hybrid ad-block and spam-filter cycle, combining a lightweight built-in filter with optional `NoSoliciting` IPC integration. Plugin Integrations Wave 2-6 (Context-Menu, NotificationMaster, Moodles, ExtraChat, XIVIM Quick-DM) follows. diff --git a/repo.json b/repo.json index eb7a138..15cf316 100644 --- a/repo.json +++ b/repo.json @@ -3,7 +3,7 @@ "Author": "Jon Kazama (Hellion Forge)", "Name": "Hellion Chat", "InternalName": "HellionChat", - "AssemblyVersion": "1.9.0.0", + "AssemblyVersion": "1.10.0.0", "Description": "A Hellion Forge plugin — privacy-focused chat replacement for FINAL FANTASY XIV, built for EU, US and JP data rules.\n\nBy default only your own conversations are stored. Public chat, NPC dialogue, system messages and battle logs are discarded at the storage layer unless you opt in. Retention windows are configurable per channel, history can be wiped retroactively, and everything can be exported on demand.\n\nFeatures:\n- Channel whitelist with a Privacy-First default\n- Per-channel retention with a daily background sweep\n- Retroactive cleanup with preview and Ctrl+Shift confirm\n- Export to Markdown, JSON or CSV\n- First-run wizard with four profiles: Privacy-First, Casual, Roleplay, Full History\n- Multi-language UI (24 locales) with live language switching\n- Own config and database — no shared state with other plugins\n\nBased on Chat 2 by Infi and Anna (EUPL-1.2).\nSupport: https://discord.gg/X9V7Kcv5gR", "ApplicableVersion": "any", "RepoUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat", @@ -25,7 +25,7 @@ "DownloadLinkInstall": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip", "DownloadLinkUpdate": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip", "DownloadLinkTesting": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip", - "TestingAssemblyVersion": "1.9.0.0", + "TestingAssemblyVersion": "1.10.0.0", "IconUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/icon.png", "ImageUrls": [ "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/chatWindow.png", From 81b68a1681b7de7c041ce7a1c52d9a8661a1df30 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 11:19:32 +0200 Subject: [PATCH 063/129] fix(settings): stop drawing the same privacy options in two tabs Three settings were rendered in two places at once, and the third is the one that matters: the entire 89-entry channel grid existed twice. ChatTab's "Channel filter" section and DataPrivacyTab's "Privacy filter" section wrote the same HashSet through near-identical code, differing only in the ImGui id suffix. Whichever one the user found first, the other silently showed the same state. ChatTab's section is gone entirely. DataPrivacyTab is a proper superset of it -- it additionally carries PrivacyPersistUnknownChannels -- so nothing is lost. PrintChangelog keeps only its General entry, where it belongs: it is start-up behaviour, not privacy. The lock-ordering comment moved before the deletion. DataPrivacyTab's version was "see ChatTab for the ordering", a cross-reference to the file being removed; the actual reasoning only existed in ChatTab. --- .../Ui/Components/Settings/Tabs/ChatTab.cs | 39 ------------------- .../Settings/Tabs/DataPrivacyTab.cs | 8 +--- 2 files changed, 2 insertions(+), 45 deletions(-) diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs index ae89425..3081efb 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs @@ -47,16 +47,6 @@ internal sealed class ChatTab DrawNameFormCombo(); } - if (ImGui.CollapsingHeader("Channel filter")) - { - DrawToggle( - "Privacy filter enabled", - () => Plugin.Config.PrivacyFilterEnabled, - v => Plugin.Config.PrivacyFilterEnabled = v - ); - DrawPrivacyPersistChannels(); - } - if (ImGui.CollapsingHeader("Command help")) { DrawCommandHelpSideCombo(); @@ -73,35 +63,6 @@ internal sealed class ChatTab } } - private void DrawPrivacyPersistChannels() - { - // Enum.GetValues gives a stable order; HashSet membership is the source - // of truth, so we toggle via Add/Remove instead of mutating a copy. - ImGui.TextUnformatted("Persist channels:"); - foreach (var ct in Enum.GetValues()) - { - var label = ct.ToString(); - var present = Plugin.Config.PrivacyPersistChannels.Contains(ct); - if (ImGui.Checkbox($"{label}##persist-{label}", ref present)) - { - // Lock closes before SaveConfig: taking ConfigMapsLock across a save - // would invert the lock order (SaveConfig can reach TabsListLock). - lock (_plugin.ConfigMapsLock) - { - if (present) - { - Plugin.Config.PrivacyPersistChannels.Add(ct); - } - else - { - Plugin.Config.PrivacyPersistChannels.Remove(ct); - } - } - _plugin.SaveConfig(); - } - } - } - private void DrawCommandHelpSideCombo() { var current = Plugin.Config.CommandHelpSide; diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs index 403be48..6e83f84 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -16,11 +16,6 @@ internal sealed class DataPrivacyTab { if (ImGui.CollapsingHeader("Logging", ImGuiTreeNodeFlags.DefaultOpen)) { - DrawToggle( - "Print changelog on update", - () => Plugin.Config.PrintChangelog, - v => Plugin.Config.PrintChangelog = v - ); DrawToggle( "Enable retention sweep", () => Plugin.Config.RetentionEnabled, @@ -81,7 +76,8 @@ internal sealed class DataPrivacyTab var present = Plugin.Config.PrivacyPersistChannels.Contains(ct); if (ImGui.Checkbox($"{label}##privacy-persist-{label}", ref present)) { - // Lock closes before the save below, see ChatTab for the ordering. + // Lock closes before SaveConfig: taking ConfigMapsLock across a save + // would invert the lock order (SaveConfig can reach TabsListLock). lock (_plugin.ConfigMapsLock) { if (present) From 9a31bbba037e94be3eb8f0deba67ed0b0db8b9ad Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 11:20:52 +0200 Subject: [PATCH 064/129] feat(settings): bring back three settings that had no way to reach them All three drive real behaviour and have translated labels in all 25 language files. None of them had a control anywhere in the UI -- they were lost in the v1.6.0 window rewrite and nobody noticed, because the config kept working with whatever value happened to be stored. Language picker. This is the one that needed care: a combo alone would have been wrong twice over. LanguageChanged is an instance method, not static, and its parameter only matters when the override is None -- it reads the config itself otherwise. Passing picked.Code() there yields "", so "follow Dalamud" would have silently meant English. Startup gets this right and is copied. More importantly the glyph ranges. Their activation used to live in Settings.Apply, a class that has not existed since v1.6.0; the comment at Plugin.cs:290 still points at it. Without OR-ing the required range in and rebuilding the font atlas, switching to Korean renders empty boxes until the plugin reloads. Four steps, in this order, and the order is forced: the culture switch reads the config, the atlas rebuild reads the glyph ranges. Sound toggle. Gates both the per-tab notification sounds and the UI click sound. It even has a self-test, just no switch. Keybind mode. Strict versus flexible modifier matching. It has Name() and Tooltip() per value, so it was demonstrably a control in v1.5.6. The language order is sorted once into a static: 24 of the 25 endonyms are fixed literals, so the order does not depend on the current culture, and recomputing it per frame would make the list jump the moment None's own label changes. "Show novice network" moves to Behaviour on the way past. It is a display filter, and it was the only entry under "Notifications" -- which now holds the sound toggle it was named for. --- HellionChat/PluginHostFactory.cs | 3 +- .../Ui/Components/Settings/Tabs/GeneralTab.cs | 102 +++++++++++++++++- 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 5960b90..f67d683 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -196,7 +196,8 @@ internal static class PluginHostFactory sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.GeneralTab( - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.ChatTab( sp.GetRequiredService() diff --git a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs index e82f597..feab47d 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs @@ -1,4 +1,5 @@ using Dalamud.Bindings.ImGui; +using HellionChat.Resources; using HellionChat.Util; namespace HellionChat.Ui.Components.Settings.Tabs; @@ -6,10 +7,25 @@ namespace HellionChat.Ui.Components.Settings.Tabs; internal sealed class GeneralTab { private readonly Plugin _plugin; + private readonly FontManager _fonts; - public GeneralTab(Plugin plugin) + // Endonyms are fixed literals for 24 of the 25 entries, so the order is + // language-independent and worth sorting once. Recomputing per frame would + // also make the list jump the moment None's own label changes culture. + private static readonly LanguageOverride[] LanguageOrder = BuildLanguageOrder(); + + public GeneralTab(Plugin plugin, FontManager fonts) { _plugin = plugin; + _fonts = fonts; + } + + private static LanguageOverride[] BuildLanguageOrder() + { + var all = Enum.GetValues() + .Where(l => l != LanguageOverride.None) + .OrderBy(l => l.Name(), StringComparer.CurrentCulture); + return new[] { LanguageOverride.None }.Concat(all).ToArray(); } public void Draw() @@ -26,6 +42,12 @@ internal sealed class GeneralTab () => Plugin.Config.PrintChangelog, v => Plugin.Config.PrintChangelog = v ); + DrawToggle( + "Show novice network", + () => Plugin.Config.ShowNoviceNetwork, + v => Plugin.Config.ShowNoviceNetwork = v + ); + DrawLanguagePicker(); } if (ImGui.CollapsingHeader("Keybinds", ImGuiTreeNodeFlags.DefaultOpen)) @@ -43,14 +65,18 @@ internal sealed class GeneralTab () => Plugin.Config.ChatTabBackward, v => Plugin.Config.ChatTabBackward = v ); + DrawKeybindModePicker(); } if (ImGui.CollapsingHeader("Notifications", ImGuiTreeNodeFlags.DefaultOpen)) { DrawToggle( - "Show novice network", - () => Plugin.Config.ShowNoviceNetwork, - v => Plugin.Config.ShowNoviceNetwork = v + "Play sounds", + () => Plugin.Config.PlaySounds, + v => Plugin.Config.PlaySounds = v + ); + ImGuiUtil.HelpMarker( + "Gates both the per-tab notification sounds and the UI click sound." ); } @@ -66,6 +92,74 @@ internal sealed class GeneralTab } } + // Four steps, all of them required. The glyph-range activation used to live + // in Settings.Apply, which has not existed since the v1.6.0 rewrite -- see + // the comment at Plugin.cs:290. Without steps 2 and 4 a switch to Korean + // renders empty boxes until the plugin reloads. + private void DrawLanguagePicker() + { + var current = Plugin.Config.LanguageOverride; + var selected = Array.IndexOf(LanguageOrder, current); + var labels = new string[LanguageOrder.Length]; + for (var i = 0; i < LanguageOrder.Length; i++) + labels[i] = LanguageOrder[i].Name(); + + ImGui.SetNextItemWidth(200f); + if (ImGui.Combo(Language.Options_Language_Name, ref selected, labels, labels.Length)) + { + if (selected >= 0 && selected < LanguageOrder.Length) + ApplyLanguage(LanguageOrder[selected]); + } + + ImGuiUtil.HelpMarker( + string.Format(Language.Options_Language_Description, Plugin.PluginName) + ); + } + + private void ApplyLanguage(LanguageOverride picked) + { + Plugin.Config.LanguageOverride = picked; + + var required = picked.RequiredGlyphRanges(); + if (required != 0 && !Plugin.Config.ExtraGlyphRanges.HasFlag(required)) + Plugin.Config.ExtraGlyphRanges |= required; + + _plugin.SaveConfig(); + + // Instance method, and the argument only matters when the override is + // None: LanguageChanged reads the config itself and falls back to the + // parameter only in that case. Passing picked.Code() there yields "", + // so "follow Dalamud" would silently mean English. + _plugin.LanguageChanged(Plugin.Interface.UiLanguage); + + // Reads Config.ExtraGlyphRanges via SetUpRanges, so it has to come after + // the OR above. Same thread as every font push, no marshal needed. + _fonts.RebuildDelegateFonts(); + } + + private void DrawKeybindModePicker() + { + var values = Enum.GetValues(); + var current = Plugin.Config.KeybindMode; + var selected = Array.IndexOf(values, current); + var labels = new string[values.Length]; + for (var i = 0; i < values.Length; i++) + labels[i] = values[i].Name(); + + ImGui.SetNextItemWidth(200f); + if (ImGui.Combo("Modifier matching", ref selected, labels, labels.Length)) + { + if (selected >= 0 && selected < values.Length) + { + Plugin.Config.KeybindMode = values[selected]; + _plugin.SaveConfig(); + } + } + + if (Plugin.Config.KeybindMode.Tooltip() is { } tip) + ImGuiUtil.HelpMarker(tip); + } + private void DrawToggle(string label, Func get, Action set) { var current = get(); From 5b51f7dcfd5cbf8fb1bf5bc721018104724a1d8c Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 11:33:42 +0200 Subject: [PATCH 065/129] fix(settings): use the existing translations and unfreeze the window title Review of block A found three things a German user sees straight away. Two of the three new controls hard-coded English labels although translated resources already existed for exactly them: Options_PlaySounds_Name and Options_KeybindMode_Name are in all 25 files. So "Sprache" sat directly above "Play sounds". The help marker now uses Options_PlaySounds_Description too. The settings window title was baked in at construction, so it kept whatever language the plugin started in. Every other string re-reads per draw; this was the one frozen one. A PreDraw override refreshes it. Three smaller items from the same review: The comment on the language order was wrong in both halves. None never reaches the sort -- it is filtered out and pinned to the front -- so the "list would jump" hazard it described cannot happen, and the count was 25 endonyms, not 24. Left as-is it invited someone to swap Where and OrderBy, which would make the order depend on the culture the game started in. The rebuild comment claimed thread affinity made it safe. It does not: the rebuild disposes the very font handle that Plugin.Draw has pushed for the frame. It works because Dalamud holds the ImFont under a per-frame lock, and that is now what the comment says. The help marker also warns that switching rebuilds the atlas and where to clear accumulated glyph ranges. ApplyLanguage gets an equality guard. Combo only reports real changes, but a font atlas rebuild is expensive enough that no future caller should be able to trigger a no-op one. ChatTab lost its last ChatType reference with the deleted grid, so the using went too. --- .../Ui/Components/Settings/Tabs/ChatTab.cs | 1 - .../Ui/Components/Settings/Tabs/GeneralTab.cs | 35 ++++++++++++++----- HellionChat/Ui/Windows/SettingsWindow.cs | 10 ++++++ 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs index 3081efb..68fdf4e 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs @@ -1,5 +1,4 @@ using Dalamud.Bindings.ImGui; -using HellionChat.Code; using HellionChat.Resources; using HellionChat.Util; diff --git a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs index feab47d..36b15de 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs @@ -9,9 +9,10 @@ internal sealed class GeneralTab private readonly Plugin _plugin; private readonly FontManager _fonts; - // Endonyms are fixed literals for 24 of the 25 entries, so the order is - // language-independent and worth sorting once. Recomputing per frame would - // also make the list jump the moment None's own label changes culture. + // Sorted once: the 25 endonyms are fixed literals, so the order never + // changes, and rebuilding it per frame costs an Enum.GetValues plus the + // whole LINQ chain. None is pinned to the front rather than sorted, so its + // localised label never affects the ordering. private static readonly LanguageOverride[] LanguageOrder = BuildLanguageOrder(); public GeneralTab(Plugin plugin, FontManager fonts) @@ -71,13 +72,11 @@ internal sealed class GeneralTab if (ImGui.CollapsingHeader("Notifications", ImGuiTreeNodeFlags.DefaultOpen)) { DrawToggle( - "Play sounds", + Language.Options_PlaySounds_Name, () => Plugin.Config.PlaySounds, v => Plugin.Config.PlaySounds = v ); - ImGuiUtil.HelpMarker( - "Gates both the per-tab notification sounds and the UI click sound." - ); + ImGuiUtil.HelpMarker(Language.Options_PlaySounds_Description); } if (ImGui.CollapsingHeader("Volumes", ImGuiTreeNodeFlags.DefaultOpen)) @@ -113,11 +112,19 @@ internal sealed class GeneralTab ImGuiUtil.HelpMarker( string.Format(Language.Options_Language_Description, Plugin.PluginName) + + "\n\nSwitching rebuilds the font atlas, so the chat window goes blank" + + " for a moment. Scripts a language needs stay enabled afterwards;" + + " clear them under Appearance -> Fonts -> Extra glyphs." ); } private void ApplyLanguage(LanguageOverride picked) { + // Combo only reports real changes, but a rebuild is expensive enough + // that a future caller should not be able to trigger a no-op one. + if (picked == Plugin.Config.LanguageOverride) + return; + Plugin.Config.LanguageOverride = picked; var required = picked.RequiredGlyphRanges(); @@ -133,7 +140,17 @@ internal sealed class GeneralTab _plugin.LanguageChanged(Plugin.Interface.UiLanguage); // Reads Config.ExtraGlyphRanges via SetUpRanges, so it has to come after - // the OR above. Same thread as every font push, no marshal needed. + // the OR above. + // + // This runs inside Plugin.Draw's open font push (Plugin.cs:1105) and + // disposes the very handle that is on the ImGui font stack. It is safe + // because Dalamud keeps the ImFont alive under a per-frame lock until + // the frame ends -- not merely because we are on the draw thread, which + // is what the old comment claimed. + // + // The rebuild is asynchronous: FontsReady goes false until the atlas is + // done, and every chat component returns early meanwhile. For CJK that + // is visible as a blank chat window for a moment. _fonts.RebuildDelegateFonts(); } @@ -147,7 +164,7 @@ internal sealed class GeneralTab labels[i] = values[i].Name(); ImGui.SetNextItemWidth(200f); - if (ImGui.Combo("Modifier matching", ref selected, labels, labels.Length)) + if (ImGui.Combo(Language.Options_KeybindMode_Name, ref selected, labels, labels.Length)) { if (selected >= 0 && selected < values.Length) { diff --git a/HellionChat/Ui/Windows/SettingsWindow.cs b/HellionChat/Ui/Windows/SettingsWindow.cs index b7b8537..543b2e1 100644 --- a/HellionChat/Ui/Windows/SettingsWindow.cs +++ b/HellionChat/Ui/Windows/SettingsWindow.cs @@ -75,6 +75,16 @@ internal sealed class SettingsWindow : Window DisableWindowSounds = true; } + // The title is baked in at construction, so it kept the language the plugin + // started in even after the user switched. Everything else re-reads its + // strings per draw; this was the one frozen string. + public override void PreDraw() + { + var wanted = $"{Language.Settings_Title.Format(Plugin.PluginName)}###chat2-settings"; + if (!string.Equals(WindowName, wanted, StringComparison.Ordinal)) + WindowName = wanted; + } + public override void Draw() { _sidebar.Draw(); From 86992c70f2a1910cc35d811f77aa5c4d5cb04827 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 11:37:11 +0200 Subject: [PATCH 066/129] feat(style): add the setting-row and toggle-switch widgets Two composites for the settings window, both built on the primitives from v1.10.0 rather than beside them. SettingRow puts the label left and the control right-aligned. ImGui does it the other way round, control first and label trailing, which is a large part of why the settings window reads as a form dump rather than a settings page. The control arrives as a callback so one row covers toggles, sliders, combos and buttons. The cursor advance in SettingRow is the part that needed care. It has to happen after the callback, because ItemSize overwrites CursorPos outright when the control is submitted -- advancing first would simply be undone. But it must not be an ImGui.Dummy: that submits an item and would replace the control as g.LastItemData, silently disabling every IsItemDeactivatedAfterEdit save throttle in the window. SetCursorScreenPos moves the cursor without touching last-item state. ToggleSwitch is a capsule with a gliding knob, driven by HoverState so it animates instead of snapping. Unlike a slider there is no throttle to preserve: a checkbox commits on the click itself. The caller owns the hit area, so a settings row can make the whole row clickable, label included. Named ToggleSwitch, not Toggle: Dalamud's Window base class already has a Toggle() method, and the plain name collides in every window that uses the widget. Geometry lives in WidgetGeometry as usual, with nine new cases pinning the right-alignment split, the narrow-row fallback (the label yields before the control does), and that the knob stays inside its capsule at both ends. ColourUtil gains a two-colour Lerp for the track crossfade. --- .../Ui/StyleEngine/Widgets/SettingRow.cs | 107 ++++++++++++++++++ .../Ui/StyleEngine/Widgets/ToggleSwitch.cs | 70 ++++++++++++ HellionChat/Ui/Windows/WidgetGalleryWindow.cs | 77 +++++++++++++ HellionChat/Util/ColourUtil.cs | 15 +++ HellionChat/Util/WidgetGeometry.cs | 63 +++++++++++ 5 files changed, 332 insertions(+) create mode 100644 HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs create mode 100644 HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs diff --git a/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs new file mode 100644 index 0000000..cc76eb9 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs @@ -0,0 +1,107 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +internal readonly record struct SettingRowColors +{ + public SettingRowColors() { } + + public uint LabelAbgr { get; init; } + public uint DescriptionAbgr { get; init; } + public uint SurfaceHoverAbgr { get; init; } + public uint BorderAbgr { get; init; } +} + +internal readonly record struct SettingRowStyle +{ + public SettingRowStyle() { } + + public float PadY { get; init; } = 4f; + public float Gap { get; init; } = 12f; + public float PreferredControlWidth { get; init; } = 200f; + public bool DrawSeparator { get; init; } +} + +// Label left, control right-aligned. ImGui puts the control first and the label +// after it, which is why the settings window reads as a form dump rather than a +// settings page. +// +// The control comes in as a callback so one row type covers toggles, sliders, +// combos and buttons. +internal static class SettingRow +{ + internal static void Draw( + uint id, + string label, + string? description, + SettingRowColors colors, + Action drawControl, + SettingRowStyle? styleOverride = null + ) + { + var style = styleOverride ?? new SettingRowStyle(); + var scale = Metrics.Scale; + var padY = style.PadY * scale; + var gap = style.Gap * scale; + + var origin = ImGui.GetCursorScreenPos(); + var width = ImGui.GetContentRegionAvail().X; + var lineHeight = ImGui.GetFrameHeight(); + var descHeight = description is null ? 0f : ImGui.GetTextLineHeight(); + var size = WidgetGeometry.SettingRow(width, lineHeight, descHeight, padY); + var (labelWidth, controlX, controlWidth) = WidgetGeometry.SettingRowSplit( + width, + style.PreferredControlWidth * scale, + gap + ); + + var hovered = + ImGui.IsMouseHoveringRect(origin, origin + size) + && ImGui.IsWindowHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem); + var hoverAmount = HoverState.Query(id, hovered); + + // Chrome first, all of it draw-list only: nothing here may submit an + // item, because IsItemDeactivatedAfterEdit inside the callback has to + // see the control as the last submitted item. + Row.Draw( + origin, + size, + new RowVisualState + { + IsActive = false, + HoverAmount = hoverAmount, + SurfaceHoverAbgr = colors.SurfaceHoverAbgr, + SurfaceActiveAbgr = colors.SurfaceHoverAbgr, + AccentAbgr = colors.BorderAbgr, + BorderAbgr = colors.BorderAbgr, + }, + new RowStyle { AccentBarWidth = 0f, DrawSeparator = style.DrawSeparator } + ); + + // Clipped to the label column: a long label would otherwise run underneath + // the control. + var dl = ImGui.GetWindowDrawList(); + dl.PushClipRect(origin, new Vector2(origin.X + labelWidth, origin.Y + size.Y), true); + dl.AddText(new Vector2(origin.X, origin.Y + padY), colors.LabelAbgr, label); + if (description is not null) + dl.AddText( + new Vector2(origin.X, origin.Y + padY + lineHeight), + colors.DescriptionAbgr, + description + ); + dl.PopClipRect(); + + ImGui.SetCursorScreenPos(new Vector2(origin.X + controlX, origin.Y + padY)); + ImGui.SetNextItemWidth(controlWidth); + drawControl(); + + // Advance with SetCursorScreenPos, never Dummy. Dummy submits an item, + // which would replace the control as g.LastItemData and silently + // disable every IsItemDeactivatedAfterEdit save throttle in the window. + // (ItemSize also overwrites CursorPos outright, so advancing before the + // callback would be undone by the callback itself.) + ImGui.SetCursorScreenPos(new Vector2(origin.X, origin.Y + size.Y)); + } +} diff --git a/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs b/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs new file mode 100644 index 0000000..69d8fd0 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs @@ -0,0 +1,70 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +internal readonly record struct ToggleSwitchColors +{ + public ToggleSwitchColors() { } + + public uint TrackOffAbgr { get; init; } + public uint TrackOnAbgr { get; init; } + public uint KnobAbgr { get; init; } +} + +internal readonly record struct ToggleSwitchStyle +{ + public ToggleSwitchStyle() { } + + // Capsule width as a multiple of its height. + public float WidthFactor { get; init; } = 1.9f; +} + +// Sliding-knob switch. Unlike a slider there is no save throttle to preserve: +// a checkbox commits on the click itself, so nothing here can break the +// persistence path. +// +// The caller owns the hit area. In a settings row the whole row is clickable, +// label included, and the widget only knows where to paint. +internal static class ToggleSwitch +{ + internal static Vector2 CalcSize(ToggleSwitchStyle? styleOverride = null) + { + var style = styleOverride ?? new ToggleSwitchStyle(); + var (size, _, _) = WidgetGeometry.Toggle(ImGui.GetFrameHeight(), style.WidthFactor, 0f); + return size; + } + + internal static void Draw( + uint id, + Vector2 origin, + bool value, + ToggleSwitchColors colors, + ToggleSwitchStyle? styleOverride = null + ) + { + var style = styleOverride ?? new ToggleSwitchStyle(); + + // Held state, so the knob glides instead of snapping. Query only marks; + // HoverState.BeginFrame does the advancing. + var amount = HoverState.Query(id, value); + var (size, knobR, knobX) = WidgetGeometry.Toggle( + ImGui.GetFrameHeight(), + style.WidthFactor, + amount + ); + + var dl = ImGui.GetWindowDrawList(); + var max = origin + size; + var track = ColourUtil.Lerp(colors.TrackOffAbgr, colors.TrackOnAbgr, amount); + + dl.AddRectFilled(origin, max, track, size.Y * 0.5f); + dl.AddCircleFilled( + new Vector2(origin.X + knobX, origin.Y + size.Y * 0.5f), + knobR, + colors.KnobAbgr, + 16 + ); + } +} diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index 075c273..c83e37d 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -24,6 +24,8 @@ internal sealed class WidgetGalleryWindow : Window private int _badgeCount = 3; private bool _rowActive = true; + private bool _toggleA = true; + private bool _toggleB; internal WidgetGalleryWindow(Plugin plugin, TokenResolver resolver) : base("Widget Gallery###hellion-widget-gallery") @@ -44,6 +46,8 @@ internal sealed class WidgetGalleryWindow : Window ImGui.Separator(); DrawRowSection(c); + DrawToggleSection(c); + DrawSettingRowSection(c); DrawBadgeSection(c); DrawPillSection(c); DrawIconButtonSection(c); @@ -97,6 +101,79 @@ internal sealed class WidgetGalleryWindow : Window ImGui.Spacing(); } + private void DrawToggleSection(ThemeColors c) + { + ImGui.TextUnformatted("Toggle"); + + var colors = new ToggleSwitchColors + { + TrackOffAbgr = _palette.Abgr(Token.SurfaceRaised, c), + TrackOnAbgr = _palette.Abgr(Token.AccentPrimary, c), + KnobAbgr = _palette.Abgr(Token.Text, c), + }; + + var size = ToggleSwitch.CalcSize(); + var gap = 10f * Metrics.Scale; + + for (var i = 0; i < 2; i++) + { + var origin = ImGui.GetCursorScreenPos(); + var id = ImGui.GetID($"gallery.toggle.{i}"); + if (ImGui.InvisibleButton($"##gallery-toggle-{i}", size)) + { + if (i == 0) + _toggleA = !_toggleA; + else + _toggleB = !_toggleB; + } + + ToggleSwitch.Draw(id, origin, i == 0 ? _toggleA : _toggleB, colors); + if (i == 0) + ImGui.SameLine(0f, gap); + } + + ImGui.Spacing(); + } + + private void DrawSettingRowSection(ThemeColors c) + { + ImGui.TextUnformatted("SettingRow"); + + var colors = new SettingRowColors + { + LabelAbgr = _palette.Abgr(Token.Text, c), + DescriptionAbgr = _palette.Abgr(Token.TextMuted, c), + SurfaceHoverAbgr = _palette.Abgr(Token.SurfaceHover, c), + BorderAbgr = _palette.Abgr(Token.Border, c), + }; + + SettingRow.Draw( + ImGui.GetID("gallery.settingrow.plain"), + "A plain option", + null, + colors, + () => ImGui.Checkbox("##gallery-sr-a", ref _toggleA) + ); + + SettingRow.Draw( + ImGui.GetID("gallery.settingrow.described"), + "With a description", + "The second line explains what the control above actually does.", + colors, + () => ImGui.Checkbox("##gallery-sr-b", ref _toggleB) + ); + + SettingRow.Draw( + ImGui.GetID("gallery.settingrow.long"), + "A deliberately very long label that has to be clipped somewhere", + null, + colors, + () => ImGui.SliderInt("##gallery-sr-c", ref _badgeCount, 0, 150) + ); + + ImGui.Spacing(); + } + private void DrawBadgeSection(ThemeColors c) { ImGui.TextUnformatted("Badge"); diff --git a/HellionChat/Util/ColourUtil.cs b/HellionChat/Util/ColourUtil.cs index 51a8305..8a2a89a 100755 --- a/HellionChat/Util/ColourUtil.cs +++ b/HellionChat/Util/ColourUtil.cs @@ -108,6 +108,21 @@ internal static class ColourUtil // going fully saturated (effect level stays "subtle"). RGB-only on // purpose -- DrawHoverSheen owns the alpha falloff. // TEST-MIRROR: ../../../Hellion Build test/Util/ColourUtilTintTests.cs + // Mixes two ABGR colours channel by channel, alpha included. Used where a + // widget crossfades between two theme slots rather than toward a constant. + internal static uint Lerp(uint fromAbgr, uint toAbgr, float t) + { + t = Math.Clamp(t, 0f, 1f); + uint Mix(int shift) + { + var a = (byte)((fromAbgr >> shift) & 0xFFu); + var b = (byte)((toAbgr >> shift) & 0xFFu); + return (uint)Math.Round(a + (b - a) * t) & 0xFFu; + } + + return (Mix(24) << 24) | (Mix(16) << 16) | (Mix(8) << 8) | Mix(0); + } + internal static uint LerpTowardWhite(uint abgr, float t) { t = Math.Clamp(t, 0f, 1f); diff --git a/HellionChat/Util/WidgetGeometry.cs b/HellionChat/Util/WidgetGeometry.cs index 1c0ec02..e657870 100644 --- a/HellionChat/Util/WidgetGeometry.cs +++ b/HellionChat/Util/WidgetGeometry.cs @@ -48,6 +48,69 @@ internal static class WidgetGeometry return Clamp(new Vector2(width, height)); } + // Label on the left, control right-aligned. The control keeps its preferred + // width unless the row is too narrow, in which case the label yields first: + // a clipped label is readable, a clipped slider is not usable. + internal static (float LabelWidth, float ControlX, float ControlWidth) SettingRowSplit( + float rowWidth, + float preferredControlWidth, + float gap + ) + { + var control = MathF.Min(preferredControlWidth, MathF.Max(MinExtent, rowWidth - gap)); + var labelWidth = MathF.Max(MinExtent, rowWidth - control - gap); + return (labelWidth, rowWidth - control, control); + } + + internal static Vector2 SettingRow( + float rowWidth, + float lineHeight, + float descriptionHeight, + float padY + ) + { + var height = lineHeight + padY * 2f; + if (descriptionHeight > 0f) + height += descriptionHeight; + + return Clamp(new Vector2(rowWidth, height)); + } + + internal static Vector2 SectionHeader( + float width, + float titleHeight, + float descriptionHeight, + float padY, + float lineThickness + ) + { + var height = titleHeight + padY * 2f + lineThickness; + if (descriptionHeight > 0f) + height += descriptionHeight + padY; + + return Clamp(new Vector2(width, height)); + } + + // Capsule plus knob. Height drives everything: the font comes from + // Config.FontSizeV2, which display scaling does not feed into, so a fixed + // capsule would shrink against a larger label. + internal static (Vector2 Size, float KnobRadius, float KnobX) Toggle( + float height, + float widthFactor, + float value + ) + { + var h = MathF.Max(MinExtent, height); + var w = MathF.Max(h, h * widthFactor); + var r = h * 0.5f - 1f; + var travel = w - (r + 1f) * 2f; + return ( + new Vector2(w, h), + MathF.Max(MinExtent, r), + r + 1f + travel * Math.Clamp(value, 0f, 1f) + ); + } + // Sum of pill widths plus the gap between them. Used by the status bar to // decide whether the right-hand slot still fits, replacing a fixed 200px // guess that never measured the left-hand slots at all. From cbc4b17e1e11270632ecab1c0bb2acf89baaa225 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 11:59:52 +0200 Subject: [PATCH 067/129] fix(style): make the setting row usable for the widgets that need it most Review of the two new widgets found four things that would all have landed on the first real tab. SettingRow had no hit area and no return value, so "the whole row is clickable, label included" -- which is the point of pairing it with a switch -- was not reachable. The label half is now an InvisibleButton and Draw returns whether it was clicked. SetNextItemWidth is a silent no-op for Checkbox, RadioButton and InvisibleButton: they size themselves from GetFrameHeight and never call CalcItemWidth. So exactly the controls the settings tabs are full of would have sat at the left edge of the control column, 200px from where the row promised to put them. The callback now receives a context with AlignRight for widgets that know their own size. The switch and the row shared a HoverState key. An enabled switch would have kept its row permanently highlighted, and hovering a disabled row would have slid its knob to "on" -- the widget lying about its own value. ToggleSwitch derives its animation key now, so a caller cannot collide even by passing the same id to both. The cursor advance moves from SetCursorScreenPos to ImGuiP.ItemSize. Both advance the cursor, but only ItemSize is guaranteed to extend CursorMaxPos, which is what the scrollbar measures. SetCursorScreenPos happens to do it on ImGui 1.88, which is what Dalamud ships -- upstream removed that in 1.92 and asserts on the pattern instead. ItemSize does not touch g.LastItemData, so the save throttles stay intact. Two smaller ones: the label column no longer collapses to a single pixel on a narrow row (it stops at 60 and the control shrinks instead), and the toggle geometry clamps its radius once rather than deriving travel from an unclamped value. Descriptions wrap now instead of being cut at the column edge. The gallery gained the pairing both widgets exist for, plus a style-override variant -- the combination that would have exposed all of this. --- .../Ui/StyleEngine/Widgets/SettingRow.cs | 87 ++++++++++++++----- .../Ui/StyleEngine/Widgets/ToggleSwitch.cs | 8 +- HellionChat/Ui/Windows/WidgetGalleryWindow.cs | 53 ++++++++--- HellionChat/Util/WidgetGeometry.cs | 11 ++- 4 files changed, 123 insertions(+), 36 deletions(-) diff --git a/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs index cc76eb9..d380a2f 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs @@ -21,23 +21,41 @@ internal readonly record struct SettingRowStyle public float PadY { get; init; } = 4f; public float Gap { get; init; } = 12f; public float PreferredControlWidth { get; init; } = 200f; - public bool DrawSeparator { get; init; } + public bool DrawSeparator { get; init; } = true; +} + +// Handed to the control callback. Widgets that respect SetNextItemWidth can +// ignore it; the ones that do not -- Checkbox, RadioButton, InvisibleButton -- +// need AlignRight to land where the row promised. +internal readonly record struct SettingRowContext +{ + public Vector2 ControlOrigin { get; init; } + public float ControlWidth { get; init; } + public float ControlHeight { get; init; } + public float HoverAmount { get; init; } + + // Right edge of the control column, vertically centred. + public Vector2 AlignRight(Vector2 size) => + new( + ControlOrigin.X + ControlWidth - size.X, + ControlOrigin.Y + MetricsMath.CenterY(ControlHeight, size.Y) + ); } // Label left, control right-aligned. ImGui puts the control first and the label -// after it, which is why the settings window reads as a form dump rather than a -// settings page. +// after it, which is a large part of why the settings window reads as a form +// dump rather than a settings page. // -// The control comes in as a callback so one row type covers toggles, sliders, -// combos and buttons. +// Returns true when the row itself was clicked outside the control column, so a +// caller can make the whole row toggle its setting. internal static class SettingRow { - internal static void Draw( + internal static bool Draw( uint id, string label, string? description, SettingRowColors colors, - Action drawControl, + Action drawControl, SettingRowStyle? styleOverride = null ) { @@ -57,14 +75,19 @@ internal static class SettingRow gap ); - var hovered = - ImGui.IsMouseHoveringRect(origin, origin + size) - && ImGui.IsWindowHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem); + // The label half is the hit area: the control column submits its own + // item and would fight with a button underneath it. + ImGui.SetCursorScreenPos(origin); + var labelClicked = ImGui.InvisibleButton( + $"##hellion-srow-{id}", + new Vector2(labelWidth, size.Y) + ); + var hovered = ImGui.IsMouseHoveringRect(origin, origin + size) && ImGui.IsWindowHovered(); var hoverAmount = HoverState.Query(id, hovered); - // Chrome first, all of it draw-list only: nothing here may submit an - // item, because IsItemDeactivatedAfterEdit inside the callback has to - // see the control as the last submitted item. + // Chrome first, all of it draw-list only: nothing between here and the + // callback may submit an item, or IsItemDeactivatedAfterEdit inside the + // callback would no longer see the control as the last item. Row.Draw( origin, size, @@ -80,28 +103,44 @@ internal static class SettingRow new RowStyle { AccentBarWidth = 0f, DrawSeparator = style.DrawSeparator } ); - // Clipped to the label column: a long label would otherwise run underneath - // the control. + // Clipped to the label column so a long label cannot run under the + // control. The description wraps instead of being cut. var dl = ImGui.GetWindowDrawList(); dl.PushClipRect(origin, new Vector2(origin.X + labelWidth, origin.Y + size.Y), true); dl.AddText(new Vector2(origin.X, origin.Y + padY), colors.LabelAbgr, label); if (description is not null) dl.AddText( + ImGui.GetFont(), + ImGui.GetFontSize(), new Vector2(origin.X, origin.Y + padY + lineHeight), colors.DescriptionAbgr, - description + description, + labelWidth ); dl.PopClipRect(); - ImGui.SetCursorScreenPos(new Vector2(origin.X + controlX, origin.Y + padY)); + var controlOrigin = new Vector2(origin.X + controlX, origin.Y + padY); + ImGui.SetCursorScreenPos(controlOrigin); ImGui.SetNextItemWidth(controlWidth); - drawControl(); + drawControl( + new SettingRowContext + { + ControlOrigin = controlOrigin, + ControlWidth = controlWidth, + ControlHeight = lineHeight, + HoverAmount = hoverAmount, + } + ); - // Advance with SetCursorScreenPos, never Dummy. Dummy submits an item, - // which would replace the control as g.LastItemData and silently - // disable every IsItemDeactivatedAfterEdit save throttle in the window. - // (ItemSize also overwrites CursorPos outright, so advancing before the - // callback would be undone by the callback itself.) - ImGui.SetCursorScreenPos(new Vector2(origin.X, origin.Y + size.Y)); + // ItemSize, not SetCursorScreenPos: it advances the cursor AND extends + // CursorMaxPos, which is what the scrollbar measures. SetCursorScreenPos + // still does the latter on ImGui 1.88, but upstream removed that in 1.92 + // and asserts on it instead. And not Dummy, which would submit an item + // and replace the control as g.LastItemData, silently disabling every + // IsItemDeactivatedAfterEdit save throttle in the window. + ImGui.SetCursorScreenPos(origin); + ImGuiP.ItemSize(new Vector2(width, size.Y - ImGui.GetStyle().ItemSpacing.Y)); + + return labelClicked; } } diff --git a/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs b/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs index 69d8fd0..5575771 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs @@ -29,6 +29,12 @@ internal readonly record struct ToggleSwitchStyle // label included, and the widget only knows where to paint. internal static class ToggleSwitch { + // The animation rides HoverState, but under a derived key: a caller that + // passes the same id to SettingRow and here would otherwise OR the two + // together -- an enabled switch would keep its row permanently highlighted, + // and hovering a disabled row would slide its knob to "on". + private static uint AnimKey(uint id) => id * 2654435761u + 0x9E3779B9u; + internal static Vector2 CalcSize(ToggleSwitchStyle? styleOverride = null) { var style = styleOverride ?? new ToggleSwitchStyle(); @@ -48,7 +54,7 @@ internal static class ToggleSwitch // Held state, so the knob glides instead of snapping. Query only marks; // HoverState.BeginFrame does the advancing. - var amount = HoverState.Query(id, value); + var amount = HoverState.Query(AnimKey(id), value); var (size, knobR, knobX) = WidgetGeometry.Toggle( ImGui.GetFrameHeight(), style.WidthFactor, diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index c83e37d..3e3a143 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -147,20 +147,42 @@ internal sealed class WidgetGalleryWindow : Window BorderAbgr = _palette.Abgr(Token.Border, c), }; - SettingRow.Draw( - ImGui.GetID("gallery.settingrow.plain"), - "A plain option", - null, - colors, - () => ImGui.Checkbox("##gallery-sr-a", ref _toggleA) - ); + var switchColors = new ToggleSwitchColors + { + TrackOffAbgr = _palette.Abgr(Token.SurfaceRaised, c), + TrackOnAbgr = _palette.Abgr(Token.AccentPrimary, c), + KnobAbgr = _palette.Abgr(Token.Text, c), + }; + + // The pairing both widgets exist for: switch inside a row, whole label + // half clickable. + var idA = ImGui.GetID("gallery.settingrow.switch"); + if ( + SettingRow.Draw( + idA, + "A switch in a row", + null, + colors, + ctx => + { + var size = ToggleSwitch.CalcSize(); + var pos = ctx.AlignRight(size); + ImGui.SetCursorScreenPos(pos); + if (ImGui.InvisibleButton("##gallery-sr-switch", size)) + _toggleA = !_toggleA; + ToggleSwitch.Draw(idA, pos, _toggleA, switchColors); + } + ) + ) + _toggleA = !_toggleA; SettingRow.Draw( ImGui.GetID("gallery.settingrow.described"), "With a description", - "The second line explains what the control above actually does.", + "The second line wraps rather than being cut, so a real settings " + + "explanation actually fits into the label column.", colors, - () => ImGui.Checkbox("##gallery-sr-b", ref _toggleB) + _ => ImGui.Checkbox("##gallery-sr-b", ref _toggleB) ); SettingRow.Draw( @@ -168,7 +190,18 @@ internal sealed class WidgetGalleryWindow : Window "A deliberately very long label that has to be clipped somewhere", null, colors, - () => ImGui.SliderInt("##gallery-sr-c", ref _badgeCount, 0, 150) + _ => ImGui.SliderInt("##gallery-sr-c", ref _badgeCount, 0, 150) + ); + + // No separator, narrow control column: the variants a settings tab will + // actually reach for. + SettingRow.Draw( + ImGui.GetID("gallery.settingrow.styled"), + "Style override", + null, + colors, + _ => ImGui.SliderInt("##gallery-sr-d", ref _badgeCount, 0, 150), + new SettingRowStyle { DrawSeparator = false, PreferredControlWidth = 90f } ); ImGui.Spacing(); diff --git a/HellionChat/Util/WidgetGeometry.cs b/HellionChat/Util/WidgetGeometry.cs index e657870..f74aad7 100644 --- a/HellionChat/Util/WidgetGeometry.cs +++ b/HellionChat/Util/WidgetGeometry.cs @@ -51,13 +51,22 @@ internal static class WidgetGeometry // Label on the left, control right-aligned. The control keeps its preferred // width unless the row is too narrow, in which case the label yields first: // a clipped label is readable, a clipped slider is not usable. + private const float MinLabelWidth = 60f; + internal static (float LabelWidth, float ControlX, float ControlWidth) SettingRowSplit( float rowWidth, float preferredControlWidth, float gap ) { - var control = MathF.Min(preferredControlWidth, MathF.Max(MinExtent, rowWidth - gap)); + var available = MathF.Max(MinExtent, rowWidth - gap); + var control = MathF.Min(preferredControlWidth, available); + + // The label yields first, but only down to MinLabelWidth. A one-pixel + // label is not yielding, it is gone. + if (rowWidth - control - gap < MinLabelWidth) + control = MathF.Max(MinExtent, MathF.Min(control, available - MinLabelWidth)); + var labelWidth = MathF.Max(MinExtent, rowWidth - control - gap); return (labelWidth, rowWidth - control, control); } From cade398b4e2ea5c4885cf5306fab8836fef8123b Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 12:01:12 +0200 Subject: [PATCH 068/129] feat(style): add the collapsible section header widget Replaces ImGui.CollapsingHeader, whose framed bar is the single most ImGui-looking element in the settings window -- 25 of them across nine files. State lives in the widget, not in ImGui's per-window storage. ImGui keys that off the label, so once the section titles are localised the open/closed state would reset on every language switch, and two titles that translate to the same string would share one state. Callers pass a stable key built from an ASCII literal instead. The disabled parameter is not optional decoration. BeginDisabled pushes an alpha that only reaches ImGui's own widgets, so a draw-list header would sit at full opacity while everything around it fades -- and ThemePicker wraps two of its headers in exactly that. Cursor advance goes through ImGuiP.ItemSize like SettingRow, so the scrollbar sees the full height. The gallery shows all three states, including the disabled one inside a real ImRaii.Disabled scope, which is where the alpha problem would otherwise only surface in the theme picker. --- .../Ui/StyleEngine/Widgets/SectionHeader.cs | 156 ++++++++++++++++++ HellionChat/Ui/Windows/WidgetGalleryWindow.cs | 49 ++++++ 2 files changed, 205 insertions(+) create mode 100644 HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs diff --git a/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs new file mode 100644 index 0000000..3aac2b6 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs @@ -0,0 +1,156 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +internal readonly record struct SectionHeaderColors +{ + public SectionHeaderColors() { } + + public uint TitleAbgr { get; init; } + public uint DescriptionAbgr { get; init; } + public uint AccentAbgr { get; init; } + public uint BorderAbgr { get; init; } + public uint HoverAbgr { get; init; } +} + +internal readonly record struct SectionHeaderStyle +{ + public SectionHeaderStyle() { } + + public float PadY { get; init; } = 6f; + public float AccentBarWidth { get; init; } = 2f; + public float ChevronInset { get; init; } = 6f; +} + +// Collapsible section heading. Replaces ImGui.CollapsingHeader, whose framed +// bar is the single most ImGui-looking element in the settings window. +// +// State lives here rather than in ImGui's per-window storage: that keys off the +// label, so once the titles are localised the open/closed state would reset on +// every language switch and translated titles could collide. Callers pass a +// stable key instead, built from an ASCII literal that never gets translated. +internal static class SectionHeader +{ + private static readonly Dictionary Open = []; + + internal static void Reset() => Open.Clear(); + + internal static bool Draw( + uint key, + string title, + string? description, + SectionHeaderColors colors, + bool defaultOpen = false, + bool disabled = false, + SectionHeaderStyle? styleOverride = null + ) + { + var style = styleOverride ?? new SectionHeaderStyle(); + var scale = Metrics.Scale; + var padY = style.PadY * scale; + + if (!Open.TryGetValue(key, out var open)) + { + open = defaultOpen; + Open[key] = open; + } + + var origin = ImGui.GetCursorScreenPos(); + var width = ImGui.GetContentRegionAvail().X; + var titleHeight = ImGui.GetTextLineHeight(); + var descHeight = description is null ? 0f : titleHeight; + var size = WidgetGeometry.SectionHeader(width, titleHeight, descHeight, padY, 1f * scale); + + ImGui.SetCursorScreenPos(origin); + var clicked = ImGui.InvisibleButton($"##hellion-section-{key}", size) && !disabled; + var hovered = ImGui.IsItemHovered() && !disabled; + var hoverAmount = HoverState.Query(key, hovered); + + if (clicked) + { + open = !open; + Open[key] = open; + } + + // BeginDisabled only dims ImGui's own widgets, so a draw-list header + // would stay at full opacity while everything around it fades. The + // ThemePicker wraps two of its headers exactly like that. + var alpha = disabled ? 0.5f : 1f; + var dl = ImGui.GetWindowDrawList(); + var max = origin + size; + + if (hoverAmount > 0f) + dl.AddRectFilled( + origin, + max, + ColourUtil.ApplyAlpha(colors.HoverAbgr, hoverAmount * alpha) + ); + + var barWidth = style.AccentBarWidth * scale; + dl.AddRectFilled( + origin, + new Vector2(origin.X + barWidth, origin.Y + titleHeight + padY * 2f), + ColourUtil.ApplyAlpha(colors.AccentAbgr, alpha) + ); + + var textX = origin.X + barWidth + style.ChevronInset * scale * 2f; + dl.AddText( + new Vector2(textX, origin.Y + padY), + ColourUtil.ApplyAlpha(colors.TitleAbgr, alpha), + title + ); + + DrawChevron( + dl, + new Vector2( + origin.X + barWidth + style.ChevronInset * scale, + origin.Y + padY + titleHeight * 0.5f + ), + titleHeight * 0.28f, + open, + ColourUtil.ApplyAlpha(colors.TitleAbgr, alpha) + ); + + if (description is not null) + dl.AddText( + ImGui.GetFont(), + ImGui.GetFontSize(), + new Vector2(textX, origin.Y + padY + titleHeight), + ColourUtil.ApplyAlpha(colors.DescriptionAbgr, alpha), + description, + width - (textX - origin.X) + ); + + dl.AddLine( + new Vector2(origin.X, max.Y - 1f * scale), + new Vector2(max.X, max.Y - 1f * scale), + ColourUtil.ApplyAlpha(colors.BorderAbgr, alpha), + 1f * scale + ); + + ImGui.SetCursorScreenPos(origin); + ImGuiP.ItemSize(new Vector2(width, size.Y - ImGui.GetStyle().ItemSpacing.Y)); + + return open; + } + + private static void DrawChevron(ImDrawListPtr dl, Vector2 centre, float r, bool open, uint abgr) + { + if (open) + dl.AddTriangleFilled( + centre + new Vector2(-r, -r * 0.5f), + centre + new Vector2(r, -r * 0.5f), + centre + new Vector2(0f, r * 0.7f), + abgr + ); + else + dl.AddTriangleFilled( + centre + new Vector2(-r * 0.5f, -r), + centre + new Vector2(r * 0.7f, 0f), + centre + new Vector2(-r * 0.5f, r), + abgr + ); + } +} diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index 3e3a143..227ed6a 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -45,6 +45,7 @@ internal sealed class WidgetGalleryWindow : Window ); ImGui.Separator(); + DrawSectionHeaderSection(c); DrawRowSection(c); DrawToggleSection(c); DrawSettingRowSection(c); @@ -101,6 +102,54 @@ internal sealed class WidgetGalleryWindow : Window ImGui.Spacing(); } + private void DrawSectionHeaderSection(ThemeColors c) + { + var colors = new SectionHeaderColors + { + TitleAbgr = _palette.Abgr(Token.Text, c), + DescriptionAbgr = _palette.Abgr(Token.TextMuted, c), + AccentAbgr = _palette.Abgr(Token.AccentPrimary, c), + BorderAbgr = _palette.Abgr(Token.Border, c), + HoverAbgr = _palette.Abgr(Token.SurfaceHover, c), + }; + + if ( + SectionHeader.Draw( + ImGui.GetID("gallery.section.open"u8), + "Open by default", + null, + colors, + defaultOpen: true + ) + ) + ImGui.TextDisabled(" content of the open section"); + + if ( + SectionHeader.Draw( + ImGui.GetID("gallery.section.described"u8), + "With a description", + "Explains what the section groups, wrapped to the available width.", + colors + ) + ) + ImGui.TextDisabled(" content of the described section"); + + // Disabled: BeginDisabled only dims ImGui's own widgets, so a draw-list + // header has to fade itself. + using (ImRaii.Disabled()) + { + SectionHeader.Draw( + ImGui.GetID("gallery.section.disabled"u8), + "Disabled", + null, + colors, + disabled: true + ); + } + + ImGui.Spacing(); + } + private void DrawToggleSection(ThemeColors c) { ImGui.TextUnformatted("Toggle"); From 688c05cd778aa6ca234066dc9e358a6b4e295062 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 13:37:43 +0200 Subject: [PATCH 069/129] feat(style): add the segmented control WindowTab picks its layout with two radio buttons that are one setting wearing two labels. A setting row knows one label and one control, so the choice was either two rows that misstate the relationship or a control that holds the whole choice. This is the latter, and it generalises to any small closed set. Segment bounds come from rounded edges rather than a per-segment width. At 201 pixels over two segments the naive form paints two 100.5px halves that land on the same physical column, which leaves a seam in the middle and a gap on the right. Deriving each edge from the run means segment i ends exactly where i+1 starts. Hover keys are derived per segment for the same reason the toggle derives its own: the caller has already spent its id on the enclosing row, and sharing it would tie the row highlight to whichever segment the mouse is over. Disabled is a parameter rather than a BeginDisabled scope because that alpha never reaches draw-list output. The invisible buttons are still submitted while disabled so item count and cursor advance do not change between the two states. MetricsMath gains Center, which is what CenterY always was underneath. Reusing CenterY to centre text horizontally would have read as a bug at every call site. --- .../StyleEngine/Widgets/SegmentedControl.cs | 134 ++++++++++++++++++ HellionChat/Util/MetricsMath.cs | 9 +- HellionChat/Util/WidgetGeometry.cs | 22 +++ 3 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs diff --git a/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs new file mode 100644 index 0000000..f04049a --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs @@ -0,0 +1,134 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +internal readonly record struct SegmentedControlColors +{ + public SegmentedControlColors() { } + + public uint TrackAbgr { get; init; } + public uint SelectedAbgr { get; init; } + public uint HoverAbgr { get; init; } + public uint LabelAbgr { get; init; } + public uint SelectedLabelAbgr { get; init; } + public uint BorderAbgr { get; init; } +} + +internal readonly record struct SegmentedControlStyle +{ + public SegmentedControlStyle() { } + + public float Rounding { get; init; } = 4f; + public float Inset { get; init; } = 2f; +} + +// One setting, n mutually exclusive choices, one control. A radio group spends +// a labelled row per option and still leaves the reader working out that the +// options belong together; this says it in the shape. +// +// Returns the index the user picked, or the current one when nothing changed, +// so callers can compare against the config value and save on difference. +internal static class SegmentedControl +{ + // Per-segment hover keys, derived rather than passed: the caller already + // spends its id on the enclosing row, and reusing it here would tie the + // row's highlight to whichever segment the mouse happens to be over. + private static uint AnimKey(uint id, int index) => + (id ^ (uint)(index + 1) * 0x85EBCA6Bu) * 2654435761u + 0x9E3779B9u; + + internal static int Draw( + uint id, + Vector2 origin, + float width, + ReadOnlySpan labels, + int selected, + SegmentedControlColors colors, + bool disabled = false, + SegmentedControlStyle? styleOverride = null + ) + { + if (labels.Length == 0) + return selected; + + var style = styleOverride ?? new SegmentedControlStyle(); + var scale = Metrics.Scale; + var rounding = style.Rounding * scale; + var inset = style.Inset * scale; + var height = ImGui.GetFrameHeight(); + var alpha = disabled ? 0.5f : 1f; + + var dl = ImGui.GetWindowDrawList(); + dl.AddRectFilled( + origin, + origin + new Vector2(width, height), + ColourUtil.ApplyAlpha(colors.TrackAbgr, alpha), + rounding + ); + + var picked = selected; + for (var i = 0; i < labels.Length; i++) + { + var (segX, segWidth) = WidgetGeometry.Segment(i, labels.Length, width); + var segOrigin = new Vector2(origin.X + segX, origin.Y); + var segSize = new Vector2(segWidth, height); + + // Submitted even while disabled, so the item count and the cursor + // behave identically in both states. Only the result is dropped. + ImGui.SetCursorScreenPos(segOrigin); + var clicked = ImGui.InvisibleButton($"##hellion-seg-{id}-{i}", segSize) && !disabled; + var hovered = ImGui.IsItemHovered() && !disabled; + var hoverAmount = HoverState.Query(AnimKey(id, i), hovered); + + if (clicked) + picked = i; + + var isSelected = i == selected; + if (isSelected) + dl.AddRectFilled( + segOrigin + new Vector2(inset, inset), + segOrigin + segSize - new Vector2(inset, inset), + ColourUtil.ApplyAlpha(colors.SelectedAbgr, alpha), + rounding + ); + else if (hoverAmount > 0f) + dl.AddRectFilled( + segOrigin + new Vector2(inset, inset), + segOrigin + segSize - new Vector2(inset, inset), + ColourUtil.ApplyAlpha(colors.HoverAbgr, hoverAmount * alpha), + rounding + ); + + // Clipped, not truncated: a translated label that outgrows its + // segment should lose its tail rather than bleed into the neighbour. + var label = labels[i]; + var textSize = ImGui.CalcTextSize(label); + dl.PushClipRect(segOrigin, segOrigin + segSize, true); + dl.AddText( + segOrigin + + new Vector2( + MetricsMath.Center(segWidth, textSize.X), + MetricsMath.Center(height, textSize.Y) + ), + ColourUtil.ApplyAlpha( + isSelected ? colors.SelectedLabelAbgr : colors.LabelAbgr, + alpha + ), + label + ); + dl.PopClipRect(); + } + + dl.AddRect( + origin, + origin + new Vector2(width, height), + ColourUtil.ApplyAlpha(colors.BorderAbgr, alpha), + rounding + ); + + return picked; + } + + internal static Vector2 CalcSize(float width) => new(width, ImGui.GetFrameHeight()); +} diff --git a/HellionChat/Util/MetricsMath.cs b/HellionChat/Util/MetricsMath.cs index afedc0d..8a2e41d 100644 --- a/HellionChat/Util/MetricsMath.cs +++ b/HellionChat/Util/MetricsMath.cs @@ -6,8 +6,11 @@ internal static class MetricsMath { internal static float Scale(float raw, float scale) => raw * scale; - // Vertical centering for text inside a fixed-height row. Frozen offsets keep - // their mis-centering at every other font size; a computed one does not. + // Centering an inner extent inside an outer one. Frozen offsets keep their + // mis-centering at every other font size; a computed one does not. + internal static float Center(float outer, float inner) => (outer - inner) * 0.5f; + + // Vertical centering for text inside a fixed-height row. internal static float CenterY(float rowHeight, float textHeight) => - (rowHeight - textHeight) * 0.5f; + Center(rowHeight, textHeight); } diff --git a/HellionChat/Util/WidgetGeometry.cs b/HellionChat/Util/WidgetGeometry.cs index f74aad7..e01f3ff 100644 --- a/HellionChat/Util/WidgetGeometry.cs +++ b/HellionChat/Util/WidgetGeometry.cs @@ -120,6 +120,28 @@ internal static class WidgetGeometry ); } + // One segment of an n-way control, as a left edge and a width. + // + // Derived from rounded edges rather than by multiplying a per-segment width: + // at width 201 over 2 segments the naive form paints two 100.5px halves that + // both land on the same physical pixel column, leaving a seam in the middle + // and a gap at the right edge. Edges first means segment i always ends + // exactly where segment i+1 begins, and the last one ends on the width. + // + // The one case where that tiling breaks is fewer pixels than segments, where + // the MinExtent clamp wins: a segment nobody can click is worse than a run + // that overshoots a control which is already unreadable at that size. + internal static (float X, float Width) Segment(int index, int count, float width) + { + if (count <= 0) + return (0f, MinExtent); + + var i = Math.Clamp(index, 0, count - 1); + var left = MathF.Round(width * i / count); + var right = MathF.Round(width * (i + 1) / count); + return (left, MathF.Max(MinExtent, right - left)); + } + // Sum of pill widths plus the gap between them. Used by the status bar to // decide whether the right-hand slot still fits, replacing a fixed 200px // guess that never measured the left-hand slots at all. From fa15130468938b9a04867895ed6c9e0f5554704e Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 13:37:43 +0200 Subject: [PATCH 070/129] feat(style): let a setting row render disabled Six settings across the window only apply while another one is on. Today they sit at full contrast and simply do nothing when clicked. BeginDisabled cannot carry this: it pushes an alpha that ImGui applies inside its own widgets, and every part of a setting row that a user reads -- label, description, separator, hover fill -- is draw-list output that never sees it. So the row fades itself, drops its click, and stops tracking hover. The flag is passed through to the control callback as well. A draw-list control handed into the row has exactly the same problem and no other way to learn about it. Note the parameter sits before styleOverride, so the one existing positional call site had to name its argument. --- .../Ui/StyleEngine/Widgets/SettingRow.cs | 29 +++++-- HellionChat/Ui/Windows/WidgetGalleryWindow.cs | 78 ++++++++++++++++++- 2 files changed, 98 insertions(+), 9 deletions(-) diff --git a/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs index d380a2f..32385ba 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs @@ -34,6 +34,10 @@ internal readonly record struct SettingRowContext public float ControlHeight { get; init; } public float HoverAmount { get; init; } + // Passed through so a draw-list control can fade itself. BeginDisabled only + // reaches ImGui's own widgets, so the row cannot dim its control for it. + public bool Disabled { get; init; } + // Right edge of the control column, vertically centred. public Vector2 AlignRight(Vector2 size) => new( @@ -56,10 +60,12 @@ internal static class SettingRow string? description, SettingRowColors colors, Action drawControl, + bool disabled = false, SettingRowStyle? styleOverride = null ) { var style = styleOverride ?? new SettingRowStyle(); + var alpha = disabled ? 0.5f : 1f; var scale = Metrics.Scale; var padY = style.PadY * scale; var gap = style.Gap * scale; @@ -78,11 +84,13 @@ internal static class SettingRow // The label half is the hit area: the control column submits its own // item and would fight with a button underneath it. ImGui.SetCursorScreenPos(origin); - var labelClicked = ImGui.InvisibleButton( - $"##hellion-srow-{id}", - new Vector2(labelWidth, size.Y) - ); - var hovered = ImGui.IsMouseHoveringRect(origin, origin + size) && ImGui.IsWindowHovered(); + var labelClicked = + ImGui.InvisibleButton($"##hellion-srow-{id}", new Vector2(labelWidth, size.Y)) + && !disabled; + var hovered = + !disabled + && ImGui.IsMouseHoveringRect(origin, origin + size) + && ImGui.IsWindowHovered(); var hoverAmount = HoverState.Query(id, hovered); // Chrome first, all of it draw-list only: nothing between here and the @@ -98,7 +106,7 @@ internal static class SettingRow SurfaceHoverAbgr = colors.SurfaceHoverAbgr, SurfaceActiveAbgr = colors.SurfaceHoverAbgr, AccentAbgr = colors.BorderAbgr, - BorderAbgr = colors.BorderAbgr, + BorderAbgr = ColourUtil.ApplyAlpha(colors.BorderAbgr, alpha), }, new RowStyle { AccentBarWidth = 0f, DrawSeparator = style.DrawSeparator } ); @@ -107,13 +115,17 @@ internal static class SettingRow // control. The description wraps instead of being cut. var dl = ImGui.GetWindowDrawList(); dl.PushClipRect(origin, new Vector2(origin.X + labelWidth, origin.Y + size.Y), true); - dl.AddText(new Vector2(origin.X, origin.Y + padY), colors.LabelAbgr, label); + dl.AddText( + new Vector2(origin.X, origin.Y + padY), + ColourUtil.ApplyAlpha(colors.LabelAbgr, alpha), + label + ); if (description is not null) dl.AddText( ImGui.GetFont(), ImGui.GetFontSize(), new Vector2(origin.X, origin.Y + padY + lineHeight), - colors.DescriptionAbgr, + ColourUtil.ApplyAlpha(colors.DescriptionAbgr, alpha), description, labelWidth ); @@ -129,6 +141,7 @@ internal static class SettingRow ControlWidth = controlWidth, ControlHeight = lineHeight, HoverAmount = hoverAmount, + Disabled = disabled, } ); diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index 227ed6a..cb9c9fa 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -48,6 +48,7 @@ internal sealed class WidgetGalleryWindow : Window DrawSectionHeaderSection(c); DrawRowSection(c); DrawToggleSection(c); + DrawSegmentedSection(c); DrawSettingRowSection(c); DrawBadgeSection(c); DrawPillSection(c); @@ -250,9 +251,84 @@ internal sealed class WidgetGalleryWindow : Window null, colors, _ => ImGui.SliderInt("##gallery-sr-d", ref _badgeCount, 0, 150), - new SettingRowStyle { DrawSeparator = false, PreferredControlWidth = 90f } + styleOverride: new SettingRowStyle + { + DrawSeparator = false, + PreferredControlWidth = 90f, + } ); + // Disabled: the label fades, the row stops lighting up, and the click + // does not reach the setting. + using (ImRaii.Disabled()) + { + SettingRow.Draw( + ImGui.GetID("gallery.settingrow.disabled"), + "Disabled row", + "Neither the row nor its control may respond.", + colors, + _ => ImGui.SliderInt("##gallery-sr-e", ref _badgeCount, 0, 150), + disabled: true + ); + } + + ImGui.Spacing(); + } + + // Segment labels are held rather than built per frame: a collection + // expression in the draw call would allocate a fresh array every frame. + private static readonly string[] SegmentLabelsTwo = ["Sidebar", "Top tabs"]; + private static readonly string[] SegmentLabelsThree = ["Off", "Compact", "Full"]; + private int _segmentTwo; + private int _segmentThree = 1; + + private void DrawSegmentedSection(ThemeColors c) + { + var colors = new SegmentedControlColors + { + TrackAbgr = _palette.Abgr(Token.SurfaceBase, c), + SelectedAbgr = _palette.Abgr(Token.AccentPrimary, c), + HoverAbgr = _palette.Abgr(Token.SurfaceHover, c), + LabelAbgr = _palette.Abgr(Token.TextMuted, c), + SelectedLabelAbgr = _palette.Abgr(Token.Text, c), + BorderAbgr = _palette.Abgr(Token.Border, c), + }; + + var width = MathF.Min(260f * Metrics.Scale, ImGui.GetContentRegionAvail().X); + + _segmentTwo = SegmentedControl.Draw( + ImGui.GetID("gallery.segmented.two"), + ImGui.GetCursorScreenPos(), + width, + SegmentLabelsTwo, + _segmentTwo, + colors + ); + ImGui.Dummy(SegmentedControl.CalcSize(width)); + + _segmentThree = SegmentedControl.Draw( + ImGui.GetID("gallery.segmented.three"), + ImGui.GetCursorScreenPos(), + width, + SegmentLabelsThree, + _segmentThree, + colors + ); + ImGui.Dummy(SegmentedControl.CalcSize(width)); + + // Odd width over three segments: the edges must stay flush with no seam + // and no overhang on the right. + SegmentedControl.Draw( + ImGui.GetID("gallery.segmented.odd"), + ImGui.GetCursorScreenPos(), + 201f, + SegmentLabelsThree, + 0, + colors, + disabled: true + ); + ImGui.Dummy(SegmentedControl.CalcSize(201f)); + ImGui.Spacing(); } From f999036e50c35bf7153aba53d3269f540616de92 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 13:42:21 +0200 Subject: [PATCH 071/129] refactor(settings): merge the duplicated tab helpers Six tabs carried a byte-identical DrawToggle, four a byte-identical slider, and five hand-rolled the same enum combo loop. 281 lines out, 82 in. The combos were not only duplicated, they were wasteful: each one called Enum.GetValues inside Draw, so every open settings window allocated five arrays per frame for sets that cannot change at runtime. EnumValues reads them once per closed generic, and the label array is one buffer shared by all of them. Two behaviours are now uniform rather than accidental. The range check on the selected index existed in exactly one of the five and is now in all of them, and the tell auto-open combo lost its inline literal array in favour of a Name extension like its seven peers -- still English, but at least in the place the localisation pass will look. SettingsWidgets is constructed by each tab rather than injected. The tabs are DI singletons and a seventh constructor signature change buys nothing here. One visible difference: the tell auto-open combo was 220px wide against 200 for every other combo in the window. It is 200 now. --- HellionChat/Configuration.cs | 17 +++ .../Ui/Components/Settings/SettingsWidgets.cs | 120 ++++++++++++++++ .../Ui/Components/Settings/Tabs/AboutTab.cs | 14 +- .../Components/Settings/Tabs/ChannelsTab.cs | 73 +++------- .../Ui/Components/Settings/Tabs/ChatTab.cs | 132 ++++-------------- .../Settings/Tabs/DataPrivacyTab.cs | 32 +---- .../Ui/Components/Settings/Tabs/GeneralTab.cs | 34 +---- .../Ui/Components/Settings/Tabs/WindowTab.cs | 84 +++-------- 8 files changed, 214 insertions(+), 292 deletions(-) create mode 100644 HellionChat/Ui/Components/Settings/SettingsWidgets.cs diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index b46cb68..6326650 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -456,6 +456,23 @@ public enum TellAutoOpenMode Popout, } +public static class TellAutoOpenModeExt +{ + // The only display name set still in English. It sat inline in ChannelsTab + // as a literal array, which is why it was missed when the rest moved into + // resources; here it is at least in the same place as its peers for the + // localisation pass to pick up. + public static string Name(this TellAutoOpenMode mode) => + mode switch + { + TellAutoOpenMode.Off => "Off", + TellAutoOpenMode.Sidebar => "Sidebar", + TellAutoOpenMode.TopTab => "Top tab", + TellAutoOpenMode.Popout => "Popout", + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null), + }; +} + [Serializable] public enum MainWindowLayoutMode { diff --git a/HellionChat/Ui/Components/Settings/SettingsWidgets.cs b/HellionChat/Ui/Components/Settings/SettingsWidgets.cs new file mode 100644 index 0000000..d650bed --- /dev/null +++ b/HellionChat/Ui/Components/Settings/SettingsWidgets.cs @@ -0,0 +1,120 @@ +using Dalamud.Bindings.ImGui; + +namespace HellionChat.Ui.Components.Settings; + +// Enum.GetValues allocates a fresh array on every call, and the settings tabs +// were calling it inside Draw -- once per combo, every frame the window is open. +// The set cannot change at runtime, so it is read once per closed generic. +internal static class EnumValues + where T : struct, Enum +{ + internal static readonly T[] All = Enum.GetValues(); +} + +// The four controls every settings tab draws. Six tabs carried a byte-identical +// DrawToggle, four a byte-identical slider, and five hand-rolled the same combo +// loop with different widths. +// +// Deliberately constructed by the tabs rather than injected: the tabs are DI +// singletons, and a new constructor parameter on all seven of them buys nothing +// here beyond a wider blast radius. +internal sealed class SettingsWidgets +{ + private readonly Plugin _plugin; + + // Shared across every combo. ImGui.Combo copies the strings it needs before + // returning, so the buffer is free again by the time the next call runs. + private string[] _labelScratch = new string[8]; + + internal SettingsWidgets(Plugin plugin) + { + _plugin = plugin; + } + + internal void Toggle(string label, Func get, Action set) + { + var current = get(); + if (!ImGui.Checkbox(label, ref current)) + return; + + set(current); + _plugin.SaveConfig(); + } + + internal void SliderFloat( + string label, + Func get, + Action set, + float min, + float max, + float width = 200f + ) + { + var current = get(); + ImGui.SetNextItemWidth(width); + // Sliders report a change every frame while dragging; deferring the write + // to release turns ~30 full-config disk writes per second into one. + if (ImGui.SliderFloat(label, ref current, min, max, "%.2f")) + set(current); + if (ImGui.IsItemDeactivatedAfterEdit()) + _plugin.SaveConfig(); + } + + internal void SliderInt( + string label, + Func get, + Action set, + int min, + int max, + float width = 200f + ) + { + var current = get(); + ImGui.SetNextItemWidth(width); + if (ImGui.SliderInt(label, ref current, min, max, "%d")) + set(current); + if (ImGui.IsItemDeactivatedAfterEdit()) + _plugin.SaveConfig(); + } + + // labelFor is a parameter rather than a constraint because the display names + // live in extension methods, which bind statically and cannot be reached + // through a generic type parameter. + internal void EnumCombo( + string label, + Func get, + Action set, + Func labelFor, + float width = 200f + ) + where T : struct, Enum + { + var values = EnumValues.All; + if (values.Length == 0) + return; + + if (_labelScratch.Length < values.Length) + _labelScratch = new string[values.Length]; + + var current = get(); + var selected = 0; + for (var i = 0; i < values.Length; i++) + { + _labelScratch[i] = labelFor(values[i]); + if (EqualityComparer.Default.Equals(values[i], current)) + selected = i; + } + + ImGui.SetNextItemWidth(width); + if (!ImGui.Combo(label, ref selected, _labelScratch, values.Length)) + return; + + // The scratch buffer can be longer than the value set, so a stale index + // from a previous, larger combo must not reach the setter. + if (selected < 0 || selected >= values.Length) + return; + + set(values[selected]); + _plugin.SaveConfig(); + } +} diff --git a/HellionChat/Ui/Components/Settings/Tabs/AboutTab.cs b/HellionChat/Ui/Components/Settings/Tabs/AboutTab.cs index 647429a..cfce8ef 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/AboutTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/AboutTab.cs @@ -13,6 +13,7 @@ internal sealed class AboutTab { private readonly FontManager _fonts; private readonly Plugin _plugin; + private readonly SettingsWidgets _w; private readonly HonorificService _honorific; private readonly ThemeRegistry _themes; private readonly IPlatformUtil _platformUtil; @@ -30,6 +31,7 @@ internal sealed class AboutTab { _fonts = fonts; _plugin = plugin; + _w = new SettingsWidgets(plugin); _honorific = honorific; _themes = themes; _platformUtil = platformUtil; @@ -103,7 +105,7 @@ internal sealed class AboutTab ImGui.TextUnformatted(HellionStrings.Settings_Integrations_Honorific_SectionHeader); DrawHonorificStatus(); - DrawToggle( + _w.Toggle( HellionStrings.Settings_Integrations_Honorific_Toggle, () => Plugin.Config.ShowHonorificTitleInHeader, v => Plugin.Config.ShowHonorificTitleInHeader = v @@ -222,16 +224,6 @@ internal sealed class AboutTab } } - private void DrawToggle(string label, Func get, Action set) - { - var current = get(); - if (ImGui.Checkbox(label, ref current)) - { - set(current); - _plugin.SaveConfig(); - } - } - // URLs are exclusively hardcoded BrandingLinks/IntegrationLinks constants, // validated to http/https at module-init. OpenLink centralises the browser // open on an off-draw thread (it internally uses the same ShellExecute, so diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs index 38398a8..7148506 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs @@ -5,46 +5,48 @@ namespace HellionChat.Ui.Components.Settings.Tabs; internal sealed class ChannelsTab { private readonly Plugin _plugin; + private readonly SettingsWidgets _w; public ChannelsTab(Plugin plugin) { _plugin = plugin; + _w = new SettingsWidgets(plugin); } public void Draw() { if (ImGui.CollapsingHeader("Tab management", ImGuiTreeNodeFlags.DefaultOpen)) { - DrawToggle( + _w.Toggle( "Enable auto-tell tabs", () => Plugin.Config.EnableAutoTellTabs, v => Plugin.Config.EnableAutoTellTabs = v ); - DrawSliderInt( + _w.SliderInt( "Auto-tell tabs limit", () => Plugin.Config.AutoTellTabsLimit, v => Plugin.Config.AutoTellTabsLimit = v, 1, 50 ); - DrawToggle( + _w.Toggle( "Compact display", () => Plugin.Config.AutoTellTabsCompactDisplay, v => Plugin.Config.AutoTellTabsCompactDisplay = v ); - DrawSliderInt( + _w.SliderInt( "History preload", () => Plugin.Config.AutoTellTabsHistoryPreload, v => Plugin.Config.AutoTellTabsHistoryPreload = v, 0, 200 ); - DrawToggle( + _w.Toggle( "Show greeted toggle", () => Plugin.Config.AutoTellTabsShowGreetedToggle, v => Plugin.Config.AutoTellTabsShowGreetedToggle = v ); - DrawToggle( + _w.Toggle( "Open as popout", () => Plugin.Config.AutoTellTabsOpenAsPopout, v => Plugin.Config.AutoTellTabsOpenAsPopout = v @@ -53,8 +55,13 @@ internal sealed class ChannelsTab if (ImGui.CollapsingHeader("Tell auto-open mode", ImGuiTreeNodeFlags.DefaultOpen)) { - DrawTellAutoOpenModeCombo(); - DrawToggle( + _w.EnumCombo( + "Tell auto-open mode", + () => Plugin.Config.TellAutoOpenMode, + v => Plugin.Config.TellAutoOpenMode = v, + v => v.Name() + ); + _w.Toggle( "Switch to the tab on every tell", () => Plugin.Config.TellAutoOpenSwitchAlways, v => Plugin.Config.TellAutoOpenSwitchAlways = v @@ -67,7 +74,7 @@ internal sealed class ChannelsTab // so the slider cannot drift away from the clamp in Sidebar.GetWidth. // The stored value is unscaled; display scaling is applied where the // sidebar is drawn. - DrawSliderInt( + _w.SliderInt( "Sidebar width", () => Plugin.Config.SidebarWidth, v => Plugin.Config.SidebarWidth = v, @@ -76,52 +83,4 @@ internal sealed class ChannelsTab ); } } - - private void DrawTellAutoOpenModeCombo() - { - var labels = new[] { "Off", "Sidebar", "Top tab", "Popout" }; - var values = Enum.GetValues(); - var current = Plugin.Config.TellAutoOpenMode; - var selected = 0; - for (var i = 0; i < values.Length; i++) - { - if (values[i] == current) - { - selected = i; - break; - } - } - - ImGui.SetNextItemWidth(220); - if (ImGui.Combo("Tell auto-open mode", ref selected, labels, labels.Length)) - { - if (selected >= 0 && selected < values.Length) - { - Plugin.Config.TellAutoOpenMode = values[selected]; - _plugin.SaveConfig(); - } - } - } - - private void DrawToggle(string label, Func get, Action set) - { - var current = get(); - if (ImGui.Checkbox(label, ref current)) - { - set(current); - _plugin.SaveConfig(); - } - } - - private void DrawSliderInt(string label, Func get, Action set, int min, int max) - { - var current = get(); - ImGui.SetNextItemWidth(200); - // Sliders report a change every frame while dragging; deferring the write - // to release turns ~30 full-config disk writes per second into one. - if (ImGui.SliderInt(label, ref current, min, max, "%d")) - set(current); - if (ImGui.IsItemDeactivatedAfterEdit()) - _plugin.SaveConfig(); - } } diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs index 68fdf4e..f46d551 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs @@ -7,53 +7,72 @@ namespace HellionChat.Ui.Components.Settings.Tabs; internal sealed class ChatTab { private readonly Plugin _plugin; + private readonly SettingsWidgets _w; public ChatTab(Plugin plugin) { _plugin = plugin; + _w = new SettingsWidgets(plugin); } public void Draw() { if (ImGui.CollapsingHeader("Display modes", ImGuiTreeNodeFlags.DefaultOpen)) { - DrawToggle( + _w.Toggle( "Compact density (card vs compact)", () => Plugin.Config.UseCompactDensity, v => Plugin.Config.UseCompactDensity = v ); - DrawToggle( + _w.Toggle( "More compact pretty mode", () => Plugin.Config.MoreCompactPretty, v => Plugin.Config.MoreCompactPretty = v ); - DrawToggle( + _w.Toggle( "Prettier timestamps", () => Plugin.Config.PrettierTimestamps, v => Plugin.Config.PrettierTimestamps = v ); - DrawToggle( + _w.Toggle( "Hide same timestamps", () => Plugin.Config.HideSameTimestamps, v => Plugin.Config.HideSameTimestamps = v ); - DrawToggle( + _w.Toggle( "24-hour clock", () => Plugin.Config.Use24HourClock, v => Plugin.Config.Use24HourClock = v ); - DrawWorldSuffixCombo(); - DrawNameFormCombo(); + _w.EnumCombo( + HellionStrings.Settings_Chat_WorldSuffix_Name, + () => Plugin.Config.WorldSuffixMode, + v => Plugin.Config.WorldSuffixMode = v, + v => v.Name() + ); + ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_WorldSuffix_Description); + _w.EnumCombo( + HellionStrings.Settings_Chat_NameForm_Name, + () => Plugin.Config.NameFormMode, + v => Plugin.Config.NameFormMode = v, + v => v.Name() + ); + ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_NameForm_Description); } if (ImGui.CollapsingHeader("Command help")) { - DrawCommandHelpSideCombo(); + _w.EnumCombo( + "Command help side", + () => Plugin.Config.CommandHelpSide, + v => Plugin.Config.CommandHelpSide = v, + v => v.Name() + ); } if (ImGui.CollapsingHeader("Plugin disclosure")) { - DrawToggle( + _w.Toggle( HellionStrings.Settings_Chat_NotifyPluginDisclosure_Name, () => Plugin.Config.NotifyPluginDisclosure, v => Plugin.Config.NotifyPluginDisclosure = v @@ -61,99 +80,4 @@ internal sealed class ChatTab ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_NotifyPluginDisclosure_Description); } } - - private void DrawCommandHelpSideCombo() - { - var current = Plugin.Config.CommandHelpSide; - var values = Enum.GetValues(); - var labels = new string[values.Length]; - var selected = 0; - for (var i = 0; i < values.Length; i++) - { - labels[i] = values[i].Name(); - if (values[i] == current) - { - selected = i; - } - } - - ImGui.SetNextItemWidth(200); - if (ImGui.Combo("Command help side", ref selected, labels, labels.Length)) - { - Plugin.Config.CommandHelpSide = values[selected]; - _plugin.SaveConfig(); - } - } - - private void DrawWorldSuffixCombo() - { - var current = Plugin.Config.WorldSuffixMode; - var values = Enum.GetValues(); - var labels = new string[values.Length]; - var selected = 0; - for (var i = 0; i < values.Length; i++) - { - labels[i] = values[i].Name(); - if (values[i] == current) - { - selected = i; - } - } - - ImGui.SetNextItemWidth(200); - if ( - ImGui.Combo( - HellionStrings.Settings_Chat_WorldSuffix_Name, - ref selected, - labels, - labels.Length - ) - ) - { - Plugin.Config.WorldSuffixMode = values[selected]; - _plugin.SaveConfig(); - } - ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_WorldSuffix_Description); - } - - private void DrawNameFormCombo() - { - var current = Plugin.Config.NameFormMode; - var values = Enum.GetValues(); - var labels = new string[values.Length]; - var selected = 0; - for (var i = 0; i < values.Length; i++) - { - labels[i] = values[i].Name(); - if (values[i] == current) - { - selected = i; - } - } - - ImGui.SetNextItemWidth(200); - if ( - ImGui.Combo( - HellionStrings.Settings_Chat_NameForm_Name, - ref selected, - labels, - labels.Length - ) - ) - { - Plugin.Config.NameFormMode = values[selected]; - _plugin.SaveConfig(); - } - ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_NameForm_Description); - } - - private void DrawToggle(string label, Func get, Action set) - { - var current = get(); - if (ImGui.Checkbox(label, ref current)) - { - set(current); - _plugin.SaveConfig(); - } - } } diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs index 6e83f84..c8713a7 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -6,22 +6,24 @@ namespace HellionChat.Ui.Components.Settings.Tabs; internal sealed class DataPrivacyTab { private readonly Plugin _plugin; + private readonly SettingsWidgets _w; public DataPrivacyTab(Plugin plugin) { _plugin = plugin; + _w = new SettingsWidgets(plugin); } public void Draw() { if (ImGui.CollapsingHeader("Logging", ImGuiTreeNodeFlags.DefaultOpen)) { - DrawToggle( + _w.Toggle( "Enable retention sweep", () => Plugin.Config.RetentionEnabled, v => Plugin.Config.RetentionEnabled = v ); - DrawSliderInt( + _w.SliderInt( "Default retention (days)", () => Plugin.Config.RetentionDefaultDays, v => Plugin.Config.RetentionDefaultDays = v, @@ -43,13 +45,13 @@ internal sealed class DataPrivacyTab if (ImGui.CollapsingHeader("Privacy filter", ImGuiTreeNodeFlags.DefaultOpen)) { - DrawToggle( + _w.Toggle( "Enable privacy filter", () => Plugin.Config.PrivacyFilterEnabled, v => Plugin.Config.PrivacyFilterEnabled = v ); DrawPrivacyPersistChannelsGrid(); - DrawToggle( + _w.Toggle( "Persist unknown channels", () => Plugin.Config.PrivacyPersistUnknownChannels, v => Plugin.Config.PrivacyPersistUnknownChannels = v @@ -93,26 +95,4 @@ internal sealed class DataPrivacyTab } } } - - private void DrawToggle(string label, Func get, Action set) - { - var current = get(); - if (ImGui.Checkbox(label, ref current)) - { - set(current); - _plugin.SaveConfig(); - } - } - - private void DrawSliderInt(string label, Func get, Action set, int min, int max) - { - var current = get(); - ImGui.SetNextItemWidth(200); - // Sliders report a change every frame while dragging; deferring the write - // to release turns ~30 full-config disk writes per second into one. - if (ImGui.SliderInt(label, ref current, min, max, "%d")) - set(current); - if (ImGui.IsItemDeactivatedAfterEdit()) - _plugin.SaveConfig(); - } } diff --git a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs index 36b15de..4387b5f 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs @@ -7,6 +7,7 @@ namespace HellionChat.Ui.Components.Settings.Tabs; internal sealed class GeneralTab { private readonly Plugin _plugin; + private readonly SettingsWidgets _w; private readonly FontManager _fonts; // Sorted once: the 25 endonyms are fixed literals, so the order never @@ -18,6 +19,7 @@ internal sealed class GeneralTab public GeneralTab(Plugin plugin, FontManager fonts) { _plugin = plugin; + _w = new SettingsWidgets(plugin); _fonts = fonts; } @@ -33,17 +35,17 @@ internal sealed class GeneralTab { if (ImGui.CollapsingHeader("Behavior", ImGuiTreeNodeFlags.DefaultOpen)) { - DrawToggle( + _w.Toggle( "Reduce motion (no theme crossfade)", () => Plugin.Config.ReduceMotion, v => Plugin.Config.ReduceMotion = v ); - DrawToggle( + _w.Toggle( "Print changelog on update", () => Plugin.Config.PrintChangelog, v => Plugin.Config.PrintChangelog = v ); - DrawToggle( + _w.Toggle( "Show novice network", () => Plugin.Config.ShowNoviceNetwork, v => Plugin.Config.ShowNoviceNetwork = v @@ -71,7 +73,7 @@ internal sealed class GeneralTab if (ImGui.CollapsingHeader("Notifications", ImGuiTreeNodeFlags.DefaultOpen)) { - DrawToggle( + _w.Toggle( Language.Options_PlaySounds_Name, () => Plugin.Config.PlaySounds, v => Plugin.Config.PlaySounds = v @@ -81,7 +83,7 @@ internal sealed class GeneralTab if (ImGui.CollapsingHeader("Volumes", ImGuiTreeNodeFlags.DefaultOpen)) { - DrawSlider( + _w.SliderFloat( "Custom sound volume", () => Plugin.Config.CustomSoundVolume, v => Plugin.Config.CustomSoundVolume = v, @@ -177,28 +179,6 @@ internal sealed class GeneralTab ImGuiUtil.HelpMarker(tip); } - private void DrawToggle(string label, Func get, Action set) - { - var current = get(); - if (ImGui.Checkbox(label, ref current)) - { - set(current); - _plugin.SaveConfig(); - } - } - - private void DrawSlider(string label, Func get, Action set, float min, float max) - { - var current = get(); - ImGui.SetNextItemWidth(200); - // Sliders report a change every frame while dragging; deferring the write - // to release turns ~30 full-config disk writes per second into one. - if (ImGui.SliderFloat(label, ref current, min, max, "%.2f")) - set(current); - if (ImGui.IsItemDeactivatedAfterEdit()) - _plugin.SaveConfig(); - } - // Wires the already-present ImGuiUtil.KeybindInput capture widget (dead/unwired // since the v1.6.0 rewrite) back into the settings, so ChatTabForward/Backward // are bindable again. ConfigKeyBind is a reference type, so a capture (new diff --git a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs index 19806b2..92391ca 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs @@ -5,10 +5,12 @@ namespace HellionChat.Ui.Components.Settings.Tabs; internal sealed class WindowTab { private readonly Plugin _plugin; + private readonly SettingsWidgets _w; public WindowTab(Plugin plugin) { _plugin = plugin; + _w = new SettingsWidgets(plugin); } public void Draw() @@ -30,17 +32,17 @@ internal sealed class WindowTab if (ImGui.CollapsingHeader("Window style", ImGuiTreeNodeFlags.DefaultOpen)) { - DrawToggle( + _w.Toggle( "Show title bar", () => Plugin.Config.ShowTitleBar, v => Plugin.Config.ShowTitleBar = v ); - DrawToggle( + _w.Toggle( "Show title bar for pop-outs", () => Plugin.Config.ShowPopOutTitleBar, v => Plugin.Config.ShowPopOutTitleBar = v ); - DrawToggle( + _w.Toggle( "Show hide button", () => Plugin.Config.ShowHideButton, v => Plugin.Config.ShowHideButton = v @@ -49,14 +51,14 @@ internal sealed class WindowTab if (ImGui.CollapsingHeader("Opacity", ImGuiTreeNodeFlags.DefaultOpen)) { - DrawSlider( + _w.SliderFloat( "Window opacity", () => Plugin.Config.WindowOpacity, v => Plugin.Config.WindowOpacity = v, 0.1f, 1f ); - DrawSlider( + _w.SliderFloat( "Inactive opacity", () => Plugin.Config.WindowOpacityInactive, v => Plugin.Config.WindowOpacityInactive = v, @@ -67,17 +69,17 @@ internal sealed class WindowTab if (ImGui.CollapsingHeader("Resize behavior", ImGuiTreeNodeFlags.DefaultOpen)) { - DrawToggle( + _w.Toggle( "Allow movement", () => Plugin.Config.CanMove, v => Plugin.Config.CanMove = v ); - DrawToggle( + _w.Toggle( "Allow resize", () => Plugin.Config.CanResize, v => Plugin.Config.CanResize = v ); - DrawSliderInt( + _w.SliderInt( "Sidebar auto-switch threshold (px)", () => Plugin.Config.SidebarAutoSwitchThresholdPx, v => Plugin.Config.SidebarAutoSwitchThresholdPx = v, @@ -88,69 +90,17 @@ internal sealed class WindowTab if (ImGui.CollapsingHeader("Input preview")) { - DrawPreviewPositionCombo(); - DrawToggle( + _w.EnumCombo( + "Preview position", + () => Plugin.Config.PreviewPosition, + v => Plugin.Config.PreviewPosition = v, + v => v.Name() + ); + _w.Toggle( "Only show preview when typing", () => Plugin.Config.OnlyPreviewIf, v => Plugin.Config.OnlyPreviewIf = v ); } } - - private void DrawPreviewPositionCombo() - { - var current = Plugin.Config.PreviewPosition; - var values = Enum.GetValues(); - var labels = new string[values.Length]; - var selected = 0; - for (var i = 0; i < values.Length; i++) - { - labels[i] = values[i].Name(); - if (values[i] == current) - { - selected = i; - } - } - - ImGui.SetNextItemWidth(200); - if (ImGui.Combo("Preview position", ref selected, labels, labels.Length)) - { - Plugin.Config.PreviewPosition = values[selected]; - _plugin.SaveConfig(); - } - } - - private void DrawToggle(string label, Func get, Action set) - { - var current = get(); - if (ImGui.Checkbox(label, ref current)) - { - set(current); - _plugin.SaveConfig(); - } - } - - private void DrawSlider(string label, Func get, Action set, float min, float max) - { - var current = get(); - ImGui.SetNextItemWidth(200); - // Sliders report a change every frame while dragging; deferring the write - // to release turns ~30 full-config disk writes per second into one. - if (ImGui.SliderFloat(label, ref current, min, max, "%.2f")) - set(current); - if (ImGui.IsItemDeactivatedAfterEdit()) - _plugin.SaveConfig(); - } - - private void DrawSliderInt(string label, Func get, Action set, int min, int max) - { - var current = get(); - ImGui.SetNextItemWidth(200); - // Sliders report a change every frame while dragging; deferring the write - // to release turns ~30 full-config disk writes per second into one. - if (ImGui.SliderInt(label, ref current, min, max, "%d")) - set(current); - if (ImGui.IsItemDeactivatedAfterEdit()) - _plugin.SaveConfig(); - } } From 1c78a84b9b9fc351b74af39a23f18f0ad4e53c8d Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 13:53:51 +0200 Subject: [PATCH 072/129] fix(style): reserve the height a wrapped description actually needs Both widgets drew their description with a wrap width and then sized the row as if it were always one line. Same bug, two different symptoms. A setting row clips to its label column, so the second line was simply cut off, directly under a comment claiming the text wraps rather than being cut. A section header has no clip rect at all, so the overflow was drawn over its own border line and into whatever the caller rendered next. Both now measure with CalcTextSize against the same wrap width they draw with. The section header also gained the clip rect it never had, and its lead offset is computed once instead of twice, so the measured width and the drawn width cannot drift apart. Third fix, same family: the segmented control relied on its last InvisibleButton to leave the cursor in the right place. It happened to work, because the loop re-pins the cursor before every segment, but a caller cannot see that from the outside -- and the gallery duly reserved the row a second time with Dummy, doubling the gap after every segmented control. The widget now owns its advance explicitly, like the other two, and CalcSize is gone with its only caller. --- .../Ui/StyleEngine/Widgets/SectionHeader.cs | 19 ++++++++++++++++--- .../StyleEngine/Widgets/SegmentedControl.cs | 11 +++++++++-- .../Ui/StyleEngine/Widgets/SettingRow.cs | 13 +++++++++++-- HellionChat/Ui/Windows/WidgetGalleryWindow.cs | 3 --- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs index 3aac2b6..f456543 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs @@ -60,7 +60,16 @@ internal static class SectionHeader var origin = ImGui.GetCursorScreenPos(); var width = ImGui.GetContentRegionAvail().X; var titleHeight = ImGui.GetTextLineHeight(); - var descHeight = description is null ? 0f : titleHeight; + + // The description starts after the chevron, so its wrap width is the + // remainder of the row. Measuring it matters more here than in a setting + // row: nothing clips this text, so an unreserved second line would draw + // straight over the border line and whatever the caller renders next. + var leadWidth = (style.AccentBarWidth + style.ChevronInset * 2f) * scale; + var descWrap = width - leadWidth; + var descHeight = description is null + ? 0f + : ImGui.CalcTextSize(description, false, descWrap).Y; var size = WidgetGeometry.SectionHeader(width, titleHeight, descHeight, padY, 1f * scale); ImGui.SetCursorScreenPos(origin); @@ -95,7 +104,7 @@ internal static class SectionHeader ColourUtil.ApplyAlpha(colors.AccentAbgr, alpha) ); - var textX = origin.X + barWidth + style.ChevronInset * scale * 2f; + var textX = origin.X + leadWidth; dl.AddText( new Vector2(textX, origin.Y + padY), ColourUtil.ApplyAlpha(colors.TitleAbgr, alpha), @@ -114,14 +123,18 @@ internal static class SectionHeader ); if (description is not null) + { + dl.PushClipRect(origin, max, true); dl.AddText( ImGui.GetFont(), ImGui.GetFontSize(), new Vector2(textX, origin.Y + padY + titleHeight), ColourUtil.ApplyAlpha(colors.DescriptionAbgr, alpha), description, - width - (textX - origin.X) + descWrap ); + dl.PopClipRect(); + } dl.AddLine( new Vector2(origin.X, max.Y - 1f * scale), diff --git a/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs index f04049a..1735b62 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs @@ -127,8 +127,15 @@ internal static class SegmentedControl rounding ); + // Explicit, rather than inheriting whatever the last InvisibleButton left + // behind. The loop re-pins the cursor before every segment, so the run + // happens to end in the right place today -- but that is a side effect, + // and a caller cannot tell from here whether it still needs to reserve + // the row itself. Same contract as SettingRow and SectionHeader now: + // the widget owns its own advance. + ImGui.SetCursorScreenPos(origin); + ImGuiP.ItemSize(new Vector2(width, height)); + return picked; } - - internal static Vector2 CalcSize(float width) => new(width, ImGui.GetFrameHeight()); } diff --git a/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs index 32385ba..71cdf75 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs @@ -73,14 +73,23 @@ internal static class SettingRow var origin = ImGui.GetCursorScreenPos(); var width = ImGui.GetContentRegionAvail().X; var lineHeight = ImGui.GetFrameHeight(); - var descHeight = description is null ? 0f : ImGui.GetTextLineHeight(); - var size = WidgetGeometry.SettingRow(width, lineHeight, descHeight, padY); + + // Split first: the description is drawn with the label column as its + // wrap width, so its height cannot be known before that width is. var (labelWidth, controlX, controlWidth) = WidgetGeometry.SettingRowSplit( width, style.PreferredControlWidth * scale, gap ); + // Measured, not assumed to be one line. AddText wraps at labelWidth, so + // a description long enough to need a second line used to be drawn into + // height the row never reserved, and the clip rect below cut it off. + var descHeight = description is null + ? 0f + : ImGui.CalcTextSize(description, false, labelWidth).Y; + var size = WidgetGeometry.SettingRow(width, lineHeight, descHeight, padY); + // The label half is the hit area: the control column submits its own // item and would fight with a button underneath it. ImGui.SetCursorScreenPos(origin); diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index cb9c9fa..7695600 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -304,7 +304,6 @@ internal sealed class WidgetGalleryWindow : Window _segmentTwo, colors ); - ImGui.Dummy(SegmentedControl.CalcSize(width)); _segmentThree = SegmentedControl.Draw( ImGui.GetID("gallery.segmented.three"), @@ -314,7 +313,6 @@ internal sealed class WidgetGalleryWindow : Window _segmentThree, colors ); - ImGui.Dummy(SegmentedControl.CalcSize(width)); // Odd width over three segments: the edges must stay flush with no seam // and no overhang on the right. @@ -327,7 +325,6 @@ internal sealed class WidgetGalleryWindow : Window colors, disabled: true ); - ImGui.Dummy(SegmentedControl.CalcSize(201f)); ImGui.Spacing(); } From c5a0c4d0d717af44db07ae121fa4b1fb1277d66d Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 13:54:01 +0200 Subject: [PATCH 073/129] fix(settings): show the translations the channels tab already had Twenty resource keys existed, were translated into all 25 languages, and were reachable from no line of code. The tab drew hardcoded English literals instead, so a German player read "Enable auto-tell tabs" while the German string sat in the plugin unused. This is the same defect as the three unreachable settings earlier in this cycle, one level down: the work was done, the wiring was not. The literals were also worse than the strings they shadowed. "Enable auto-tell tabs" against "Automatically open a tab per conversation partner for every /tell", and every setting had a written description that had never been shown at all, so nothing in the section explained what it did. Two of those are worth more than the labels. The conflict hint names the one setting in another plugin that silently stops auto-tell tabs from ever opening, which is not something a user works out alone; it is on screen now. The sidebar width description explains what the 44px default actually means. The keys carry stale prefixes from the old eight-tab layout -- the preload one still says Privacy_ though the setting lives here. Renaming them would touch 25 files per key, so they keep their names and the strings go on screen now. --- .../Components/Settings/Tabs/ChannelsTab.cs | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs index 7148506..aeb6edb 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs @@ -1,4 +1,6 @@ using Dalamud.Bindings.ImGui; +using HellionChat.Resources; +using HellionChat.Util; namespace HellionChat.Ui.Components.Settings.Tabs; @@ -15,42 +17,64 @@ internal sealed class ChannelsTab public void Draw() { - if (ImGui.CollapsingHeader("Tab management", ImGuiTreeNodeFlags.DefaultOpen)) + if ( + ImGui.CollapsingHeader( + HellionStrings.Settings_Section_AutoTellTabs, + ImGuiTreeNodeFlags.DefaultOpen + ) + ) { _w.Toggle( - "Enable auto-tell tabs", + HellionStrings.ChatLog_AutoTellTabs_Enable_Name, () => Plugin.Config.EnableAutoTellTabs, v => Plugin.Config.EnableAutoTellTabs = v ); + ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_Enable_Description); + _w.SliderInt( - "Auto-tell tabs limit", + HellionStrings.ChatLog_AutoTellTabs_Limit_Name, () => Plugin.Config.AutoTellTabsLimit, v => Plugin.Config.AutoTellTabsLimit = v, 1, 50 ); + ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_Limit_Description); + _w.Toggle( - "Compact display", + HellionStrings.ChatLog_AutoTellTabs_Compact_Name, () => Plugin.Config.AutoTellTabsCompactDisplay, v => Plugin.Config.AutoTellTabsCompactDisplay = v ); + ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_Compact_Description); + _w.SliderInt( - "History preload", + HellionStrings.Privacy_AutoTellTabs_Preload_Name, () => Plugin.Config.AutoTellTabsHistoryPreload, v => Plugin.Config.AutoTellTabsHistoryPreload = v, 0, 200 ); + ImGuiUtil.HelpMarker(HellionStrings.Privacy_AutoTellTabs_Preload_Description); + _w.Toggle( - "Show greeted toggle", + HellionStrings.ChatLog_AutoTellTabs_GreetedToggle_Name, () => Plugin.Config.AutoTellTabsShowGreetedToggle, v => Plugin.Config.AutoTellTabsShowGreetedToggle = v ); + ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_GreetedToggle_Description); + _w.Toggle( - "Open as popout", + HellionStrings.ChatLog_AutoTellTabs_OpenAsPopout_Name, () => Plugin.Config.AutoTellTabsOpenAsPopout, v => Plugin.Config.AutoTellTabsOpenAsPopout = v ); + ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_OpenAsPopout_Description); + + // Written for this screen and never shown until now. It names the + // one setting in a third-party plugin that silently stops auto-tell + // tabs from ever opening, which is not something a user guesses. + ImGui.Spacing(); + ImGui.TextWrapped(HellionStrings.ChatLog_AutoTellTabs_ConflictHint); } if (ImGui.CollapsingHeader("Tell auto-open mode", ImGuiTreeNodeFlags.DefaultOpen)) @@ -75,12 +99,13 @@ internal sealed class ChannelsTab // The stored value is unscaled; display scaling is applied where the // sidebar is drawn. _w.SliderInt( - "Sidebar width", + HellionStrings.Settings_ThemeAndLayout_SidebarWidth_Name, () => Plugin.Config.SidebarWidth, v => Plugin.Config.SidebarWidth = v, (int)Sidebar.MinSidebarWidth, (int)Sidebar.MaxSidebarWidth ); + ImGuiUtil.HelpMarker(HellionStrings.Settings_ThemeAndLayout_SidebarWidth_Description); } } } From 0c3023c8cb45954329fc827a97945a85082e38e2 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 14:11:31 +0200 Subject: [PATCH 074/129] fix(settings): stop enum combos listing blank rows The shared label buffer was handed to ImGui.Combo whole, with the value count as the fourth argument. That argument is not the item count -- it is popupMaxHeightInItems. The count comes from the span's own length, which was always 8. So every enum combo listed eight rows. A three-value setting showed five blank ones below its real entries, and the popup was capped at three rows high, so it scrolled. Clicking a blank row hit the range guard and silently did nothing. Five combos were affected: world suffix, name form, command help side, preview position and tell auto-open mode. The per-tab code this replaced sized its array to the value count, so the count happened to be right; sharing one buffer is what exposed the misread parameter. Slicing the buffer fixes both halves at once: the span carries the real count and popupMaxHeightInItems falls back to its -1 default. Also moves Lerp below LerpTowardWhite. It was inserted directly under that method's comment block, TEST-MIRROR line included, so the documentation sat on the wrong method. --- .../Ui/Components/Settings/SettingsWidgets.cs | 9 ++++-- HellionChat/Util/ColourUtil.cs | 30 +++++++++---------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/HellionChat/Ui/Components/Settings/SettingsWidgets.cs b/HellionChat/Ui/Components/Settings/SettingsWidgets.cs index d650bed..6905266 100644 --- a/HellionChat/Ui/Components/Settings/SettingsWidgets.cs +++ b/HellionChat/Ui/Components/Settings/SettingsWidgets.cs @@ -24,6 +24,7 @@ internal sealed class SettingsWidgets // Shared across every combo. ImGui.Combo copies the strings it needs before // returning, so the buffer is free again by the time the next call runs. + // Always sliced to the value count when passed on -- see EnumCombo. private string[] _labelScratch = new string[8]; internal SettingsWidgets(Plugin plugin) @@ -105,12 +106,14 @@ internal sealed class SettingsWidgets selected = i; } + // Sliced, not passed whole with a count. The binding's fourth parameter + // is popupMaxHeightInItems, not the item count -- that comes from the + // span's own length. Handing over the full buffer would list all eight + // slots, so a three-value enum would show five blank rows. ImGui.SetNextItemWidth(width); - if (!ImGui.Combo(label, ref selected, _labelScratch, values.Length)) + if (!ImGui.Combo(label, ref selected, _labelScratch.AsSpan(0, values.Length))) return; - // The scratch buffer can be longer than the value set, so a stale index - // from a previous, larger combo must not reach the setter. if (selected < 0 || selected >= values.Length) return; diff --git a/HellionChat/Util/ColourUtil.cs b/HellionChat/Util/ColourUtil.cs index 8a2a89a..9dee87d 100755 --- a/HellionChat/Util/ColourUtil.cs +++ b/HellionChat/Util/ColourUtil.cs @@ -108,21 +108,6 @@ internal static class ColourUtil // going fully saturated (effect level stays "subtle"). RGB-only on // purpose -- DrawHoverSheen owns the alpha falloff. // TEST-MIRROR: ../../../Hellion Build test/Util/ColourUtilTintTests.cs - // Mixes two ABGR colours channel by channel, alpha included. Used where a - // widget crossfades between two theme slots rather than toward a constant. - internal static uint Lerp(uint fromAbgr, uint toAbgr, float t) - { - t = Math.Clamp(t, 0f, 1f); - uint Mix(int shift) - { - var a = (byte)((fromAbgr >> shift) & 0xFFu); - var b = (byte)((toAbgr >> shift) & 0xFFu); - return (uint)Math.Round(a + (b - a) * t) & 0xFFu; - } - - return (Mix(24) << 24) | (Mix(16) << 16) | (Mix(8) << 8) | Mix(0); - } - internal static uint LerpTowardWhite(uint abgr, float t) { t = Math.Clamp(t, 0f, 1f); @@ -138,6 +123,21 @@ internal static class ColourUtil return ((uint)a << 24) | ((uint)nb << 16) | ((uint)ng << 8) | nr; } + // Mixes two ABGR colours channel by channel, alpha included. Used where a + // widget crossfades between two theme slots rather than toward a constant. + internal static uint Lerp(uint fromAbgr, uint toAbgr, float t) + { + t = Math.Clamp(t, 0f, 1f); + uint Mix(int shift) + { + var a = (byte)((fromAbgr >> shift) & 0xFFu); + var b = (byte)((toAbgr >> shift) & 0xFFu); + return (uint)Math.Round(a + (b - a) * t) & 0xFFu; + } + + return (Mix(24) << 24) | (Mix(16) << 16) | (Mix(8) << 8) | Mix(0); + } + public static uint HexToRgba(string hex) { ArgumentNullException.ThrowIfNull(hex); From 64db8986e6e3dc298c3e088aae7a10077285809b Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 14:38:42 +0200 Subject: [PATCH 075/129] fix(style): close the remaining unreserved-space cases Three more instances of the pattern the previous fix only half caught. The section header clipped its description and not its title, so a translated heading wider than the pane ran out over the scrollbar. Only the window clip rect stopped it, not the content width. Row's separator used an unscaled 1px offset with a scaled stroke. ImGui strokes centred on the path, so at UI scale 2 half the line sits below the rectangle the row reserved -- and rows stack flush, so it landed in the first pixel row of the next one. Offset now derives from the thickness. The segmented control claimed in its own comment to share the setting row's contract. It did not. It took a position as a parameter and then re-pinned the layout cursor to it, which moves a caller's layout whenever that position was not the cursor, and it advanced by the full height where the other two subtract ItemSpacing.Y. It now reads the cursor like they do, and the comment says why the advance still differs: those two stack flush because they are list entries, this is a single control and takes the normal gap. Also: the label centres against the control band when it has no description below it, since a slider draws its text at FramePadding.Y and a top-aligned label sits visibly high next to it. Gallery arrays are held rather than rebuilt per frame, which its own comment already demanded two sections earlier. And SectionHeader.Reset is gone -- no caller, and section state keys off ids rather than labels, so nothing needs to clear it on a language change. --- HellionChat/Ui/StyleEngine/Widgets/Row.cs | 14 ++++++++--- .../Ui/StyleEngine/Widgets/SectionHeader.cs | 4 ++-- .../StyleEngine/Widgets/SegmentedControl.cs | 18 ++++++++++----- .../Ui/StyleEngine/Widgets/SettingRow.cs | 9 +++++++- HellionChat/Ui/Windows/WidgetGalleryWindow.cs | 23 +++++++++++-------- 5 files changed, 46 insertions(+), 22 deletions(-) diff --git a/HellionChat/Ui/StyleEngine/Widgets/Row.cs b/HellionChat/Ui/StyleEngine/Widgets/Row.cs index 42cebde..f80cd93 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/Row.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/Row.cs @@ -67,11 +67,19 @@ internal static class Row } if (style.DrawSeparator) + { + // Offset scales with the thickness. ImGui strokes centred on the + // path, so an unscaled 1px offset with a scaled stroke puts half the + // line below max.Y -- and rows stack flush, so that half lands in the + // first pixel row of the next one. + var thickness = Metrics.Scale; + var y = max.Y - thickness * 0.5f; dl.AddLine( - new Vector2(origin.X, max.Y - 1f), - new Vector2(max.X, max.Y - 1f), + new Vector2(origin.X, y), + new Vector2(max.X, y), state.BorderAbgr, - Metrics.Scale + thickness ); + } } } diff --git a/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs index f456543..78c1ffd 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs @@ -35,8 +35,6 @@ internal static class SectionHeader { private static readonly Dictionary Open = []; - internal static void Reset() => Open.Clear(); - internal static bool Draw( uint key, string title, @@ -105,11 +103,13 @@ internal static class SectionHeader ); var textX = origin.X + leadWidth; + dl.PushClipRect(origin, max, true); dl.AddText( new Vector2(textX, origin.Y + padY), ColourUtil.ApplyAlpha(colors.TitleAbgr, alpha), title ); + dl.PopClipRect(); DrawChevron( dl, diff --git a/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs index 1735b62..4fc5b63 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs @@ -38,9 +38,13 @@ internal static class SegmentedControl private static uint AnimKey(uint id, int index) => (id ^ (uint)(index + 1) * 0x85EBCA6Bu) * 2654435761u + 0x9E3779B9u; + // Reads the cursor rather than taking a position, like SettingRow and + // SectionHeader. It cannot avoid reserving space -- every segment submits an + // InvisibleButton, and those always advance -- so taking an origin would let + // a caller place it somewhere the reservation does not match. + // To right-align it inside a setting row, set the cursor before calling. internal static int Draw( uint id, - Vector2 origin, float width, ReadOnlySpan labels, int selected, @@ -52,6 +56,7 @@ internal static class SegmentedControl if (labels.Length == 0) return selected; + var origin = ImGui.GetCursorScreenPos(); var style = styleOverride ?? new SegmentedControlStyle(); var scale = Metrics.Scale; var rounding = style.Rounding * scale; @@ -128,11 +133,12 @@ internal static class SegmentedControl ); // Explicit, rather than inheriting whatever the last InvisibleButton left - // behind. The loop re-pins the cursor before every segment, so the run - // happens to end in the right place today -- but that is a side effect, - // and a caller cannot tell from here whether it still needs to reserve - // the row itself. Same contract as SettingRow and SectionHeader now: - // the widget owns its own advance. + // behind: the loop re-pins the cursor before every segment, so the run + // only ends in the right place as a side effect. + // + // The full height, unlike SettingRow and SectionHeader, which subtract + // ItemSpacing.Y. Those two stack flush on purpose because they are list + // entries. This is a single control and takes the normal gap. ImGui.SetCursorScreenPos(origin); ImGuiP.ItemSize(new Vector2(width, height)); diff --git a/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs index 71cdf75..6f4d1b3 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs @@ -124,8 +124,15 @@ internal static class SettingRow // control. The description wraps instead of being cut. var dl = ImGui.GetWindowDrawList(); dl.PushClipRect(origin, new Vector2(origin.X + labelWidth, origin.Y + size.Y), true); + // Without a description the label is alone next to the control, so it + // centres against the control band. A slider draws its text at + // FramePadding.Y, so a top-aligned label sits visibly high next to it. + // With a description the pair is top-aligned and this must not apply. + var labelY = description is null + ? origin.Y + padY + MetricsMath.Center(lineHeight, ImGui.GetTextLineHeight()) + : origin.Y + padY; dl.AddText( - new Vector2(origin.X, origin.Y + padY), + new Vector2(origin.X, labelY), ColourUtil.ApplyAlpha(colors.LabelAbgr, alpha), label ); diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index 7695600..35ad0f0 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -282,6 +282,16 @@ internal sealed class WidgetGalleryWindow : Window private int _segmentTwo; private int _segmentThree = 1; + // Held, not rebuilt per frame: a collection expression inside Draw + // allocates a fresh array on every frame the window is open. + private readonly int[] _badgeSamples = [1, 9, 0, 120]; + private static readonly FontAwesomeIcon[] GalleryGlyphs = + [ + FontAwesomeIcon.ArrowUpRightFromSquare, + FontAwesomeIcon.Check, + FontAwesomeIcon.CheckCircle, + ]; + private void DrawSegmentedSection(ThemeColors c) { var colors = new SegmentedControlColors @@ -298,7 +308,6 @@ internal sealed class WidgetGalleryWindow : Window _segmentTwo = SegmentedControl.Draw( ImGui.GetID("gallery.segmented.two"), - ImGui.GetCursorScreenPos(), width, SegmentLabelsTwo, _segmentTwo, @@ -307,7 +316,6 @@ internal sealed class WidgetGalleryWindow : Window _segmentThree = SegmentedControl.Draw( ImGui.GetID("gallery.segmented.three"), - ImGui.GetCursorScreenPos(), width, SegmentLabelsThree, _segmentThree, @@ -318,7 +326,6 @@ internal sealed class WidgetGalleryWindow : Window // and no overhang on the right. SegmentedControl.Draw( ImGui.GetID("gallery.segmented.odd"), - ImGui.GetCursorScreenPos(), 201f, SegmentLabelsThree, 0, @@ -334,7 +341,8 @@ internal sealed class WidgetGalleryWindow : Window ImGui.TextUnformatted("Badge"); ImGui.SliderInt("count##badge", ref _badgeCount, 0, 150); - int[] samples = [1, 9, _badgeCount, 120]; + _badgeSamples[2] = _badgeCount; + var samples = _badgeSamples; var origin = ImGui.GetCursorScreenPos(); var x = origin.X; foreach (var n in samples) @@ -393,12 +401,7 @@ internal sealed class WidgetGalleryWindow : Window ImGui.TextUnformatted("IconButton"); var size = new Vector2(Metrics.SidebarPopOutHitWidth, Metrics.SidebarRowHeight); - FontAwesomeIcon[] glyphs = - [ - FontAwesomeIcon.ArrowUpRightFromSquare, - FontAwesomeIcon.Check, - FontAwesomeIcon.CheckCircle, - ]; + var glyphs = GalleryGlyphs; for (var i = 0; i < glyphs.Length; i++) { From 588b88653777dbc99753bf43307de3fbf335b559 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 14:41:07 +0200 Subject: [PATCH 076/129] fix(settings): remove the switches that do nothing Six settings had a control, a saved value, translated labels, and no reader anywhere in the plugin. Flipping them changed a byte on disk and nothing else. Three sat next to each other in the Chat tab under timestamps, so the section read as four related options where only one -- the 24-hour clock -- works. FormatTimestamp consults nothing else. The first-run wizard offered one of them too, on its visual step, and listed it back in the summary as if it had been applied. That is the first screen a new user sees, so it goes as well. Their own strings show how long this has been drifting: the wizard called PrettierTimestamps "relative time", the settings tab called the same field "modern layout". Two different features, one boolean, neither implemented. That is also why these are removed rather than wired up -- there is no single behaviour to restore, and inventing one belongs in a cycle that plans it. Config fields stay, with a comment. A stored value should survive until the rendering it describes exists, and dropping them would silently reset anyone who set them. Found by an audit that turned up considerably more of this: 433 of 824 translated resource keys reach no code at all, and the export, cleanup, tab editor and pin paths are complete but unreachable. That is its own cycle; this commit only clears what actively lies to the user in the window we are working on. --- HellionChat/Configuration.cs | 10 ++++++++++ .../Ui/Components/Settings/ChatColourPicker.cs | 12 ------------ .../Ui/Components/Settings/Tabs/ChatTab.cs | 15 --------------- .../Ui/Components/Settings/Tabs/GeneralTab.cs | 10 ---------- HellionChat/Ui/FirstRunWizard.cs | 11 ----------- 5 files changed, 10 insertions(+), 48 deletions(-) diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index 6326650..b5b9c78 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -181,9 +181,16 @@ public class Configuration : IPluginConfiguration public bool ShowHideButton = true; public bool NativeItemTooltips = true; public bool ScreenshotMode; + + // No control and no reader. Kept so a stored value survives until the + // rendering they describe exists; see the reconnect backlog. Note the two + // resource sets disagree on what PrettierTimestamps even means -- the wizard + // called it "relative time", the settings tab "modern layout". public bool PrettierTimestamps = true; public bool MoreCompactPretty; public bool HideSameTimestamps = true; + + // No reader; see the reconnect backlog. public bool ShowNoviceNetwork; // Migration-only since v23: the 1.5.6 sidebar↔top-tabs switch, superseded by @@ -191,6 +198,8 @@ public class Configuration : IPluginConfiguration // the v23 migration in Plugin.cs and kept deserializable so a 1.5.6 user's // false value survives one load. Remove in a later schema bump. public bool SidebarTabView = true; + + // No reader; see the reconnect backlog. public bool PrintChangelog = true; public bool OnlyPreviewIf; public int PreviewMinimum = 1; @@ -260,6 +269,7 @@ public class Configuration : IPluginConfiguration return defaults; } + // No reader; see the reconnect backlog. public bool ColorSelectedInputChannelButton = true; public List Tabs = []; diff --git a/HellionChat/Ui/Components/Settings/ChatColourPicker.cs b/HellionChat/Ui/Components/Settings/ChatColourPicker.cs index 575650b..733305f 100644 --- a/HellionChat/Ui/Components/Settings/ChatColourPicker.cs +++ b/HellionChat/Ui/Components/Settings/ChatColourPicker.cs @@ -41,18 +41,6 @@ internal sealed class ChatColourPicker ImGui.Separator(); ImGui.Spacing(); - if ( - ImGui.Checkbox( - Language.Options_ColorSelectedInputChannelButton_Name, - ref Plugin.Config.ColorSelectedInputChannelButton - ) - ) - { - _plugin.SaveConfig(); - } - ImGuiUtil.HelpMarker(Language.Options_ColorSelectedInputChannelButton_Description); - ImGui.Spacing(); - // Discrete clicks (reset/import) persist at once. The ColorEdit3 drag only // recolours live (Refresh, no disk write) and defers SaveConfig to release // via IsItemDeactivatedAfterEdit, so dragging the colour wheel doesn't fire diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs index f46d551..b7a3a9f 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs @@ -24,21 +24,6 @@ internal sealed class ChatTab () => Plugin.Config.UseCompactDensity, v => Plugin.Config.UseCompactDensity = v ); - _w.Toggle( - "More compact pretty mode", - () => Plugin.Config.MoreCompactPretty, - v => Plugin.Config.MoreCompactPretty = v - ); - _w.Toggle( - "Prettier timestamps", - () => Plugin.Config.PrettierTimestamps, - v => Plugin.Config.PrettierTimestamps = v - ); - _w.Toggle( - "Hide same timestamps", - () => Plugin.Config.HideSameTimestamps, - v => Plugin.Config.HideSameTimestamps = v - ); _w.Toggle( "24-hour clock", () => Plugin.Config.Use24HourClock, diff --git a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs index 4387b5f..956dc61 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs @@ -40,16 +40,6 @@ internal sealed class GeneralTab () => Plugin.Config.ReduceMotion, v => Plugin.Config.ReduceMotion = v ); - _w.Toggle( - "Print changelog on update", - () => Plugin.Config.PrintChangelog, - v => Plugin.Config.PrintChangelog = v - ); - _w.Toggle( - "Show novice network", - () => Plugin.Config.ShowNoviceNetwork, - v => Plugin.Config.ShowNoviceNetwork = v - ); DrawLanguagePicker(); } diff --git a/HellionChat/Ui/FirstRunWizard.cs b/HellionChat/Ui/FirstRunWizard.cs index 7506d14..91a5510 100644 --- a/HellionChat/Ui/FirstRunWizard.cs +++ b/HellionChat/Ui/FirstRunWizard.cs @@ -414,10 +414,6 @@ public sealed class FirstRunWizard : Window if (ImGui.Checkbox(HellionStrings.Wizard_Step3_UseCompactDensity_Label, ref compact)) _state.PendingUseCompactDensity = compact; - var pretty = _state.PendingPrettierTimestamps ?? Plugin.Config.PrettierTimestamps; - if (ImGui.Checkbox(HellionStrings.Wizard_Step3_PrettierTimestamps_Label, ref pretty)) - _state.PendingPrettierTimestamps = pretty; - // Theme dropdown — built-ins only. Custom themes are power-user // territory and would clutter the first-run flow. var currentSlug = _state.PendingTheme ?? Plugin.Config.Theme; @@ -513,14 +509,11 @@ public sealed class FirstRunWizard : Window ); var compact = _state.PendingUseCompactDensity ?? Plugin.Config.UseCompactDensity; - var pretty = _state.PendingPrettierTimestamps ?? Plugin.Config.PrettierTimestamps; var themeSlug = _state.PendingTheme ?? Plugin.Config.Theme; var themeName = Plugin.ThemeRegistry.Get(themeSlug).Name; var visualParts = new List(); if (compact) visualParts.Add(HellionStrings.Wizard_Step3_UseCompactDensity_Label); - if (pretty) - visualParts.Add(HellionStrings.Wizard_Step3_PrettierTimestamps_Label); visualParts.Add(themeName); ImGui.TextWrapped( string.Format( @@ -594,9 +587,6 @@ public sealed class FirstRunWizard : Window if (_state.PendingUseCompactDensity.HasValue) Plugin.Config.UseCompactDensity = _state.PendingUseCompactDensity.Value; - if (_state.PendingPrettierTimestamps.HasValue) - Plugin.Config.PrettierTimestamps = _state.PendingPrettierTimestamps.Value; - if (!string.IsNullOrWhiteSpace(_state.PendingTheme)) { Plugin.Config.Theme = _state.PendingTheme; @@ -687,7 +677,6 @@ public sealed class FirstRunWizard : Window public bool? PendingFilterIncludePreviousSessions { get; set; } public int? PendingAutoTellTabsHistoryPreload { get; set; } public bool? PendingUseCompactDensity { get; set; } - public bool? PendingPrettierTimestamps { get; set; } public string? PendingTheme { get; set; } } } From 639791671244ce6e47d8e6139fdb459a3a2303d3 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 14:43:34 +0200 Subject: [PATCH 077/129] fix(settings): show which channels get their history deleted Three of the four first-run profiles switch the retention sweep on and write a per-channel deletion policy. The window only ever showed the global default, so whatever the wizard decided about individual channels was invisible from the moment the wizard closed. The tab now lists every channel whose retention differs from the global value, tagged by where the number comes from, plus a button to drop the custom ones. It also finally uses the strings written for this screen, sweep description and default help included. Read-only, deliberately. Zero means "keep forever" as a global default and "delete this channel's entire history" as a per-channel value, because the default takes a separate SQL branch while a mapped channel gets cutoff = now. No shipped profile contains a zero and no UI could set one, so nothing is broken today -- but an editor cannot be offered until those two meanings agree. Noted at the branch in DeleteByRetentionPolicy. The default slider now goes down to 0, which is the value that means "never delete anything". Its own label has been promising "0 = never" while the slider started at 1. --- HellionChat/MessageStore.cs | 6 ++ .../Settings/Tabs/DataPrivacyTab.cs | 64 ++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index 1ec4372..c46f841 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -529,6 +529,12 @@ internal class MessageStore : IDisposable var index = 0; foreach (var (type, days) in chatTypeDaysMap) { + // Careful: 0 here is NOT the "keep forever" it means for + // defaultDays below. A per-channel 0 puts the cutoff at now + // and deletes the channel's entire history. No profile ships + // a 0 and no UI can set one, which is why this is a comment + // and not a guard -- but any editor added later has to + // reconcile the two meanings before it exposes the value. var cutoff = nowMs - days * 86400000L; var typeParam = $"$type{index}"; var cutoffParam = $"$cutoff{index}"; diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs index c8713a7..d7803bb 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -1,5 +1,7 @@ using Dalamud.Bindings.ImGui; using HellionChat.Code; +using HellionChat.Resources; +using HellionChat.Util; namespace HellionChat.Ui.Components.Settings.Tabs; @@ -19,17 +21,24 @@ internal sealed class DataPrivacyTab if (ImGui.CollapsingHeader("Logging", ImGuiTreeNodeFlags.DefaultOpen)) { _w.Toggle( - "Enable retention sweep", + HellionStrings.Retention_Enabled_Name, () => Plugin.Config.RetentionEnabled, v => Plugin.Config.RetentionEnabled = v ); + ImGuiUtil.HelpMarker(HellionStrings.Retention_Enabled_Description); + + // Down to 0, which DeleteByRetentionPolicy reads as "keep forever" + // for channels without an override. The slider started at 1, so the + // one value that means "never delete anything" was unreachable + // through the UI while the label promised it. _w.SliderInt( - "Default retention (days)", + HellionStrings.Retention_Default_Label, () => Plugin.Config.RetentionDefaultDays, v => Plugin.Config.RetentionDefaultDays = v, - 1, + 0, 365 ); + ImGuiUtil.HelpMarker(HellionStrings.Retention_Default_Help); // RetentionLastRunAt defaults to MinValue on a fresh install, which // would render as "0001-01-01 00:00" and look like a bug; the "Never" @@ -41,6 +50,8 @@ internal sealed class DataPrivacyTab ? "Never" : Plugin.Config.RetentionLastRunAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"); ImGui.TextDisabled($"Last run: {lastRun}"); + + DrawRetentionOverrides(); } if (ImGui.CollapsingHeader("Privacy filter", ImGuiTreeNodeFlags.DefaultOpen)) @@ -66,6 +77,53 @@ internal sealed class DataPrivacyTab } } + // Read-only on purpose. Three of the four wizard profiles write a + // per-channel policy and switch the sweep on, and until now the window only + // showed the global default -- so whatever the wizard decided about your + // channels was invisible from here on. + // + // Editing them is a separate matter: 0 means "keep forever" as a global + // default but "delete everything" as a per-channel value, and no UI should + // offer that until the two agree. + private void DrawRetentionOverrides() + { + var overrides = Plugin.Config.RetentionPerChannelDays; + + ImGui.Spacing(); + ImGui.TextUnformatted(HellionStrings.Retention_Tree_Heading); + + var shown = 0; + foreach (var type in EnumValues.All) + { + var days = Plugin.Config.GetRetentionDays(type); + if (days == Plugin.Config.RetentionDefaultDays && !overrides.ContainsKey(type)) + continue; + + var tag = overrides.ContainsKey(type) + ? HellionStrings.Retention_Tag_Override + : HellionStrings.Retention_Tag_Spec; + ImGui.TextDisabled($" {type.Name()}: {days} d {tag}"); + shown++; + } + + if (shown == 0) + { + ImGui.TextDisabled($" {HellionStrings.Retention_Tag_Global}"); + return; + } + + if (overrides.Count > 0 && ImGui.Button(HellionStrings.Retention_Clear_Overrides)) + { + // Same lock the sweep takes when it snapshots the policy, so a clear + // cannot cut its enumeration short. + lock (_plugin.ConfigMapsLock) + { + overrides.Clear(); + } + _plugin.SaveConfig(); + } + } + private void DrawPrivacyPersistChannelsGrid() { // HashSet: iterate Enum.GetValues() for stable From 18d43f9afa0954fe3b57725a3d00aa8b83590077 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 17:01:10 +0200 Subject: [PATCH 078/129] fix(emotes): survive BetterTTV's shared-emote endpoint going private /emotes/shared/top now answers 403 with {"message":"unauthorized"}. Nothing checked the status code, so that object was handed to a List deserializer, which threw on the very first character on every plugin start. The throw escaped the whole loader, so the 65 global emotes -- fetched successfully one call earlier -- were discarded along with it, and State went back to Unloaded, arming the same failure for the next trigger. A failed page now stops paging and keeps what the global endpoint returned. The global call itself still throws when it fails, because without it there is nothing to keep. Two smaller hazards on the same path: an empty page would have made Last() throw rather than end the loop, and a null deserialization result was dereferenced outright. Both end the loop now. Logged as a warning, not an error. This is a third-party endpoint changing its access policy, not a fault in the plugin, and it does not need a stack trace in the log at every launch. --- HellionChat/EmoteCache.cs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/HellionChat/EmoteCache.cs b/HellionChat/EmoteCache.cs index 0af31b7..260da90 100644 --- a/HellionChat/EmoteCache.cs +++ b/HellionChat/EmoteCache.cs @@ -125,6 +125,15 @@ public static class EmoteCache try { var global = await Client.GetAsync(GlobalEmotes, ct); + if (!global.IsSuccessStatusCode) + { + // Nothing usable at all -- let the catch below reset to Unloaded + // so a later trigger can retry. + throw new HttpRequestException( + $"BetterTTV global emotes returned {(int)global.StatusCode}." + ); + } + var globalList = await global.Content.ReadAsStringAsync(ct); foreach (var emote in JsonSerializer.Deserialize(globalList)!) @@ -135,9 +144,27 @@ public static class EmoteCache for (var i = 0; i < 15; i++) { var top = await Client.GetAsync(Top100Emotes.Format(BetterTTV, lastId), ct); + + // The shared-emote endpoint went behind authentication and now + // answers 403 with a JSON object. Deserializing that as a list + // threw on every single start, and took the global emotes -- which + // still work -- down with it. The global set is the useful half + // anyway, so a failure here stops paging instead of the load. + if (!top.IsSuccessStatusCode) + { + Plugin.LogProxy.Warning( + $"BetterTTV shared emotes unavailable ({(int)top.StatusCode}); " + + "continuing with global emotes only." + ); + break; + } + var topList = await top.Content.ReadAsStringAsync(ct); - var jsonList = JsonSerializer.Deserialize>(topList)!; + var jsonList = JsonSerializer.Deserialize>(topList); + if (jsonList is not { Count: > 0 }) + break; + // BetterTTV occasionally returns entries with a null Code; // skip them so a single bad row doesn't break the whole cache. foreach (var emote in jsonList) @@ -147,7 +174,7 @@ public static class EmoteCache ) Cache.TryAdd(emote.Emote.Code, emote.Emote); - lastId = jsonList.Last().Id; + lastId = jsonList[^1].Id; } SortedCodeArray = Cache.Keys.Order().ToArray(); From 5a4c3b6707826e4122393570381c8a2b8502a191 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 17:07:39 +0200 Subject: [PATCH 079/129] feat(settings): convert the window tab to the styled widgets The first tab that actually looks different. Everything here was stock ImGui: framed collapsing bars, checkboxes with the label trailing on the right, and sliders glued to the left edge with their name behind them. Now: section headers with an accent bar, rows with the label on the left and the control right-aligned in its own column, sliding switches instead of checkboxes, and a segmented control where two radio buttons used to pretend to be two settings when they are one. Three of the four widgets built in this cycle had no call site outside the debug gallery. That is the exact defect that triggered v1.10.0 -- five tools built and never wired up -- and the spec rule written afterwards says no widget without a call site in the same cycle. This closes that on the pilot tab; the remaining five follow one at a time. The row helpers live in SettingsWidgets so the other tabs get them unchanged, and the theme lookups go through SettingsPalette, cached per frame: twenty rows would otherwise resolve the same five tokens twenty times over. Section keys are u8 literals rather than the visible titles. ImGui's own storage keys collapsing headers off the label, which would reset every section's open state on a language change and merge two sections whose titles translate alike. Note WindowTab's constructor gained a parameter, so this needs the DI smoke pass before the next tab follows. --- HellionChat/PluginHostFactory.cs | 3 +- .../Ui/Components/Settings/SettingsPalette.cs | 59 ++++++ .../Ui/Components/Settings/SettingsWidgets.cs | 198 +++++++++++++++++- .../Ui/Components/Settings/Tabs/WindowTab.cs | 93 +++++--- 4 files changed, 320 insertions(+), 33 deletions(-) create mode 100644 HellionChat/Ui/Components/Settings/SettingsPalette.cs diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index f67d683..f2ed0fd 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -203,7 +203,8 @@ internal static class PluginHostFactory sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.WindowTab( - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.ChannelsTab( sp.GetRequiredService() diff --git a/HellionChat/Ui/Components/Settings/SettingsPalette.cs b/HellionChat/Ui/Components/Settings/SettingsPalette.cs new file mode 100644 index 0000000..d4db594 --- /dev/null +++ b/HellionChat/Ui/Components/Settings/SettingsPalette.cs @@ -0,0 +1,59 @@ +using HellionChat.Themes; +using HellionChat.Ui.StyleEngine; +using HellionChat.Ui.StyleEngine.Widgets; + +namespace HellionChat.Ui.Components.Settings; + +// Every styled settings control needs the same four or five theme slots, and +// each widget wants them in its own colour struct. Building those inline turned +// each call site into six lines of plumbing around one line of intent. +// +// Rebuilt per frame rather than cached: the active theme changes while the +// window is open -- that is what the appearance tab is for. +internal sealed class SettingsPalette +{ + private readonly WidgetPalette _palette; + + internal SettingsPalette(TokenResolver resolver) + { + _palette = new WidgetPalette(resolver); + } + + internal SettingRowColors Row(ThemeColors c) => + new() + { + LabelAbgr = _palette.Abgr(Token.Text, c), + DescriptionAbgr = _palette.Abgr(Token.TextMuted, c), + SurfaceHoverAbgr = _palette.Abgr(Token.SurfaceHover, c), + BorderAbgr = _palette.Abgr(Token.Border, c), + }; + + internal ToggleSwitchColors Toggle(ThemeColors c) => + new() + { + TrackOffAbgr = _palette.Abgr(Token.SurfaceBase, c), + TrackOnAbgr = _palette.Abgr(Token.AccentPrimary, c), + KnobAbgr = _palette.Abgr(Token.Text, c), + }; + + internal SectionHeaderColors Section(ThemeColors c) => + new() + { + TitleAbgr = _palette.Abgr(Token.Text, c), + DescriptionAbgr = _palette.Abgr(Token.TextMuted, c), + AccentAbgr = _palette.Abgr(Token.AccentPrimary, c), + BorderAbgr = _palette.Abgr(Token.Border, c), + HoverAbgr = _palette.Abgr(Token.SurfaceHover, c), + }; + + internal SegmentedControlColors Segmented(ThemeColors c) => + new() + { + TrackAbgr = _palette.Abgr(Token.SurfaceBase, c), + SelectedAbgr = _palette.Abgr(Token.AccentPrimary, c), + HoverAbgr = _palette.Abgr(Token.SurfaceHover, c), + LabelAbgr = _palette.Abgr(Token.TextMuted, c), + SelectedLabelAbgr = _palette.Abgr(Token.Text, c), + BorderAbgr = _palette.Abgr(Token.Border, c), + }; +} diff --git a/HellionChat/Ui/Components/Settings/SettingsWidgets.cs b/HellionChat/Ui/Components/Settings/SettingsWidgets.cs index 6905266..e2dda3b 100644 --- a/HellionChat/Ui/Components/Settings/SettingsWidgets.cs +++ b/HellionChat/Ui/Components/Settings/SettingsWidgets.cs @@ -1,4 +1,8 @@ +using System.Numerics; using Dalamud.Bindings.ImGui; +using HellionChat.Themes; +using HellionChat.Ui.StyleEngine; +using HellionChat.Ui.StyleEngine.Widgets; namespace HellionChat.Ui.Components.Settings; @@ -27,9 +31,201 @@ internal sealed class SettingsWidgets // Always sliced to the value count when passed on -- see EnumCombo. private string[] _labelScratch = new string[8]; - internal SettingsWidgets(Plugin plugin) + private readonly SettingsPalette? _colors; + + // Cached per frame: twenty rows would otherwise re-resolve the same five + // theme tokens twenty times, and the active theme cannot change mid-frame. + private int _frame = -1; + private SettingRowColors _row; + private ToggleSwitchColors _toggle; + private SectionHeaderColors _section; + private SegmentedControlColors _segmented; + + internal SettingsWidgets(Plugin plugin, SettingsPalette? colors = null) { _plugin = plugin; + _colors = colors; + } + + // Tabs that have not been converted yet pass no palette and keep using the + // plain ImGui helpers below. + private void EnsureFrame() + { + if (_colors is null || _frame == ImGui.GetFrameCount()) + return; + + _frame = ImGui.GetFrameCount(); + var c = _plugin.ThemeRegistry.Active.Colors; + _row = _colors.Row(c); + _toggle = _colors.Toggle(c); + _section = _colors.Section(c); + _segmented = _colors.Segmented(c); + } + + internal bool Section(uint key, string title, string? description = null, bool open = true) + { + EnsureFrame(); + return SectionHeader.Draw(key, title, description, _section, defaultOpen: open); + } + + // The whole row toggles, label included. The switch itself gets its own + // invisible button because SettingRow's hit area stops at the label column, + // and clicking the control is what a user tries first. + internal void ToggleRow( + uint id, + string label, + string? description, + Func get, + Action set + ) + { + EnsureFrame(); + var value = get(); + var hit = false; + + var rowClicked = SettingRow.Draw( + id, + label, + description, + _row, + ctx => + { + var size = ToggleSwitch.CalcSize(); + var pos = ctx.AlignRight(size); + ImGui.SetCursorScreenPos(pos); + if (ImGui.InvisibleButton($"##hc-sw-{id}", size)) + hit = true; + ToggleSwitch.Draw(id, pos, value, _toggle); + } + ); + + if (!rowClicked && !hit) + return; + + set(!value); + _plugin.SaveConfig(); + } + + internal void SliderFloatRow( + uint id, + string label, + string? description, + Func get, + Action set, + float min, + float max + ) + { + EnsureFrame(); + var current = get(); + SettingRow.Draw( + id, + label, + description, + _row, + ctx => + { + ImGui.SetNextItemWidth(ctx.ControlWidth); + if (ImGui.SliderFloat($"##hc-sf-{id}", ref current, min, max, "%.2f")) + set(current); + if (ImGui.IsItemDeactivatedAfterEdit()) + _plugin.SaveConfig(); + } + ); + } + + internal void SliderIntRow( + uint id, + string label, + string? description, + Func get, + Action set, + int min, + int max + ) + { + EnsureFrame(); + var current = get(); + SettingRow.Draw( + id, + label, + description, + _row, + ctx => + { + ImGui.SetNextItemWidth(ctx.ControlWidth); + if (ImGui.SliderInt($"##hc-si-{id}", ref current, min, max, "%d")) + set(current); + if (ImGui.IsItemDeactivatedAfterEdit()) + _plugin.SaveConfig(); + } + ); + } + + internal void EnumComboRow( + uint id, + string label, + string? description, + Func get, + Action set, + Func labelFor + ) + where T : struct, Enum + { + EnsureFrame(); + SettingRow.Draw( + id, + label, + description, + _row, + ctx => + { + ImGui.SetNextItemWidth(ctx.ControlWidth); + EnumCombo($"##hc-ec-{id}", get, set, labelFor, ctx.ControlWidth); + } + ); + } + + // One setting, n choices. The control fills the whole control column rather + // than right-aligning, because segments need the room to stay readable. + internal void SegmentRow( + uint id, + string label, + string? description, + T[] values, + string[] labels, + Func get, + Action set + ) + where T : struct, Enum + { + EnsureFrame(); + var current = get(); + var selected = 0; + for (var i = 0; i < values.Length; i++) + if (EqualityComparer.Default.Equals(values[i], current)) + selected = i; + + var picked = selected; + var colors = _segmented; + + SettingRow.Draw( + id, + label, + description, + _row, + ctx => + { + ImGui.SetCursorScreenPos(new Vector2(ctx.ControlOrigin.X, ctx.ControlOrigin.Y)); + picked = SegmentedControl.Draw(id, ctx.ControlWidth, labels, selected, colors); + } + ); + + if (picked == selected) + return; + + set(values[picked]); + _plugin.SaveConfig(); } internal void Toggle(string label, Func get, Action set) diff --git a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs index 92391ca..adee5d5 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs @@ -1,65 +1,86 @@ using Dalamud.Bindings.ImGui; +using HellionChat.Ui.StyleEngine; namespace HellionChat.Ui.Components.Settings.Tabs; +// First tab converted to the styled widgets. Everything here used to be stock +// ImGui: framed collapsing bars, checkboxes with their label on the right, and +// sliders glued to the left edge with their name trailing behind. internal sealed class WindowTab { - private readonly Plugin _plugin; private readonly SettingsWidgets _w; - public WindowTab(Plugin plugin) + // Held rather than built per frame, and ordered to match the enum values + // passed alongside them. + private static readonly MainWindowLayoutMode[] LayoutValues = + [ + MainWindowLayoutMode.Sidebar, + MainWindowLayoutMode.TopTabs, + ]; + private static readonly string[] LayoutLabels = ["Sidebar", "Top tabs"]; + + public WindowTab(Plugin plugin, TokenResolver resolver) { - _plugin = plugin; - _w = new SettingsWidgets(plugin); + _w = new SettingsWidgets(plugin, new SettingsPalette(resolver)); } public void Draw() { - if (ImGui.CollapsingHeader("Layout mode", ImGuiTreeNodeFlags.DefaultOpen)) + // ASCII literals, not the visible titles: the keys have to survive a + // language change, and u8 literals cost no allocation. + if (_w.Section(ImGui.GetID("window.layout"u8), "Layout mode")) { - var mode = Plugin.Config.MainWindowLayoutMode; - if (ImGui.RadioButton("Sidebar", mode == MainWindowLayoutMode.Sidebar)) - { - Plugin.Config.MainWindowLayoutMode = MainWindowLayoutMode.Sidebar; - _plugin.SaveConfig(); - } - if (ImGui.RadioButton("Top tabs", mode == MainWindowLayoutMode.TopTabs)) - { - Plugin.Config.MainWindowLayoutMode = MainWindowLayoutMode.TopTabs; - _plugin.SaveConfig(); - } + _w.SegmentRow( + ImGui.GetID("window.layout.mode"u8), + "Tab placement", + "Where the tab list sits in the main window.", + LayoutValues, + LayoutLabels, + () => Plugin.Config.MainWindowLayoutMode, + v => Plugin.Config.MainWindowLayoutMode = v + ); } - if (ImGui.CollapsingHeader("Window style", ImGuiTreeNodeFlags.DefaultOpen)) + if (_w.Section(ImGui.GetID("window.style"u8), "Window style")) { - _w.Toggle( + _w.ToggleRow( + ImGui.GetID("window.style.titlebar"u8), "Show title bar", + null, () => Plugin.Config.ShowTitleBar, v => Plugin.Config.ShowTitleBar = v ); - _w.Toggle( + _w.ToggleRow( + ImGui.GetID("window.style.popouttitlebar"u8), "Show title bar for pop-outs", + null, () => Plugin.Config.ShowPopOutTitleBar, v => Plugin.Config.ShowPopOutTitleBar = v ); - _w.Toggle( + _w.ToggleRow( + ImGui.GetID("window.style.hidebutton"u8), "Show hide button", + null, () => Plugin.Config.ShowHideButton, v => Plugin.Config.ShowHideButton = v ); } - if (ImGui.CollapsingHeader("Opacity", ImGuiTreeNodeFlags.DefaultOpen)) + if (_w.Section(ImGui.GetID("window.opacity"u8), "Opacity")) { - _w.SliderFloat( + _w.SliderFloatRow( + ImGui.GetID("window.opacity.active"u8), "Window opacity", + null, () => Plugin.Config.WindowOpacity, v => Plugin.Config.WindowOpacity = v, 0.1f, 1f ); - _w.SliderFloat( + _w.SliderFloatRow( + ImGui.GetID("window.opacity.inactive"u8), "Inactive opacity", + "Applies while another window has focus.", () => Plugin.Config.WindowOpacityInactive, v => Plugin.Config.WindowOpacityInactive = v, 0.1f, @@ -67,20 +88,26 @@ internal sealed class WindowTab ); } - if (ImGui.CollapsingHeader("Resize behavior", ImGuiTreeNodeFlags.DefaultOpen)) + if (_w.Section(ImGui.GetID("window.resize"u8), "Resize behaviour")) { - _w.Toggle( + _w.ToggleRow( + ImGui.GetID("window.resize.move"u8), "Allow movement", + null, () => Plugin.Config.CanMove, v => Plugin.Config.CanMove = v ); - _w.Toggle( + _w.ToggleRow( + ImGui.GetID("window.resize.resize"u8), "Allow resize", + null, () => Plugin.Config.CanResize, v => Plugin.Config.CanResize = v ); - _w.SliderInt( - "Sidebar auto-switch threshold (px)", + _w.SliderIntRow( + ImGui.GetID("window.resize.threshold"u8), + "Sidebar auto-switch threshold", + "Below this width the sidebar folds into top tabs, in pixels.", () => Plugin.Config.SidebarAutoSwitchThresholdPx, v => Plugin.Config.SidebarAutoSwitchThresholdPx = v, 200, @@ -88,16 +115,20 @@ internal sealed class WindowTab ); } - if (ImGui.CollapsingHeader("Input preview")) + if (_w.Section(ImGui.GetID("window.preview"u8), "Input preview", open: false)) { - _w.EnumCombo( + _w.EnumComboRow( + ImGui.GetID("window.preview.position"u8), "Preview position", + null, () => Plugin.Config.PreviewPosition, v => Plugin.Config.PreviewPosition = v, v => v.Name() ); - _w.Toggle( + _w.ToggleRow( + ImGui.GetID("window.preview.onlyif"u8), "Only show preview when typing", + null, () => Plugin.Config.OnlyPreviewIf, v => Plugin.Config.OnlyPreviewIf = v ); From 2f4518a6b184c6292080b88114e66c6611cb7297 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 17:13:43 +0200 Subject: [PATCH 080/129] fix(style): give the settings pane the contrast it was missing The converted tab had the right structure and no legibility. Five separate causes, all of them contrast rather than colour choice. The content pane never set ChildBg, so it inherited whatever showed through the window, and the window is translucent by default. Settings text was sitting on moving scenery. It gets the surface tone now; the window's own opacity still applies on top, so the glass look survives. Section headings had no ground of their own and floated between the rows at roughly their weight, which meant the tab had lost its grouping entirely. They get the raised surface, and their accent bar now runs the full height instead of stopping at the title line. An off toggle was filled with the same surface tone as the row behind it and read as empty space. It gets an outline that fades out as it turns on, where the filled track carries the shape by itself. Two colours could not be picked statically at all, because themes here range from near-black to pastel: the label on a selected segment, and the knob on the track. Both now derive from the luminance of what they sit on, so neither can end up light-on-light. That is what ColourUtil.OnColour is for. Descriptions drop from TextMuted to TextFaint. Level with the label they made each row read as two settings rather than one with an explanation. --- HellionChat/PluginHostFactory.cs | 5 +++- .../Ui/Components/Settings/ContentArea.cs | 27 +++++++++++++++++++ .../Ui/Components/Settings/SettingsPalette.cs | 6 ++++- .../Ui/StyleEngine/Widgets/SectionHeader.cs | 12 +++++++-- .../StyleEngine/Widgets/SegmentedControl.cs | 10 ++++++- .../Ui/StyleEngine/Widgets/ToggleSwitch.cs | 22 ++++++++++++++- HellionChat/Ui/Windows/WidgetGalleryWindow.cs | 1 + HellionChat/Util/ColourUtil.cs | 17 ++++++++++++ 8 files changed, 94 insertions(+), 6 deletions(-) diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index f2ed0fd..83fba5f 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -162,7 +162,10 @@ internal static class PluginHostFactory services.AddSingleton(sp => new Ui.Components.Settings.TabSidebar( sp.GetRequiredService() )); - services.AddSingleton(sp => new Ui.Components.Settings.ContentArea()); + services.AddSingleton(sp => new Ui.Components.Settings.ContentArea( + sp.GetRequiredService(), + sp.GetRequiredService() + )); services.AddSingleton(sp => new Ui.Components.Settings.ThemePicker( sp.GetRequiredService(), sp.GetRequiredService() diff --git a/HellionChat/Ui/Components/Settings/ContentArea.cs b/HellionChat/Ui/Components/Settings/ContentArea.cs index 1e7da68..4021c0d 100644 --- a/HellionChat/Ui/Components/Settings/ContentArea.cs +++ b/HellionChat/Ui/Components/Settings/ContentArea.cs @@ -1,12 +1,39 @@ using System.Numerics; +using Dalamud.Bindings.ImGui; using Dalamud.Interface.Utility.Raii; +using HellionChat.Themes; +using HellionChat.Ui.StyleEngine; +using HellionChat.Util; namespace HellionChat.Ui.Components.Settings; internal sealed class ContentArea { + private readonly ThemeRegistry _themes; + private readonly TokenResolver _resolver; + + public ContentArea(ThemeRegistry themes, TokenResolver resolver) + { + _themes = themes; + _resolver = resolver; + } + public void Draw(string activeTab, Action renderTab) { + // ChildBg was never set, so the pane inherited whatever showed through + // the window -- and the window is translucent by default. Settings text + // ended up sitting on moving scenery, which is legible in a screenshot + // and not while the game is running. + // + // Pushed as a colour rather than painted into the draw list so ImGui + // fills it before the scrollbar and border, and the window's own opacity + // still applies on top. + var colors = _themes.Active.Colors; + using var bg = ImRaii.PushColor( + ImGuiCol.ChildBg, + ColourUtil.RgbaToVector4(_resolver.Resolve(Token.SurfaceBase, colors)) + ); + using var child = ImRaii.Child("##settings-content", new Vector2(0, 0), true); if (!child.Success) { diff --git a/HellionChat/Ui/Components/Settings/SettingsPalette.cs b/HellionChat/Ui/Components/Settings/SettingsPalette.cs index d4db594..b2eee56 100644 --- a/HellionChat/Ui/Components/Settings/SettingsPalette.cs +++ b/HellionChat/Ui/Components/Settings/SettingsPalette.cs @@ -23,7 +23,9 @@ internal sealed class SettingsPalette new() { LabelAbgr = _palette.Abgr(Token.Text, c), - DescriptionAbgr = _palette.Abgr(Token.TextMuted, c), + // A step below the label, not level with it: at TextMuted the two + // lines carried the same weight and the row read as two settings. + DescriptionAbgr = _palette.Abgr(Token.TextFaint, c), SurfaceHoverAbgr = _palette.Abgr(Token.SurfaceHover, c), BorderAbgr = _palette.Abgr(Token.Border, c), }; @@ -34,6 +36,7 @@ internal sealed class SettingsPalette TrackOffAbgr = _palette.Abgr(Token.SurfaceBase, c), TrackOnAbgr = _palette.Abgr(Token.AccentPrimary, c), KnobAbgr = _palette.Abgr(Token.Text, c), + BorderAbgr = _palette.Abgr(Token.Border, c), }; internal SectionHeaderColors Section(ThemeColors c) => @@ -44,6 +47,7 @@ internal sealed class SettingsPalette AccentAbgr = _palette.Abgr(Token.AccentPrimary, c), BorderAbgr = _palette.Abgr(Token.Border, c), HoverAbgr = _palette.Abgr(Token.SurfaceHover, c), + SurfaceAbgr = _palette.Abgr(Token.SurfaceRaised, c), }; internal SegmentedControlColors Segmented(ThemeColors c) => diff --git a/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs index 78c1ffd..1189b8b 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs @@ -13,6 +13,10 @@ internal readonly record struct SectionHeaderColors public uint AccentAbgr { get; init; } public uint BorderAbgr { get; init; } public uint HoverAbgr { get; init; } + + // Own ground. Without it the heading floats between the rows at roughly + // their own weight and stops separating anything. + public uint SurfaceAbgr { get; init; } } internal readonly record struct SectionHeaderStyle @@ -20,7 +24,7 @@ internal readonly record struct SectionHeaderStyle public SectionHeaderStyle() { } public float PadY { get; init; } = 6f; - public float AccentBarWidth { get; init; } = 2f; + public float AccentBarWidth { get; init; } = 3f; public float ChevronInset { get; init; } = 6f; } @@ -88,6 +92,8 @@ internal static class SectionHeader var dl = ImGui.GetWindowDrawList(); var max = origin + size; + dl.AddRectFilled(origin, max, ColourUtil.ApplyAlpha(colors.SurfaceAbgr, alpha)); + if (hoverAmount > 0f) dl.AddRectFilled( origin, @@ -95,10 +101,12 @@ internal static class SectionHeader ColourUtil.ApplyAlpha(colors.HoverAbgr, hoverAmount * alpha) ); + // Full height, not just the title line. Stopping at the title left the + // bar looking like a stray tick next to a two-line heading. var barWidth = style.AccentBarWidth * scale; dl.AddRectFilled( origin, - new Vector2(origin.X + barWidth, origin.Y + titleHeight + padY * 2f), + new Vector2(origin.X + barWidth, max.Y), ColourUtil.ApplyAlpha(colors.AccentAbgr, alpha) ); diff --git a/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs index 4fc5b63..acbd32f 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs @@ -117,7 +117,15 @@ internal static class SegmentedControl MetricsMath.Center(height, textSize.Y) ), ColourUtil.ApplyAlpha( - isSelected ? colors.SelectedLabelAbgr : colors.LabelAbgr, + isSelected + // On the accent fill, so the readable colour depends on + // how light that accent is in the active theme. + ? ColourUtil.OnColour( + colors.SelectedAbgr, + colors.SelectedLabelAbgr, + colors.TrackAbgr + ) + : colors.LabelAbgr, alpha ), label diff --git a/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs b/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs index 5575771..3d00ff2 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ToggleSwitch.cs @@ -11,6 +11,11 @@ internal readonly record struct ToggleSwitchColors public uint TrackOffAbgr { get; init; } public uint TrackOnAbgr { get; init; } public uint KnobAbgr { get; init; } + + // The off track needs an outline of its own. Filled with a surface tone it + // is the same colour as the row behind it, so an off switch read as empty + // space rather than as a control. + public uint BorderAbgr { get; init; } } internal readonly record struct ToggleSwitchStyle @@ -66,10 +71,25 @@ internal static class ToggleSwitch var track = ColourUtil.Lerp(colors.TrackOffAbgr, colors.TrackOnAbgr, amount); dl.AddRectFilled(origin, max, track, size.Y * 0.5f); + + // Fades out as the switch turns on, where the filled track carries the + // shape by itself. + if (amount < 1f) + dl.AddRect( + origin, + max, + ColourUtil.ApplyAlpha(colors.BorderAbgr, 1f - amount), + size.Y * 0.5f, + ImDrawFlags.None, + Metrics.Scale + ); + + // The knob picks its contrast from the track it sits on, so it stays + // visible on a pale accent and on a near-black surface alike. dl.AddCircleFilled( new Vector2(origin.X + knobX, origin.Y + size.Y * 0.5f), knobR, - colors.KnobAbgr, + ColourUtil.OnColour(track, colors.KnobAbgr, colors.TrackOffAbgr), 16 ); } diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index 35ad0f0..3d51571 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -160,6 +160,7 @@ internal sealed class WidgetGalleryWindow : Window TrackOffAbgr = _palette.Abgr(Token.SurfaceRaised, c), TrackOnAbgr = _palette.Abgr(Token.AccentPrimary, c), KnobAbgr = _palette.Abgr(Token.Text, c), + BorderAbgr = _palette.Abgr(Token.Border, c), }; var size = ToggleSwitch.CalcSize(); diff --git a/HellionChat/Util/ColourUtil.cs b/HellionChat/Util/ColourUtil.cs index 9dee87d..462ac5e 100755 --- a/HellionChat/Util/ColourUtil.cs +++ b/HellionChat/Util/ColourUtil.cs @@ -108,6 +108,23 @@ internal static class ColourUtil // going fully saturated (effect level stays "subtle"). RGB-only on // purpose -- DrawHoverSheen owns the alpha falloff. // TEST-MIRROR: ../../../Hellion Build test/Util/ColourUtilTintTests.cs + // Relative luminance of an ABGR colour, 0..1, using the sRGB coefficients. + // Alpha is ignored: this answers "is this surface light or dark", and a + // translucent light surface still reads light against the window behind it. + internal static float Luminance(uint abgr) + { + var r = (abgr & 0xFFu) / 255f; + var g = ((abgr >> 8) & 0xFFu) / 255f; + var b = ((abgr >> 16) & 0xFFu) / 255f; + return 0.2126f * r + 0.7152f * g + 0.0722f * b; + } + + // Picks whichever of two candidates stands further from the background. + // Themes range from near-black to pastel, so a fixed text colour on an accent + // fill is legible in some and invisible in others. + internal static uint OnColour(uint background, uint light, uint dark) => + Luminance(background) > 0.5f ? dark : light; + internal static uint LerpTowardWhite(uint abgr, float t) { t = Math.Clamp(t, 0f, 1f); From ced7ea0c55c3a0151b2c68e374162246168d7f8b Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 17:42:02 +0200 Subject: [PATCH 081/129] feat(style): headings by typography, surfaces by gradient Two rounds of screenshots said the same thing twice: the section headings read against blue themes and vanished against violet ones. The cause was that their only distinction from a normal row was a fill colour, and no fill works in every palette. Headings are typography now. Small caps with wide tracking, no plate, no bar, followed by a rule that fades out along its length instead of stopping dead. The weight comes from letterforms and whitespace, which look the same in every theme. 18px of air above each one does the grouping that the bar used to fake. ImGui has no letter-spacing, so DrawTrackedText renders one glyph at a time through a stack buffer. That technique is documented in Character Select+, whose Boutique style layer solves exactly this problem the same way. Row separators are off by default. A rule under every row turns a settings page into a ledger, and the hover fill already marks where a row starts and ends. Surfaces get vertical gradients, derived from a single theme tone rather than a hardcoded pair, so they follow whatever the active theme sets. Lerp rather than a brightness multiplier: these surfaces sit near black, where scaling a channel of 12 by 1.14 lands back on 13. That is what LerpTowardBlack is for. The selected segment finally uses DrawSlipPolygon, which has been sitting in the plugin unused since it was written, and its highlight is a second, shorter chamfer rather than a clipped gradient -- PushClipRect is rectangular and would have squared the cut corner straight back off. Reference note: Lightless and Umbra were read for approach only. Both are AGPL-3.0 and none of their code is here; a vertical gradient is common knowledge, their implementation of it is theirs. --- .../Ui/Components/Settings/ContentArea.cs | 19 ++- .../Ui/Components/Settings/SettingsPalette.cs | 1 - .../Ui/StyleEngine/DrawListExtensions.cs | 88 +++++++++++ .../Ui/StyleEngine/Widgets/SectionHeader.cs | 148 +++++++++--------- .../StyleEngine/Widgets/SegmentedControl.cs | 42 ++++- .../Ui/StyleEngine/Widgets/SettingRow.cs | 8 +- HellionChat/Util/ColourUtil.cs | 14 ++ 7 files changed, 233 insertions(+), 87 deletions(-) diff --git a/HellionChat/Ui/Components/Settings/ContentArea.cs b/HellionChat/Ui/Components/Settings/ContentArea.cs index 4021c0d..ed72173 100644 --- a/HellionChat/Ui/Components/Settings/ContentArea.cs +++ b/HellionChat/Ui/Components/Settings/ContentArea.cs @@ -29,17 +29,28 @@ internal sealed class ContentArea // fills it before the scrollbar and border, and the window's own opacity // still applies on top. var colors = _themes.Active.Colors; - using var bg = ImRaii.PushColor( - ImGuiCol.ChildBg, - ColourUtil.RgbaToVector4(_resolver.Resolve(Token.SurfaceBase, colors)) - ); + // Transparent child, gradient painted underneath. ChildBg only takes a + // flat colour, and a flat pane is what made the window read as a form + // dump: nothing told the eye where the surface started. + using var bg = ImRaii.PushColor(ImGuiCol.ChildBg, 0u); using var child = ImRaii.Child("##settings-content", new Vector2(0, 0), true); if (!child.Success) { return; } + // GetWindowDrawList inside the child is the child's own list, so this + // lands behind its content without a channel split. + var surface = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceBase, colors)); + ImGui + .GetWindowDrawList() + .DrawVerticalGradient( + ImGui.GetWindowPos(), + ImGui.GetWindowPos() + ImGui.GetWindowSize(), + surface + ); + renderTab(activeTab); } } diff --git a/HellionChat/Ui/Components/Settings/SettingsPalette.cs b/HellionChat/Ui/Components/Settings/SettingsPalette.cs index b2eee56..c7c24da 100644 --- a/HellionChat/Ui/Components/Settings/SettingsPalette.cs +++ b/HellionChat/Ui/Components/Settings/SettingsPalette.cs @@ -47,7 +47,6 @@ internal sealed class SettingsPalette AccentAbgr = _palette.Abgr(Token.AccentPrimary, c), BorderAbgr = _palette.Abgr(Token.Border, c), HoverAbgr = _palette.Abgr(Token.SurfaceHover, c), - SurfaceAbgr = _palette.Abgr(Token.SurfaceRaised, c), }; internal SegmentedControlColors Segmented(ThemeColors c) => diff --git a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs index c0824a4..cd050a2 100644 --- a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs +++ b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs @@ -54,6 +54,94 @@ internal static class DrawListExtensions ); } + // ImGui has no letter-spacing, so tracked text is drawn one glyph at a time + // with an extra gap between them. Small caps with wide tracking is what + // separates a heading from a label when both use the same font size -- and + // unlike colour, it reads the same in every theme. + // + // One char at a time through a stack buffer, so a heading costs no + // allocation per frame. + public static float DrawTrackedText( + this ImDrawListPtr dl, + Vector2 pos, + ReadOnlySpan text, + uint abgr, + float trackPx + ) + { + Span one = stackalloc char[1]; + var x = pos.X; + for (var i = 0; i < text.Length; i++) + { + one[0] = text[i]; + dl.AddText(new Vector2(x, pos.Y), abgr, one); + x += ImGui.CalcTextSize(one).X; + if (i < text.Length - 1) + x += trackPx; + } + + return x - pos.X; + } + + public static float MeasureTrackedText(ReadOnlySpan text, float trackPx) + { + Span one = stackalloc char[1]; + var w = 0f; + for (var i = 0; i < text.Length; i++) + { + one[0] = text[i]; + w += ImGui.CalcTextSize(one).X; + if (i < text.Length - 1) + w += trackPx; + } + + return w; + } + + // Vertical gradient from a single base colour, brightened at the top and + // darkened at the bottom. Deriving both ends from one tone keeps it working + // across every theme, where a hardcoded pair would only suit one of them. + // + // ImGui cannot put a gradient behind a child window -- ChildBg takes a flat + // colour -- so surfaces that want depth have to paint it themselves. + public static void DrawVerticalGradient( + this ImDrawListPtr dl, + Vector2 min, + Vector2 max, + uint baseAbgr, + float topLift = 0.14f, + float bottomDrop = 0.10f + ) + { + // Lerp, not a multiplier: these surfaces sit near black, and scaling a + // channel of 12 by 1.14 lands back on 13. + var top = ColourUtil.LerpTowardWhite(baseAbgr, topLift); + var bottom = ColourUtil.LerpTowardBlack(baseAbgr, bottomDrop); + dl.AddRectFilledMultiColor(min, max, top, top, bottom, bottom); + } + + // A rule that fades out along its length instead of stopping dead. A hard + // line boxes content in; a fading one suggests a boundary without drawing a + // wall, which is the whole difference between a heading and a header bar. + public static void DrawFadeRule( + this ImDrawListPtr dl, + Vector2 start, + float width, + uint abgr, + float thickness + ) + { + var transparent = abgr & 0x00FFFFFFu; + dl.AddRectFilledMultiColor( + start, + new Vector2(start.X + width, start.Y + thickness), + abgr, + transparent, + transparent, + abgr + ); + } + public static void DrawGlowBorder( this ImDrawListPtr dl, Vector2 min, diff --git a/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs index 1189b8b..deb34d7 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SectionHeader.cs @@ -13,23 +13,27 @@ internal readonly record struct SectionHeaderColors public uint AccentAbgr { get; init; } public uint BorderAbgr { get; init; } public uint HoverAbgr { get; init; } - - // Own ground. Without it the heading floats between the rows at roughly - // their own weight and stops separating anything. - public uint SurfaceAbgr { get; init; } } internal readonly record struct SectionHeaderStyle { public SectionHeaderStyle() { } - public float PadY { get; init; } = 6f; - public float AccentBarWidth { get; init; } = 3f; - public float ChevronInset { get; init; } = 6f; + // Air above the heading, which is what actually groups the rows below it. + // A filled bar can be replaced by whitespace; whitespace cannot be replaced + // by a bar. + public float SpaceAbove { get; init; } = 18f; + public float SpaceBelow { get; init; } = 6f; + public float TrackPx { get; init; } = 1.6f; + public float ChevronGap { get; init; } = 7f; } -// Collapsible section heading. Replaces ImGui.CollapsingHeader, whose framed -// bar is the single most ImGui-looking element in the settings window. +// Collapsible section heading, drawn as typography rather than as a bar. +// +// The bar version failed a real test: it read fine against blue themes and +// disappeared against violet ones, because its only distinction from a normal +// row was a fill colour. Small caps with wide tracking carries the same weight +// in every palette, since the difference is shape, not hue. // // State lives here rather than in ImGui's per-window storage: that keys off the // label, so once the titles are localised the open/closed state would reset on @@ -44,14 +48,16 @@ internal static class SectionHeader string title, string? description, SectionHeaderColors colors, - bool defaultOpen = false, + bool defaultOpen = true, bool disabled = false, SectionHeaderStyle? styleOverride = null ) { var style = styleOverride ?? new SectionHeaderStyle(); var scale = Metrics.Scale; - var padY = style.PadY * scale; + var spaceAbove = style.SpaceAbove * scale; + var spaceBelow = style.SpaceBelow * scale; + var track = style.TrackPx * scale; if (!Open.TryGetValue(key, out var open)) { @@ -61,18 +67,19 @@ internal static class SectionHeader var origin = ImGui.GetCursorScreenPos(); var width = ImGui.GetContentRegionAvail().X; - var titleHeight = ImGui.GetTextLineHeight(); + var lineHeight = ImGui.GetTextLineHeight(); + var chevronWidth = lineHeight * 0.45f + style.ChevronGap * scale; - // The description starts after the chevron, so its wrap width is the - // remainder of the row. Measuring it matters more here than in a setting - // row: nothing clips this text, so an unreserved second line would draw - // straight over the border line and whatever the caller renders next. - var leadWidth = (style.AccentBarWidth + style.ChevronInset * 2f) * scale; - var descWrap = width - leadWidth; + var descWrap = width - chevronWidth; var descHeight = description is null ? 0f : ImGui.CalcTextSize(description, false, descWrap).Y; - var size = WidgetGeometry.SectionHeader(width, titleHeight, descHeight, padY, 1f * scale); + + // The rule sits on the baseline gap, not on its own row. + var size = new Vector2( + width, + spaceAbove + lineHeight + spaceBelow + descHeight + (description is null ? 0f : 2f) + ); ImGui.SetCursorScreenPos(origin); var clicked = ImGui.InvisibleButton($"##hellion-section-{key}", size) && !disabled; @@ -86,91 +93,88 @@ internal static class SectionHeader } // BeginDisabled only dims ImGui's own widgets, so a draw-list header - // would stay at full opacity while everything around it fades. The - // ThemePicker wraps two of its headers exactly like that. + // would stay at full opacity while everything around it fades. var alpha = disabled ? 0.5f : 1f; var dl = ImGui.GetWindowDrawList(); - var max = origin + size; + var textY = origin.Y + spaceAbove; - dl.AddRectFilled(origin, max, ColourUtil.ApplyAlpha(colors.SurfaceAbgr, alpha)); - - if (hoverAmount > 0f) - dl.AddRectFilled( - origin, - max, - ColourUtil.ApplyAlpha(colors.HoverAbgr, hoverAmount * alpha) - ); - - // Full height, not just the title line. Stopping at the title left the - // bar looking like a stray tick next to a two-line heading. - var barWidth = style.AccentBarWidth * scale; - dl.AddRectFilled( - origin, - new Vector2(origin.X + barWidth, max.Y), - ColourUtil.ApplyAlpha(colors.AccentAbgr, alpha) + // Hover brightens the heading itself. There is no plate to tint, and + // tinting the empty band above it would look like a stray selection. + var titleAbgr = ColourUtil.ApplyAlpha( + ColourUtil.Lerp(colors.TitleAbgr, colors.AccentAbgr, hoverAmount), + alpha ); - var textX = origin.X + leadWidth; - dl.PushClipRect(origin, max, true); - dl.AddText( - new Vector2(textX, origin.Y + padY), - ColourUtil.ApplyAlpha(colors.TitleAbgr, alpha), - title - ); - dl.PopClipRect(); - DrawChevron( dl, - new Vector2( - origin.X + barWidth + style.ChevronInset * scale, - origin.Y + padY + titleHeight * 0.5f - ), - titleHeight * 0.28f, + new Vector2(origin.X + lineHeight * 0.18f, textY + lineHeight * 0.5f), + lineHeight * 0.26f, open, - ColourUtil.ApplyAlpha(colors.TitleAbgr, alpha) + ColourUtil.ApplyAlpha(colors.AccentAbgr, alpha) ); + // Upper-cased for the tracking to land: wide spacing between lowercase + // letters reads as a rendering fault, between caps as deliberate. + var textX = origin.X + chevronWidth; + dl.PushClipRect(origin, origin + size, true); + var titleWidth = dl.DrawTrackedText( + new Vector2(textX, textY), + title.ToUpperInvariant(), + titleAbgr, + track + ); + + // Starts where the title ends and fades into nothing, so it reads as a + // continuation of the heading rather than as a box lid. + var ruleX = textX + titleWidth + 10f * scale; + var ruleWidth = origin.X + width - ruleX; + if (ruleWidth > 0f) + dl.DrawFadeRule( + new Vector2(ruleX, textY + lineHeight * 0.5f), + ruleWidth, + ColourUtil.ApplyAlpha(colors.BorderAbgr, alpha), + MathF.Max(1f, scale) + ); + if (description is not null) - { - dl.PushClipRect(origin, max, true); dl.AddText( ImGui.GetFont(), ImGui.GetFontSize(), - new Vector2(textX, origin.Y + padY + titleHeight), + new Vector2(textX, textY + lineHeight + 2f * scale), ColourUtil.ApplyAlpha(colors.DescriptionAbgr, alpha), description, descWrap ); - dl.PopClipRect(); - } - - dl.AddLine( - new Vector2(origin.X, max.Y - 1f * scale), - new Vector2(max.X, max.Y - 1f * scale), - ColourUtil.ApplyAlpha(colors.BorderAbgr, alpha), - 1f * scale - ); + dl.PopClipRect(); + // ItemSize, not SetCursorScreenPos: it advances the cursor AND extends + // CursorMaxPos, which is what the scrollbar measures. ImGui.SetCursorScreenPos(origin); ImGuiP.ItemSize(new Vector2(width, size.Y - ImGui.GetStyle().ItemSpacing.Y)); return open; } - private static void DrawChevron(ImDrawListPtr dl, Vector2 centre, float r, bool open, uint abgr) + private static void DrawChevron( + ImDrawListPtr dl, + Vector2 centre, + float radius, + bool open, + uint abgr + ) { if (open) dl.AddTriangleFilled( - centre + new Vector2(-r, -r * 0.5f), - centre + new Vector2(r, -r * 0.5f), - centre + new Vector2(0f, r * 0.7f), + new Vector2(centre.X - radius, centre.Y - radius * 0.5f), + new Vector2(centre.X + radius, centre.Y - radius * 0.5f), + new Vector2(centre.X, centre.Y + radius * 0.75f), abgr ); else dl.AddTriangleFilled( - centre + new Vector2(-r * 0.5f, -r), - centre + new Vector2(r * 0.7f, 0f), - centre + new Vector2(-r * 0.5f, r), + new Vector2(centre.X - radius * 0.5f, centre.Y - radius), + new Vector2(centre.X - radius * 0.5f, centre.Y + radius), + new Vector2(centre.X + radius * 0.75f, centre.Y), abgr ); } diff --git a/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs index acbd32f..98e78a9 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs @@ -20,8 +20,12 @@ internal readonly record struct SegmentedControlStyle { public SegmentedControlStyle() { } - public float Rounding { get; init; } = 4f; + public float Rounding { get; init; } = 3f; public float Inset { get; init; } = 2f; + + // Chamfer on the selected segment. The plugin already had DrawSlipPolygon + // for exactly this cut and had never called it once. + public float Chamfer { get; init; } = 5f; } // One setting, n mutually exclusive choices, one control. A radio group spends @@ -65,11 +69,12 @@ internal static class SegmentedControl var alpha = disabled ? 0.5f : 1f; var dl = ImGui.GetWindowDrawList(); - dl.AddRectFilled( + dl.DrawVerticalGradient( origin, origin + new Vector2(width, height), ColourUtil.ApplyAlpha(colors.TrackAbgr, alpha), - rounding + topLift: 0.04f, + bottomDrop: 0.06f ); var picked = selected; @@ -91,12 +96,33 @@ internal static class SegmentedControl var isSelected = i == selected; if (isSelected) - dl.AddRectFilled( - segOrigin + new Vector2(inset, inset), - segOrigin + segSize - new Vector2(inset, inset), - ColourUtil.ApplyAlpha(colors.SelectedAbgr, alpha), - rounding + { + // Chamfered rather than rounded: the cut corner is the shape the + // rest of the plugin's HUD language uses, and it distinguishes + // the active segment by silhouette instead of by fill alone. + var min = segOrigin + new Vector2(inset, inset); + var max = segOrigin + segSize - new Vector2(inset, inset); + var fill = ColourUtil.ApplyAlpha(colors.SelectedAbgr, alpha); + var cham = style.Chamfer * scale; + dl.DrawSlipPolygon(min, max, ColourUtil.RgbaToAbgr(fill), cham); + + // Highlight as a second, shorter chamfered shape rather than a + // clipped gradient: PushClipRect is rectangular and would square + // the cut corner straight back off. Same silhouette, half the + // height, lifted toward white -- it reads as light from above + // without touching the outline. + dl.DrawSlipPolygon( + min, + new Vector2(max.X, min.Y + (max.Y - min.Y) * 0.5f), + ColourUtil.RgbaToAbgr( + ColourUtil.ApplyAlpha( + ColourUtil.LerpTowardWhite(fill, 0.16f), + 0.55f * alpha + ) + ), + cham ); + } else if (hoverAmount > 0f) dl.AddRectFilled( segOrigin + new Vector2(inset, inset), diff --git a/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs index 6f4d1b3..1df7df2 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SettingRow.cs @@ -18,10 +18,14 @@ internal readonly record struct SettingRowStyle { public SettingRowStyle() { } - public float PadY { get; init; } = 4f; + public float PadY { get; init; } = 7f; public float Gap { get; init; } = 12f; public float PreferredControlWidth { get; init; } = 200f; - public bool DrawSeparator { get; init; } = true; + + // Off by default. A rule under every row turns a settings page into a + // ledger; the hover fill already tells the reader where a row begins and + // ends, and it only appears where the pointer is. + public bool DrawSeparator { get; init; } } // Handed to the control callback. Widgets that respect SetNextItemWidth can diff --git a/HellionChat/Util/ColourUtil.cs b/HellionChat/Util/ColourUtil.cs index 462ac5e..5c203a9 100755 --- a/HellionChat/Util/ColourUtil.cs +++ b/HellionChat/Util/ColourUtil.cs @@ -125,6 +125,20 @@ internal static class ColourUtil internal static uint OnColour(uint background, uint light, uint dark) => Luminance(background) > 0.5f ? dark : light; + // Mixes an ABGR colour's RGB channels toward black by factor t, alpha + // untouched. The counterpart to LerpTowardWhite, and the reason both exist + // rather than AdjustBrightness: this plugin's surfaces sit near black, where + // a multiplier has almost nothing to scale. 12 * 1.15 is still 13. + internal static uint LerpTowardBlack(uint abgr, float t) + { + t = Math.Clamp(t, 0f, 1f); + var a = (byte)((abgr >> 24) & 0xFFu); + var b = (byte)Math.Round(((abgr >> 16) & 0xFFu) * (1f - t)); + var g = (byte)Math.Round(((abgr >> 8) & 0xFFu) * (1f - t)); + var r = (byte)Math.Round((abgr & 0xFFu) * (1f - t)); + return ((uint)a << 24) | ((uint)b << 16) | ((uint)g << 8) | r; + } + internal static uint LerpTowardWhite(uint abgr, float t) { t = Math.Clamp(t, 0f, 1f); From 76416afe3ac648c0d31a629d411336f72c9a53cb Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 17:50:32 +0200 Subject: [PATCH 082/129] feat(style): depth for the settings window Brief was a mix of Lightless and Character Select+, modern without being overloaded, and the gradient allowed to show more. The content pane gets a stronger gradient plus an accent wash falling from the top edge to about a third of the height. Without it the pane was a neutral box that happened to sit inside a themed window; now the theme reaches the surface being read. Ambient motes drift up behind it. Procedural rather than the image sequence Character Select+ uses, so there are no assets to ship and they take the theme's accent instead of whatever was baked into a PNG. Fixed count, fixed arrays, seeded once, so the layout is identical every session and nothing allocates per frame. They advance once per frame regardless of how many surfaces call in, and ReduceMotion skips them entirely -- same contract HoverState already honours. The tab sidebar was the last stock-ImGui element and had started to look it. Its entries are drawn now: darker plane than the content pane so the two read as separate surfaces, a chamfered plate with a solid leading bar for the active one, hover fading between muted and full text. Its icons go through the draw list with a font pointer taken outside the scope, rather than being drawn inside a FontAwesome push. That atlas has no ASCII glyphs, so any label caught inside such a scope renders blank -- which has already happened twice in this plugin, once in the tooltips and once in a badge. --- HellionChat/PluginHostFactory.cs | 4 +- .../Ui/Components/Settings/ContentArea.cs | 48 +++--- .../Ui/Components/Settings/TabSidebar.cs | 139 ++++++++++++++++-- .../Ui/StyleEngine/AmbientParticles.cs | 102 +++++++++++++ 4 files changed, 258 insertions(+), 35 deletions(-) create mode 100644 HellionChat/Ui/StyleEngine/AmbientParticles.cs diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 83fba5f..3e05ec5 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -160,7 +160,9 @@ internal static class PluginHostFactory () => sp.GetRequiredService().MainWindow.UserHide() )); services.AddSingleton(sp => new Ui.Components.Settings.TabSidebar( - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.ContentArea( sp.GetRequiredService(), diff --git a/HellionChat/Ui/Components/Settings/ContentArea.cs b/HellionChat/Ui/Components/Settings/ContentArea.cs index ed72173..5ccdd05 100644 --- a/HellionChat/Ui/Components/Settings/ContentArea.cs +++ b/HellionChat/Ui/Components/Settings/ContentArea.cs @@ -11,6 +11,7 @@ internal sealed class ContentArea { private readonly ThemeRegistry _themes; private readonly TokenResolver _resolver; + private readonly AmbientParticles _motes = new(); public ContentArea(ThemeRegistry themes, TokenResolver resolver) { @@ -20,19 +21,12 @@ internal sealed class ContentArea public void Draw(string activeTab, Action renderTab) { - // ChildBg was never set, so the pane inherited whatever showed through - // the window -- and the window is translucent by default. Settings text - // ended up sitting on moving scenery, which is legible in a screenshot - // and not while the game is running. - // - // Pushed as a colour rather than painted into the draw list so ImGui - // fills it before the scrollbar and border, and the window's own opacity - // still applies on top. var colors = _themes.Active.Colors; - // Transparent child, gradient painted underneath. ChildBg only takes a - // flat colour, and a flat pane is what made the window read as a form - // dump: nothing told the eye where the surface started. + // Transparent child with the ground painted by hand. ChildBg takes one + // flat colour and nothing else, so a gradient, an accent wash or motes + // are all impossible through it -- and the pane had no ground at all + // before, which left settings text sitting on the moving game world. using var bg = ImRaii.PushColor(ImGuiCol.ChildBg, 0u); using var child = ImRaii.Child("##settings-content", new Vector2(0, 0), true); if (!child.Success) @@ -40,16 +34,30 @@ internal sealed class ContentArea return; } - // GetWindowDrawList inside the child is the child's own list, so this - // lands behind its content without a channel split. + // GetWindowDrawList inside a child is that child's own list, so all of + // this lands behind its content without a channel split. + var dl = ImGui.GetWindowDrawList(); + var min = ImGui.GetWindowPos(); + var max = min + ImGui.GetWindowSize(); var surface = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceBase, colors)); - ImGui - .GetWindowDrawList() - .DrawVerticalGradient( - ImGui.GetWindowPos(), - ImGui.GetWindowPos() + ImGui.GetWindowSize(), - surface - ); + var accent = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.AccentPrimary, colors)); + + dl.DrawVerticalGradient(min, max, surface, topLift: 0.20f, bottomDrop: 0.14f); + + // A wash of the accent down from the top edge, fading to nothing by + // roughly a third of the height. Without it the pane is a neutral box + // that happens to sit under a themed window; with it the theme reaches + // the surface the user is actually reading. + dl.AddRectFilledMultiColor( + min, + new Vector2(max.X, min.Y + (max.Y - min.Y) * 0.38f), + ColourUtil.ApplyAlpha(accent, 0.10f), + ColourUtil.ApplyAlpha(accent, 0.05f), + accent & 0x00FFFFFFu, + accent & 0x00FFFFFFu + ); + + _motes.Draw(dl, min, max, accent); renderTab(activeTab); } diff --git a/HellionChat/Ui/Components/Settings/TabSidebar.cs b/HellionChat/Ui/Components/Settings/TabSidebar.cs index e040c97..15ea805 100644 --- a/HellionChat/Ui/Components/Settings/TabSidebar.cs +++ b/HellionChat/Ui/Components/Settings/TabSidebar.cs @@ -2,51 +2,162 @@ using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility.Raii; +using HellionChat.Themes; +using HellionChat.Ui.StyleEngine; +using HellionChat.Util; namespace HellionChat.Ui.Components.Settings; internal sealed class TabSidebar { private readonly FontManager _fonts; + private readonly ThemeRegistry _themes; + private readonly TokenResolver _resolver; public event Action? OnTabSelected; public string ActiveTab { get; private set; } = "general"; - public TabSidebar(FontManager fonts) + public TabSidebar(FontManager fonts, ThemeRegistry themes, TokenResolver resolver) { _fonts = fonts; + _themes = themes; + _resolver = resolver; } public void Draw() { + using var bg = ImRaii.PushColor(ImGuiCol.ChildBg, 0u); using var child = ImRaii.Child("##settings-tab-sidebar", new Vector2(170, 0), true); if (!child.Success) { return; } - DrawEntry("general", FontAwesomeIcon.SlidersH, "General"); - DrawEntry("appearance", FontAwesomeIcon.Palette, "Appearance"); - DrawEntry("chat", FontAwesomeIcon.Comments, "Chat"); - DrawEntry("window", FontAwesomeIcon.WindowMaximize, "Window"); - DrawEntry("channels", FontAwesomeIcon.Hashtag, "Channels"); - DrawEntry("data-privacy", FontAwesomeIcon.Shield, "Data & Privacy"); - DrawEntry("about", FontAwesomeIcon.InfoCircle, "About"); - } + var colors = _themes.Active.Colors; + var dl = ImGui.GetWindowDrawList(); + var min = ImGui.GetWindowPos(); + var max = min + ImGui.GetWindowSize(); - private void DrawEntry(string id, FontAwesomeIcon icon, string label) - { + var surface = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceBase, colors)); + var accent = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.AccentPrimary, colors)); + + // Darker than the content pane so the two read as separate planes rather + // than one wide surface with a line drawn down it. + dl.DrawVerticalGradient( + min, + max, + ColourUtil.LerpTowardBlack(surface, 0.25f), + topLift: 0.10f, + bottomDrop: 0.06f + ); + + // The icon font has no ASCII glyphs, so anything textual drawn inside a + // FontAwesome scope comes out blank -- twice bitten in this plugin. The + // handle is taken here and the glyphs are drawn through the draw list + // instead, which keeps the scope off the labels entirely. + ImFontPtr iconFont; + float iconSize; using (_fonts.FontAwesome.Push()) { - ImGui.TextUnformatted(icon.ToIconString()); + iconFont = ImGui.GetFont(); + iconSize = ImGui.GetFontSize(); } - ImGui.SameLine(); + + var palette = new SidebarPalette + { + Text = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.Text, colors)), + TextMuted = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.TextMuted, colors)), + Accent = accent, + Hover = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceHover, colors)), + IconFont = iconFont, + IconSize = iconSize, + }; + + DrawEntry("general", FontAwesomeIcon.SlidersH, "General", palette); + DrawEntry("appearance", FontAwesomeIcon.Palette, "Appearance", palette); + DrawEntry("chat", FontAwesomeIcon.Comments, "Chat", palette); + DrawEntry("window", FontAwesomeIcon.WindowMaximize, "Window", palette); + DrawEntry("channels", FontAwesomeIcon.Hashtag, "Channels", palette); + DrawEntry("data-privacy", FontAwesomeIcon.Shield, "Data & Privacy", palette); + DrawEntry("about", FontAwesomeIcon.InfoCircle, "About", palette); + } + + private readonly record struct SidebarPalette + { + public required uint Text { get; init; } + public required uint TextMuted { get; init; } + public required uint Accent { get; init; } + public required uint Hover { get; init; } + public required ImFontPtr IconFont { get; init; } + public required float IconSize { get; init; } + } + + private void DrawEntry(string id, FontAwesomeIcon icon, string label, SidebarPalette p) + { + var scale = Metrics.Scale; + var origin = ImGui.GetCursorScreenPos(); + var width = ImGui.GetContentRegionAvail().X; + var height = ImGui.GetTextLineHeight() + 12f * scale; + var size = new Vector2(width, height); var selected = ActiveTab == id; - if (ImGui.Selectable($" {label}##tab-{id}", selected)) + var clicked = ImGui.InvisibleButton($"##tab-{id}", size); + var hovered = ImGui.IsItemHovered(); + var hoverAmount = HoverState.Query(ImGui.GetID($"sidebar.{id}"), hovered); + + if (clicked) { ActiveTab = id; OnTabSelected?.Invoke(id); } + + var dl = ImGui.GetWindowDrawList(); + + if (selected) + { + // Chamfered plate plus a solid bar on the leading edge. The bar is + // what survives at a glance; the plate gives it something to sit on. + dl.DrawSlipPolygon( + origin, + origin + size, + ColourUtil.RgbaToAbgr(ColourUtil.ApplyAlpha(p.Accent, 0.22f)), + 6f * scale + ); + dl.AddRectFilled( + origin, + new Vector2(origin.X + 2.5f * scale, origin.Y + size.Y), + p.Accent + ); + } + else if (hoverAmount > 0f) + dl.AddRectFilled( + origin, + origin + size, + ColourUtil.ApplyAlpha(p.Hover, hoverAmount * 0.7f) + ); + + var contentColour = selected ? p.Text : ColourUtil.Lerp(p.TextMuted, p.Text, hoverAmount); + + var iconX = origin.X + 12f * scale; + dl.AddText( + p.IconFont, + p.IconSize, + new Vector2(iconX, origin.Y + MetricsMath.Center(height, p.IconSize)), + selected ? p.Accent : contentColour, + icon.ToIconString() + ); + + var labelSize = ImGui.CalcTextSize(label); + dl.AddText( + new Vector2( + iconX + p.IconSize + 9f * scale, + origin.Y + MetricsMath.Center(height, labelSize.Y) + ), + contentColour, + label + ); + + ImGui.SetCursorScreenPos(origin); + ImGuiP.ItemSize(new Vector2(width, size.Y - ImGui.GetStyle().ItemSpacing.Y + 3f * scale)); } } diff --git a/HellionChat/Ui/StyleEngine/AmbientParticles.cs b/HellionChat/Ui/StyleEngine/AmbientParticles.cs new file mode 100644 index 0000000..c42f21e --- /dev/null +++ b/HellionChat/Ui/StyleEngine/AmbientParticles.cs @@ -0,0 +1,102 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine; + +// Slow motes drifting up behind a surface. Character Select+ does its fog with +// an image sequence; this is procedural instead, so there are no assets to ship, +// no texture handles to keep alive, and it takes the theme's accent colour +// rather than whatever was baked into a PNG. +// +// Deliberately faint and deliberately slow. The brief was "modern, not +// overloaded" -- at this opacity it reads as depth rather than as motion, and +// nothing here competes with the text on top of it. +internal sealed class AmbientParticles +{ + private readonly struct Mote + { + public required float X { get; init; } // 0..1 across the surface + public required float Speed { get; init; } // fractions of height per second + public required float Radius { get; init; } + public required float Phase { get; init; } // sway offset, keeps them out of lockstep + public required float Sway { get; init; } + } + + private readonly Mote[] _motes; + private readonly float[] _y; + private int _lastFrame = -1; + + internal AmbientParticles(int count = 24, int seed = 0x48454C4C) + { + // Seeded rather than time-based: the layout is identical every session, + // so a screenshot taken today matches one taken tomorrow. + var rng = new Random(seed); + _motes = new Mote[count]; + _y = new float[count]; + + for (var i = 0; i < count; i++) + { + _motes[i] = new Mote + { + X = (float)rng.NextDouble(), + Speed = 0.012f + (float)rng.NextDouble() * 0.022f, + Radius = 1f + (float)rng.NextDouble() * 1.6f, + Phase = (float)rng.NextDouble() * MathF.Tau, + Sway = 0.004f + (float)rng.NextDouble() * 0.010f, + }; + _y[i] = (float)rng.NextDouble(); + } + } + + internal void Draw(ImDrawListPtr dl, Vector2 min, Vector2 max, uint accentAbgr) + { + if (Plugin.Config.ReduceMotion) + return; + + var size = max - min; + if (size.X <= 0f || size.Y <= 0f) + return; + + // One advance per frame, not per call: the settings window draws this + // once, but a caller that draws two surfaces would otherwise run the + // simulation at double speed. + var frame = ImGui.GetFrameCount(); + var advance = frame != _lastFrame; + if (advance) + _lastFrame = frame; + + var dt = Math.Min(ImGui.GetIO().DeltaTime, 0.1f); + var time = (float)ImGui.GetTime(); + + for (var i = 0; i < _motes.Length; i++) + { + var m = _motes[i]; + + if (advance) + { + _y[i] -= m.Speed * dt; + if (_y[i] < -0.05f) + _y[i] += 1.1f; + } + + var sway = MathF.Sin(time * 0.35f + m.Phase) * m.Sway; + var pos = new Vector2(min.X + (m.X + sway) * size.X, min.Y + _y[i] * size.Y); + + if (pos.X < min.X || pos.X > max.X || pos.Y < min.Y || pos.Y > max.Y) + continue; + + // Fades out at both ends of its travel, so motes appear and vanish + // instead of popping at the edge. + var edge = Math.Clamp(MathF.Min(_y[i], 1f - _y[i]) * 6f, 0f, 1f); + var pulse = 0.55f + 0.45f * MathF.Sin(time * 0.6f + m.Phase); + + dl.AddCircleFilled( + pos, + m.Radius * Metrics.Scale, + ColourUtil.ApplyAlpha(accentAbgr, 0.16f * edge * pulse), + 8 + ); + } + } +} From 8ebed6846d84f37582ef781b072a16eca2c30543 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 17:55:44 +0200 Subject: [PATCH 083/129] feat(style): put the chat log on the same floor as the settings The motes were too subtle to register, so: seventy instead of twenty-four, wider radius range, and each one is a soft halo with a brighter core rather than a flat disc. At this size a single filled circle reads as a speck of dirt on the screen; the ring around it is what makes it look lit. The backdrop moves into its own component and the chat log gets it too. Two windows in one plugin looking like two separate products was half the reason the settings window read as untouched -- the chat had been reworked in v1.10.0 and the settings had not, and no amount of work inside one window closes that gap. No accent wash over the message list. It works on a settings pane, where the top of the surface is a heading; over a chat log the oldest visible messages would sit in a tinted band and read as highlighted. Registered transient rather than singleton, so two surfaces on screen own separate mote sets instead of sharing one drift pattern -- which would be visible the moment a popout sits next to the main window. MainWindow's constructor changed, so this needs the DI smoke pass. --- HellionChat/PluginHostFactory.cs | 13 +++- .../Ui/Components/Settings/ContentArea.cs | 38 ++---------- .../Ui/StyleEngine/AmbientParticles.cs | 22 +++++-- HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs | 59 +++++++++++++++++++ HellionChat/Ui/Windows/MainWindow.cs | 17 +++++- 5 files changed, 103 insertions(+), 46 deletions(-) create mode 100644 HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 3e05ec5..e729aaf 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -101,6 +101,13 @@ internal static class PluginHostFactory )); services.AddSingleton(_ => new Ui.StyleEngine.TokenResolver()); + + // Transient: each surface owns its motes, so two backdrops on screen do + // not drift in lockstep. + services.AddTransient(sp => new Ui.StyleEngine.SurfaceBackdrop( + sp.GetRequiredService(), + sp.GetRequiredService() + )); services.AddSingleton(sp => new Ui.StyleEngine.PushStack( sp.GetRequiredService() )); @@ -165,8 +172,7 @@ internal static class PluginHostFactory sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.ContentArea( - sp.GetRequiredService(), - sp.GetRequiredService() + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.ThemePicker( sp.GetRequiredService(), @@ -241,7 +247,8 @@ internal static class PluginHostFactory sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Integrations.FailedTellNotifier( sp.GetRequiredService>() diff --git a/HellionChat/Ui/Components/Settings/ContentArea.cs b/HellionChat/Ui/Components/Settings/ContentArea.cs index 5ccdd05..71e872a 100644 --- a/HellionChat/Ui/Components/Settings/ContentArea.cs +++ b/HellionChat/Ui/Components/Settings/ContentArea.cs @@ -1,28 +1,21 @@ using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Interface.Utility.Raii; -using HellionChat.Themes; using HellionChat.Ui.StyleEngine; -using HellionChat.Util; namespace HellionChat.Ui.Components.Settings; internal sealed class ContentArea { - private readonly ThemeRegistry _themes; - private readonly TokenResolver _resolver; - private readonly AmbientParticles _motes = new(); + private readonly SurfaceBackdrop _backdrop; - public ContentArea(ThemeRegistry themes, TokenResolver resolver) + public ContentArea(SurfaceBackdrop backdrop) { - _themes = themes; - _resolver = resolver; + _backdrop = backdrop; } public void Draw(string activeTab, Action renderTab) { - var colors = _themes.Active.Colors; - // Transparent child with the ground painted by hand. ChildBg takes one // flat colour and nothing else, so a gradient, an accent wash or motes // are all impossible through it -- and the pane had no ground at all @@ -34,30 +27,7 @@ internal sealed class ContentArea return; } - // GetWindowDrawList inside a child is that child's own list, so all of - // this lands behind its content without a channel split. - var dl = ImGui.GetWindowDrawList(); - var min = ImGui.GetWindowPos(); - var max = min + ImGui.GetWindowSize(); - var surface = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceBase, colors)); - var accent = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.AccentPrimary, colors)); - - dl.DrawVerticalGradient(min, max, surface, topLift: 0.20f, bottomDrop: 0.14f); - - // A wash of the accent down from the top edge, fading to nothing by - // roughly a third of the height. Without it the pane is a neutral box - // that happens to sit under a themed window; with it the theme reaches - // the surface the user is actually reading. - dl.AddRectFilledMultiColor( - min, - new Vector2(max.X, min.Y + (max.Y - min.Y) * 0.38f), - ColourUtil.ApplyAlpha(accent, 0.10f), - ColourUtil.ApplyAlpha(accent, 0.05f), - accent & 0x00FFFFFFu, - accent & 0x00FFFFFFu - ); - - _motes.Draw(dl, min, max, accent); + _backdrop.Draw(); renderTab(activeTab); } diff --git a/HellionChat/Ui/StyleEngine/AmbientParticles.cs b/HellionChat/Ui/StyleEngine/AmbientParticles.cs index c42f21e..72a5805 100644 --- a/HellionChat/Ui/StyleEngine/AmbientParticles.cs +++ b/HellionChat/Ui/StyleEngine/AmbientParticles.cs @@ -27,7 +27,7 @@ internal sealed class AmbientParticles private readonly float[] _y; private int _lastFrame = -1; - internal AmbientParticles(int count = 24, int seed = 0x48454C4C) + internal AmbientParticles(int count = 70, int seed = 0x48454C4C) { // Seeded rather than time-based: the layout is identical every session, // so a screenshot taken today matches one taken tomorrow. @@ -40,8 +40,8 @@ internal sealed class AmbientParticles _motes[i] = new Mote { X = (float)rng.NextDouble(), - Speed = 0.012f + (float)rng.NextDouble() * 0.022f, - Radius = 1f + (float)rng.NextDouble() * 1.6f, + Speed = 0.014f + (float)rng.NextDouble() * 0.030f, + Radius = 1.1f + (float)rng.NextDouble() * 2.6f, Phase = (float)rng.NextDouble() * MathF.Tau, Sway = 0.004f + (float)rng.NextDouble() * 0.010f, }; @@ -90,12 +90,22 @@ internal sealed class AmbientParticles // instead of popping at the edge. var edge = Math.Clamp(MathF.Min(_y[i], 1f - _y[i]) * 6f, 0f, 1f); var pulse = 0.55f + 0.45f * MathF.Sin(time * 0.6f + m.Phase); + var radius = m.Radius * Metrics.Scale; + // Halo first, core on top. A flat disc at this size reads as a + // speck of dirt on the screen; the soft ring around it is what makes + // it look lit. dl.AddCircleFilled( pos, - m.Radius * Metrics.Scale, - ColourUtil.ApplyAlpha(accentAbgr, 0.16f * edge * pulse), - 8 + radius * 2.1f, + ColourUtil.ApplyAlpha(accentAbgr, 0.13f * edge * pulse), + 10 + ); + dl.AddCircleFilled( + pos, + radius, + ColourUtil.ApplyAlpha(accentAbgr, 0.42f * edge * pulse), + 10 ); } } diff --git a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs new file mode 100644 index 0000000..d724aa5 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs @@ -0,0 +1,59 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using HellionChat.Themes; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine; + +// The ground under a scrolling surface: gradient, accent wash, drifting motes. +// Pulled out of the settings pane so the chat log can stand on the same floor -- +// two windows in one plugin looking like two different products was half of why +// the settings window read as untouched. +// +// Each instance owns its motes, so two surfaces on screen at once do not share +// one drift pattern. +internal sealed class SurfaceBackdrop +{ + private readonly ThemeRegistry _themes; + private readonly TokenResolver _resolver; + private readonly AmbientParticles _motes; + + internal SurfaceBackdrop(ThemeRegistry themes, TokenResolver resolver, int moteCount = 70) + { + _themes = themes; + _resolver = resolver; + _motes = new AmbientParticles(moteCount); + } + + // Call right after entering a child, before its content. GetWindowDrawList + // is then that child's own list, so everything lands behind the content + // without a channel split. + internal void Draw(float accentWashHeight = 0.38f, float darken = 0f, bool motes = true) + { + var dl = ImGui.GetWindowDrawList(); + var min = ImGui.GetWindowPos(); + var max = min + ImGui.GetWindowSize(); + var colors = _themes.Active.Colors; + + var surface = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceBase, colors)); + if (darken > 0f) + surface = ColourUtil.LerpTowardBlack(surface, darken); + + var accent = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.AccentPrimary, colors)); + + dl.DrawVerticalGradient(min, max, surface, topLift: 0.20f, bottomDrop: 0.14f); + + if (accentWashHeight > 0f) + dl.AddRectFilledMultiColor( + min, + new Vector2(max.X, min.Y + (max.Y - min.Y) * accentWashHeight), + ColourUtil.ApplyAlpha(accent, 0.10f), + ColourUtil.ApplyAlpha(accent, 0.05f), + accent & 0x00FFFFFFu, + accent & 0x00FFFFFFu + ); + + if (motes) + _motes.Draw(dl, min, max, accent); + } +} diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index a65460e..8c3de06 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -31,6 +31,7 @@ internal sealed class MainWindow : Window, IFocusableChatWindow private readonly Components.StatusBar _status; private readonly Lender _handlerLender; private readonly ChannelPopoutPool _pool; + private readonly Ui.StyleEngine.SurfaceBackdrop _backdrop; private Tab? _activeTab; @@ -53,7 +54,8 @@ internal sealed class MainWindow : Window, IFocusableChatWindow Components.InputBar input, Components.StatusBar status, Lender handlerLender, - ChannelPopoutPool pool + ChannelPopoutPool pool, + Ui.StyleEngine.SurfaceBackdrop backdrop ) : base($"{Plugin.PluginName}###hellion-main") { @@ -65,6 +67,7 @@ internal sealed class MainWindow : Window, IFocusableChatWindow _status = status; _handlerLender = handlerLender; _pool = pool; + _backdrop = backdrop; Size = new Vector2(DefaultWidth, DefaultHeight); SizeCondition = ImGuiCond.FirstUseEver; @@ -380,8 +383,16 @@ internal sealed class MainWindow : Window, IFocusableChatWindow ) ) { - if (messages.Success && _activeTab is not null) - _messages.Draw(_activeTab); + if (messages.Success) + { + // No accent wash here. It works on a settings pane, where the + // top of the surface is a heading; over a chat log the first + // messages would sit in a tinted band and read as highlighted. + _backdrop.Draw(accentWashHeight: 0f); + + if (_activeTab is not null) + _messages.Draw(_activeTab); + } } // Inside-mode inline render: measure first so PreviewHeight is fresh From 8b96ffb2eca52e34b6485d761c019414ae706d5e Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 18:01:07 +0200 Subject: [PATCH 084/129] fix(style): let the backdrop follow the window's own opacity Three problems from one screenshot, and the same root cause behind two of them. The pane painted at full opacity regardless of the window it sat in. BgAlpha only ever reaches WindowBg, so a draw-list fill ignores it entirely, and a deliberately translucent settings window came out as a solid block beside a translucent chat window. The backdrop now reads the alpha out of the resolved WindowBg colour and carries it through the gradient, the accent wash and the motes. The banding is 8-bit quantisation, not a rendering fault: a gentle ramp across a tall surface crosses so few distinct values that each one covers a visible band. Halving the range leaves fewer and fainter steps, and the translucency now underneath them breaks up what remains. Motes over the chat log drop to 18%. A settings page is read in glances and can carry motion behind it; a chat log is read line by line, where anything drifting behind the text competes with it. Intensity is a parameter rather than a second particle system, so the two surfaces share one implementation. --- .../Ui/StyleEngine/AmbientParticles.cs | 12 +++++++--- HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs | 24 ++++++++++++++----- HellionChat/Ui/Windows/MainWindow.cs | 9 +++---- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/HellionChat/Ui/StyleEngine/AmbientParticles.cs b/HellionChat/Ui/StyleEngine/AmbientParticles.cs index 72a5805..ccc25ad 100644 --- a/HellionChat/Ui/StyleEngine/AmbientParticles.cs +++ b/HellionChat/Ui/StyleEngine/AmbientParticles.cs @@ -49,7 +49,13 @@ internal sealed class AmbientParticles } } - internal void Draw(ImDrawListPtr dl, Vector2 min, Vector2 max, uint accentAbgr) + internal void Draw( + ImDrawListPtr dl, + Vector2 min, + Vector2 max, + uint accentAbgr, + float intensity = 1f + ) { if (Plugin.Config.ReduceMotion) return; @@ -98,13 +104,13 @@ internal sealed class AmbientParticles dl.AddCircleFilled( pos, radius * 2.1f, - ColourUtil.ApplyAlpha(accentAbgr, 0.13f * edge * pulse), + ColourUtil.ApplyAlpha(accentAbgr, 0.13f * edge * pulse * intensity), 10 ); dl.AddCircleFilled( pos, radius, - ColourUtil.ApplyAlpha(accentAbgr, 0.42f * edge * pulse), + ColourUtil.ApplyAlpha(accentAbgr, 0.42f * edge * pulse * intensity), 10 ); } diff --git a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs index d724aa5..d62d5c7 100644 --- a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs +++ b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs @@ -28,32 +28,44 @@ internal sealed class SurfaceBackdrop // Call right after entering a child, before its content. GetWindowDrawList // is then that child's own list, so everything lands behind the content // without a channel split. - internal void Draw(float accentWashHeight = 0.38f, float darken = 0f, bool motes = true) + internal void Draw(float accentWashHeight = 0.38f, float darken = 0f, float moteIntensity = 1f) { var dl = ImGui.GetWindowDrawList(); var min = ImGui.GetWindowPos(); var max = min + ImGui.GetWindowSize(); var colors = _themes.Active.Colors; + // Opacity taken from the window's own background colour rather than + // assumed. BgAlpha only reaches WindowBg, so a draw-list fill painted at + // full opacity turns a deliberately translucent window into a solid + // block -- which is exactly how the settings pane ended up looking like + // a foreign object next to the chat window. + var windowAlpha = ((ImGui.GetColorU32(ImGuiCol.WindowBg) >> 24) & 0xFFu) / 255f; + var surface = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceBase, colors)); if (darken > 0f) surface = ColourUtil.LerpTowardBlack(surface, darken); + surface = ColourUtil.ApplyAlpha(surface, windowAlpha); var accent = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.AccentPrimary, colors)); - dl.DrawVerticalGradient(min, max, surface, topLift: 0.20f, bottomDrop: 0.14f); + // Shallower than it was. The banding people see in a large gradient is + // 8-bit quantisation: a gentle ramp crosses few enough values that each + // one covers a visible band of pixels. Less range means fewer, fainter + // steps, and the window's translucency breaks up what is left. + dl.DrawVerticalGradient(min, max, surface, topLift: 0.10f, bottomDrop: 0.07f); if (accentWashHeight > 0f) dl.AddRectFilledMultiColor( min, new Vector2(max.X, min.Y + (max.Y - min.Y) * accentWashHeight), - ColourUtil.ApplyAlpha(accent, 0.10f), - ColourUtil.ApplyAlpha(accent, 0.05f), + ColourUtil.ApplyAlpha(accent, 0.09f * windowAlpha), + ColourUtil.ApplyAlpha(accent, 0.045f * windowAlpha), accent & 0x00FFFFFFu, accent & 0x00FFFFFFu ); - if (motes) - _motes.Draw(dl, min, max, accent); + if (moteIntensity > 0f) + _motes.Draw(dl, min, max, accent, moteIntensity * windowAlpha); } } diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index 8c3de06..a2e76b1 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -385,10 +385,11 @@ internal sealed class MainWindow : Window, IFocusableChatWindow { if (messages.Success) { - // No accent wash here. It works on a settings pane, where the - // top of the surface is a heading; over a chat log the first - // messages would sit in a tinted band and read as highlighted. - _backdrop.Draw(accentWashHeight: 0f); + // No accent wash, and the motes turned right down. Both work on + // a settings pane, which is read in glances; a chat log is read + // line by line, and anything drifting behind the text competes + // with it. What is left is barely a texture. + _backdrop.Draw(accentWashHeight: 0f, moteIntensity: 0.18f); if (_activeTab is not null) _messages.Draw(_activeTab); From c1f1c0563ca18f22ad638ccd55647959bc012bb2 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 18:05:11 +0200 Subject: [PATCH 085/129] fix(style): tint the surface instead of repainting it Both remaining complaints had one cause. The window paints its own background, and the backdrop was filling the same area a second time. Two stacked layers turn a translucent window solid, which is the density over the chat log. And an opaque fill has to carry the entire gradient by itself. That is where the banding came from: a shallow ramp across an opaque surface crosses so few 8-bit values that each one covers a visible stripe. Nothing sits underneath to break them up. So it tints now. Near-transparent white at the top, near-transparent black at the bottom, straight over whatever the window already drew. The window colour stays visible, the ramp only shades it, and the game showing through disperses what little stepping is left. The sidebar does the same with a flat black wash rather than a repainted darker surface. Motes are roughly half as bright everywhere, and the chat log takes 10% of that. DrawVerticalGradient stays for the segmented control, which paints a surface that genuinely is its own rather than one already drawn underneath. --- .../Ui/Components/Settings/TabSidebar.cs | 16 ++++----- .../Ui/StyleEngine/AmbientParticles.cs | 4 +-- HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs | 36 +++++++++---------- HellionChat/Ui/Windows/MainWindow.cs | 2 +- 4 files changed, 27 insertions(+), 31 deletions(-) diff --git a/HellionChat/Ui/Components/Settings/TabSidebar.cs b/HellionChat/Ui/Components/Settings/TabSidebar.cs index 15ea805..e8237ea 100644 --- a/HellionChat/Ui/Components/Settings/TabSidebar.cs +++ b/HellionChat/Ui/Components/Settings/TabSidebar.cs @@ -38,18 +38,14 @@ internal sealed class TabSidebar var min = ImGui.GetWindowPos(); var max = min + ImGui.GetWindowSize(); - var surface = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceBase, colors)); var accent = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.AccentPrimary, colors)); - // Darker than the content pane so the two read as separate planes rather - // than one wide surface with a line drawn down it. - dl.DrawVerticalGradient( - min, - max, - ColourUtil.LerpTowardBlack(surface, 0.25f), - topLift: 0.10f, - bottomDrop: 0.06f - ); + // A wash of black rather than a repainted surface. The window has drawn + // its own background already; filling over it stacks a second layer and + // turns a translucent window solid. This only needs to read as the + // darker of two planes, and a tint does that. + var opacity = ((ImGui.GetColorU32(ImGuiCol.WindowBg) >> 24) & 0xFFu) / 255f; + dl.AddRectFilled(min, max, (uint)(0x38 * opacity) << 24); // The icon font has no ASCII glyphs, so anything textual drawn inside a // FontAwesome scope comes out blank -- twice bitten in this plugin. The diff --git a/HellionChat/Ui/StyleEngine/AmbientParticles.cs b/HellionChat/Ui/StyleEngine/AmbientParticles.cs index ccc25ad..387f9c4 100644 --- a/HellionChat/Ui/StyleEngine/AmbientParticles.cs +++ b/HellionChat/Ui/StyleEngine/AmbientParticles.cs @@ -104,13 +104,13 @@ internal sealed class AmbientParticles dl.AddCircleFilled( pos, radius * 2.1f, - ColourUtil.ApplyAlpha(accentAbgr, 0.13f * edge * pulse * intensity), + ColourUtil.ApplyAlpha(accentAbgr, 0.075f * edge * pulse * intensity), 10 ); dl.AddCircleFilled( pos, radius, - ColourUtil.ApplyAlpha(accentAbgr, 0.42f * edge * pulse * intensity), + ColourUtil.ApplyAlpha(accentAbgr, 0.20f * edge * pulse * intensity), 10 ); } diff --git a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs index d62d5c7..c833fae 100644 --- a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs +++ b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs @@ -35,37 +35,37 @@ internal sealed class SurfaceBackdrop var max = min + ImGui.GetWindowSize(); var colors = _themes.Active.Colors; - // Opacity taken from the window's own background colour rather than - // assumed. BgAlpha only reaches WindowBg, so a draw-list fill painted at - // full opacity turns a deliberately translucent window into a solid - // block -- which is exactly how the settings pane ended up looking like - // a foreign object next to the chat window. - var windowAlpha = ((ImGui.GetColorU32(ImGuiCol.WindowBg) >> 24) & 0xFFu) / 255f; + // Modulation, not a second ground. The window has already painted its + // own background; filling the same area again stacked two layers and + // made a deliberately translucent window read as solid. Worse, an opaque + // fill had to carry the whole gradient by itself, which is what produced + // the banding -- a shallow ramp across an opaque surface crosses few + // enough 8-bit values that each covers a visible stripe. + // + // Near-transparent white over black instead: the window colour stays + // visible underneath, the ramp only tints it, and the game showing + // through breaks up any step that is left. + var opacity = ((ImGui.GetColorU32(ImGuiCol.WindowBg) >> 24) & 0xFFu) / 255f; + var lift = 0x00FFFFFFu | ((uint)(0x0E * opacity) << 24); + var drop = 0x00000000u | ((uint)(0x1A * opacity) << 24); + dl.AddRectFilledMultiColor(min, max, lift, lift, drop, drop); - var surface = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceBase, colors)); if (darken > 0f) - surface = ColourUtil.LerpTowardBlack(surface, darken); - surface = ColourUtil.ApplyAlpha(surface, windowAlpha); + dl.AddRectFilled(min, max, (uint)(0xFF * darken * opacity) << 24); var accent = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.AccentPrimary, colors)); - // Shallower than it was. The banding people see in a large gradient is - // 8-bit quantisation: a gentle ramp crosses few enough values that each - // one covers a visible band of pixels. Less range means fewer, fainter - // steps, and the window's translucency breaks up what is left. - dl.DrawVerticalGradient(min, max, surface, topLift: 0.10f, bottomDrop: 0.07f); - if (accentWashHeight > 0f) dl.AddRectFilledMultiColor( min, new Vector2(max.X, min.Y + (max.Y - min.Y) * accentWashHeight), - ColourUtil.ApplyAlpha(accent, 0.09f * windowAlpha), - ColourUtil.ApplyAlpha(accent, 0.045f * windowAlpha), + ColourUtil.ApplyAlpha(accent, 0.07f * opacity), + ColourUtil.ApplyAlpha(accent, 0.035f * opacity), accent & 0x00FFFFFFu, accent & 0x00FFFFFFu ); if (moteIntensity > 0f) - _motes.Draw(dl, min, max, accent, moteIntensity * windowAlpha); + _motes.Draw(dl, min, max, accent, moteIntensity * opacity); } } diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index a2e76b1..9ad68c2 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -389,7 +389,7 @@ internal sealed class MainWindow : Window, IFocusableChatWindow // a settings pane, which is read in glances; a chat log is read // line by line, and anything drifting behind the text competes // with it. What is left is barely a texture. - _backdrop.Draw(accentWashHeight: 0f, moteIntensity: 0.18f); + _backdrop.Draw(accentWashHeight: 0f, moteIntensity: 0.10f); if (_activeTab is not null) _messages.Draw(_activeTab); From 05b203c85c1ed9f7aa5d44e8202846e0081d49a0 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 18:09:46 +0200 Subject: [PATCH 086/129] fix(style): dither the surface tint so it stops banding "Horizontal scanlines" names it exactly. An alpha ramp from 0x34 to 0 has 52 distinct values, so across a 600px pane each one owns a stripe about 12 pixels tall, and the eye reads those stripes as scanlines. Raising the contrast only buys more of them, thinner. So it dithers. Four ramps at a quarter alpha each, every one ending slightly lower than the last, so they share a starting colour but run at different slopes. Their step boundaries land on different rows, and where one layer has stepped up its neighbours have not, which puts the blend between two quantised values. Same total tint, four times the effective resolution, three extra draw calls. Layers are staggered by slope rather than offset on purpose: shifting them would leave a gap at the top where fewer layers overlap, which reads as a bright band. That trades one artefact for a worse one. The tint is roughly twice as strong now, which the settings pane wanted anyway, and strength is a parameter so the chat log can stay at 45% of it. --- .../Ui/StyleEngine/DrawListExtensions.cs | 63 +++++++++++++++++++ HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs | 17 +++-- HellionChat/Ui/Windows/MainWindow.cs | 2 +- 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs index cd050a2..a92509f 100644 --- a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs +++ b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs @@ -120,6 +120,69 @@ internal static class DrawListExtensions dl.AddRectFilledMultiColor(min, max, top, top, bottom, bottom); } + // A vertical tint that does not band. + // + // The problem is quantisation, not the ramp: an alpha sweep from 26 to 0 + // only has 26 distinct values, so across 500 pixels each one owns a stripe + // roughly 20px tall -- read as horizontal scanlines. Raising the contrast + // just gives more, thinner stripes. + // + // The fix is to dither. Instead of one ramp, several at a fraction of the + // alpha each, every one offset by a sub-step of the height. Their stripe + // boundaries land at different rows, so where one layer steps up its + // neighbours have not yet, and the blend between them lands between two + // quantised values. Same total tint, layers times the effective resolution, + // for one extra draw call per layer. + public static void DrawDitheredVTint( + this ImDrawListPtr dl, + Vector2 min, + Vector2 max, + uint topAbgr, + uint bottomAbgr, + int layers = 4 + ) + { + if (layers < 1) + layers = 1; + + var height = max.Y - min.Y; + if (height <= 0f) + return; + + // Every layer starts at the top edge and ends slightly lower than the + // last, so they share a starting colour but run at different slopes. + // Shifting them instead would leave a gap at the top where fewer layers + // overlap, which reads as a bright band -- trading one artefact for a + // worse one. + // + // The stagger is a fraction of one quantisation band, so the steps + // interleave rather than landing on top of each other. + var steps = MathF.Max(1f, ((topAbgr >> 24) & 0xFFu) + ((bottomAbgr >> 24) & 0xFFu)); + var band = height / steps; + var share = 1f / layers; + var top = ScaleAlpha(topAbgr, share); + var bottom = ScaleAlpha(bottomAbgr, share); + + for (var i = 0; i < layers; i++) + { + var stretch = band * (i / (float)layers); + dl.AddRectFilledMultiColor( + min, + new Vector2(max.X, max.Y + stretch), + top, + top, + bottom, + bottom + ); + } + } + + private static uint ScaleAlpha(uint abgr, float factor) + { + var a = (uint)Math.Clamp(MathF.Round(((abgr >> 24) & 0xFFu) * factor), 0f, 255f); + return (abgr & 0x00FFFFFFu) | (a << 24); + } + // A rule that fades out along its length instead of stopping dead. A hard // line boxes content in; a fading one suggests a boundary without drawing a // wall, which is the whole difference between a heading and a header bar. diff --git a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs index c833fae..cd82b0f 100644 --- a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs +++ b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs @@ -28,7 +28,12 @@ internal sealed class SurfaceBackdrop // Call right after entering a child, before its content. GetWindowDrawList // is then that child's own list, so everything lands behind the content // without a channel split. - internal void Draw(float accentWashHeight = 0.38f, float darken = 0f, float moteIntensity = 1f) + internal void Draw( + float accentWashHeight = 0.38f, + float darken = 0f, + float moteIntensity = 1f, + float strength = 1f + ) { var dl = ImGui.GetWindowDrawList(); var min = ImGui.GetWindowPos(); @@ -46,9 +51,13 @@ internal sealed class SurfaceBackdrop // visible underneath, the ramp only tints it, and the game showing // through breaks up any step that is left. var opacity = ((ImGui.GetColorU32(ImGuiCol.WindowBg) >> 24) & 0xFFu) / 255f; - var lift = 0x00FFFFFFu | ((uint)(0x0E * opacity) << 24); - var drop = 0x00000000u | ((uint)(0x1A * opacity) << 24); - dl.AddRectFilledMultiColor(min, max, lift, lift, drop, drop); + var lift = 0x00FFFFFFu | ((uint)(0x1E * strength * opacity) << 24); + var drop = 0x00000000u | ((uint)(0x34 * strength * opacity) << 24); + + // Dithered, because a plain ramp bands: the alpha sweep has only a few + // dozen distinct values, so each owns a stripe tens of pixels tall, seen + // as horizontal scanlines across the pane. + dl.DrawDitheredVTint(min, max, lift, drop); if (darken > 0f) dl.AddRectFilled(min, max, (uint)(0xFF * darken * opacity) << 24); diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index 9ad68c2..b162b93 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -389,7 +389,7 @@ internal sealed class MainWindow : Window, IFocusableChatWindow // a settings pane, which is read in glances; a chat log is read // line by line, and anything drifting behind the text competes // with it. What is left is barely a texture. - _backdrop.Draw(accentWashHeight: 0f, moteIntensity: 0.10f); + _backdrop.Draw(accentWashHeight: 0f, moteIntensity: 0.10f, strength: 0.45f); if (_activeTab is not null) _messages.Draw(_activeTab); From 7df18bd552a6f675073a67a9350384e14b2fae3a Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 18:14:02 +0200 Subject: [PATCH 087/129] fix(style): shorten the ramp instead of layering it The dithering attempt made it worse and the reasoning was backwards. Four layers at a quarter alpha each carry a quarter of the steps each, so every layer bands more coarsely than the single ramp did, and blending four coarse ramps adds interference on top. Reverted. The real variable is distance, not layer count. A ramp has as many steps as it has distinct alpha values, so stretching it over the full height of a pane gives each step a stripe twenty-odd pixels tall, which the eye sharpens into scanlines. Running the same ramp over 190 pixels puts those steps a few pixels apart, where they read as a falloff. So: light fading down from the top edge, shadow gathering at the bottom, and a flat surface in between. Constant alpha cannot band at all, which leaves most of the pane immune by construction. --- .../Ui/StyleEngine/DrawListExtensions.cs | 80 ++++++++----------- HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs | 13 +-- 2 files changed, 41 insertions(+), 52 deletions(-) diff --git a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs index a92509f..62579c4 100644 --- a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs +++ b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs @@ -120,67 +120,55 @@ internal static class DrawListExtensions dl.AddRectFilledMultiColor(min, max, top, top, bottom, bottom); } - // A vertical tint that does not band. + // A vertical tint that stays under the banding threshold. // - // The problem is quantisation, not the ramp: an alpha sweep from 26 to 0 - // only has 26 distinct values, so across 500 pixels each one owns a stripe - // roughly 20px tall -- read as horizontal scanlines. Raising the contrast - // just gives more, thinner stripes. + // Banding is quantisation: an alpha ramp has as many steps as it has + // distinct values, and across a tall pane each step owns a stripe tens of + // pixels high. The eye then sharpens those edges into scanlines. // - // The fix is to dither. Instead of one ramp, several at a fraction of the - // alpha each, every one offset by a sub-step of the height. Their stripe - // boundaries land at different rows, so where one layer steps up its - // neighbours have not yet, and the blend between them lands between two - // quantised values. Same total tint, layers times the effective resolution, - // for one extra draw call per layer. - public static void DrawDitheredVTint( + // Splitting the ramp into faint layers makes it worse, not better -- each + // layer carries a fraction of the alpha and therefore a fraction of the + // steps, so the stripes get coarser and their blend adds interference. + // + // What actually works is running the whole ramp over a short distance + // instead of a tall one. The same handful of steps then falls within a + // couple of hundred pixels, each stripe is a few pixels tall rather than + // twenty, and they read as a smooth falloff. Below the fade the surface is + // flat, where a constant alpha cannot band at all. + public static void DrawEdgeTint( this ImDrawListPtr dl, Vector2 min, Vector2 max, - uint topAbgr, - uint bottomAbgr, - int layers = 4 + uint edgeAbgr, + float fadeHeight, + bool fromBottom = false ) { - if (layers < 1) - layers = 1; - var height = max.Y - min.Y; if (height <= 0f) return; - // Every layer starts at the top edge and ends slightly lower than the - // last, so they share a starting colour but run at different slopes. - // Shifting them instead would leave a gap at the top where fewer layers - // overlap, which reads as a bright band -- trading one artefact for a - // worse one. - // - // The stagger is a fraction of one quantisation band, so the steps - // interleave rather than landing on top of each other. - var steps = MathF.Max(1f, ((topAbgr >> 24) & 0xFFu) + ((bottomAbgr >> 24) & 0xFFu)); - var band = height / steps; - var share = 1f / layers; - var top = ScaleAlpha(topAbgr, share); - var bottom = ScaleAlpha(bottomAbgr, share); + var fade = MathF.Min(fadeHeight, height); + var clear = edgeAbgr & 0x00FFFFFFu; - for (var i = 0; i < layers; i++) - { - var stretch = band * (i / (float)layers); + if (fromBottom) + dl.AddRectFilledMultiColor( + new Vector2(min.X, max.Y - fade), + max, + clear, + clear, + edgeAbgr, + edgeAbgr + ); + else dl.AddRectFilledMultiColor( min, - new Vector2(max.X, max.Y + stretch), - top, - top, - bottom, - bottom + new Vector2(max.X, min.Y + fade), + edgeAbgr, + edgeAbgr, + clear, + clear ); - } - } - - private static uint ScaleAlpha(uint abgr, float factor) - { - var a = (uint)Math.Clamp(MathF.Round(((abgr >> 24) & 0xFFu) * factor), 0f, 255f); - return (abgr & 0x00FFFFFFu) | (a << 24); } // A rule that fades out along its length instead of stopping dead. A hard diff --git a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs index cd82b0f..ab4c223 100644 --- a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs +++ b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs @@ -51,13 +51,14 @@ internal sealed class SurfaceBackdrop // visible underneath, the ramp only tints it, and the game showing // through breaks up any step that is left. var opacity = ((ImGui.GetColorU32(ImGuiCol.WindowBg) >> 24) & 0xFFu) / 255f; - var lift = 0x00FFFFFFu | ((uint)(0x1E * strength * opacity) << 24); - var drop = 0x00000000u | ((uint)(0x34 * strength * opacity) << 24); - // Dithered, because a plain ramp bands: the alpha sweep has only a few - // dozen distinct values, so each owns a stripe tens of pixels tall, seen - // as horizontal scanlines across the pane. - dl.DrawDitheredVTint(min, max, lift, drop); + // Two short fades rather than one tall ramp: light from the top edge, + // shadow gathering at the bottom, flat in between. A ramp stretched over + // the full height is exactly the case that bands, because its handful of + // alpha steps each cover twenty-odd pixels. + var fade = MathF.Min(190f * Metrics.Scale, (max.Y - min.Y) * 0.45f); + dl.DrawEdgeTint(min, max, 0x00FFFFFFu | ((uint)(0x22 * strength * opacity) << 24), fade); + dl.DrawEdgeTint(min, max, (uint)(0x30 * strength * opacity) << 24, fade, fromBottom: true); if (darken > 0f) dl.AddRectFilled(min, max, (uint)(0xFF * darken * opacity) << 24); From 57780351d4aab119258c17b53408efff4ddf4fa3 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 18:23:34 +0200 Subject: [PATCH 088/129] feat(settings): convert the remaining four standard tabs Chat, Channels, General and Data & Privacy now draw the same way Window does. A half-converted window is worse than an unconverted one: before, all seven tabs were consistently dated; after the pilot, one looked current and six looked abandoned, and switching between them made the seam obvious. Descriptions move out of the help markers and onto the rows. Those strings were written to be read, and a (?) the user has to hover is where an explanation goes to be ignored. Several were translated into 25 languages and had never appeared on screen at all. Two settings that could not use the standard helpers -- the language picker, which rebuilds the font atlas, and the keybind mode, whose description depends on the selected value -- go through a plain Row that hands the caller the control column and leaves the save logic alone. Privacy filter labels now come from the resources that already existed for them, same defect as the channels tab last round: strings present, translated, and reachable from no line of code. Four more constructors take a TokenResolver, so this wants the DI smoke pass. Only the appearance tab still draws stock collapsing headers; its four components are their own block. --- HellionChat/PluginHostFactory.cs | 12 ++- .../Ui/Components/Settings/SettingsWidgets.cs | 14 ++++ .../Components/Settings/Tabs/ChannelsTab.cs | 73 +++++++++-------- .../Ui/Components/Settings/Tabs/ChatTab.cs | 47 ++++++----- .../Settings/Tabs/DataPrivacyTab.cs | 39 +++++---- .../Ui/Components/Settings/Tabs/GeneralTab.cs | 80 ++++++++++--------- 6 files changed, 159 insertions(+), 106 deletions(-) diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index e729aaf..0de13eb 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -208,20 +208,24 @@ internal static class PluginHostFactory )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.GeneralTab( sp.GetRequiredService(), - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.ChatTab( - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.WindowTab( sp.GetRequiredService(), sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.ChannelsTab( - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.DataPrivacyTab( - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.AboutTab( sp.GetRequiredService(), diff --git a/HellionChat/Ui/Components/Settings/SettingsWidgets.cs b/HellionChat/Ui/Components/Settings/SettingsWidgets.cs index e2dda3b..826ec2d 100644 --- a/HellionChat/Ui/Components/Settings/SettingsWidgets.cs +++ b/HellionChat/Ui/Components/Settings/SettingsWidgets.cs @@ -71,6 +71,20 @@ internal sealed class SettingsWidgets // The whole row toggles, label included. The switch itself gets its own // invisible button because SettingRow's hit area stops at the label column, // and clicking the control is what a user tries first. + // Escape hatch for controls the helpers do not cover -- a keybind capture, + // a picker with side effects. The caller draws whatever it likes into the + // control column and keeps its own save logic. + internal void Row( + uint id, + string label, + string? description, + Action drawControl + ) + { + EnsureFrame(); + SettingRow.Draw(id, label, description, _row, drawControl); + } + internal void ToggleRow( uint id, string label, diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs index aeb6edb..09119f8 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs @@ -1,111 +1,116 @@ using Dalamud.Bindings.ImGui; using HellionChat.Resources; -using HellionChat.Util; +using HellionChat.Ui.StyleEngine; namespace HellionChat.Ui.Components.Settings.Tabs; internal sealed class ChannelsTab { - private readonly Plugin _plugin; private readonly SettingsWidgets _w; - public ChannelsTab(Plugin plugin) + public ChannelsTab(Plugin plugin, TokenResolver resolver) { - _plugin = plugin; - _w = new SettingsWidgets(plugin); + _w = new SettingsWidgets(plugin, new SettingsPalette(resolver)); } public void Draw() { if ( - ImGui.CollapsingHeader( - HellionStrings.Settings_Section_AutoTellTabs, - ImGuiTreeNodeFlags.DefaultOpen + _w.Section( + ImGui.GetID("channels.autotell"u8), + HellionStrings.Settings_Section_AutoTellTabs ) ) { - _w.Toggle( + _w.ToggleRow( + ImGui.GetID("channels.autotell.enable"u8), HellionStrings.ChatLog_AutoTellTabs_Enable_Name, + HellionStrings.ChatLog_AutoTellTabs_Enable_Description, () => Plugin.Config.EnableAutoTellTabs, v => Plugin.Config.EnableAutoTellTabs = v ); - ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_Enable_Description); - - _w.SliderInt( + _w.SliderIntRow( + ImGui.GetID("channels.autotell.limit"u8), HellionStrings.ChatLog_AutoTellTabs_Limit_Name, + HellionStrings.ChatLog_AutoTellTabs_Limit_Description, () => Plugin.Config.AutoTellTabsLimit, v => Plugin.Config.AutoTellTabsLimit = v, 1, 50 ); - ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_Limit_Description); - - _w.Toggle( + _w.ToggleRow( + ImGui.GetID("channels.autotell.compact"u8), HellionStrings.ChatLog_AutoTellTabs_Compact_Name, + HellionStrings.ChatLog_AutoTellTabs_Compact_Description, () => Plugin.Config.AutoTellTabsCompactDisplay, v => Plugin.Config.AutoTellTabsCompactDisplay = v ); - ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_Compact_Description); - - _w.SliderInt( + _w.SliderIntRow( + ImGui.GetID("channels.autotell.preload"u8), HellionStrings.Privacy_AutoTellTabs_Preload_Name, + HellionStrings.Privacy_AutoTellTabs_Preload_Description, () => Plugin.Config.AutoTellTabsHistoryPreload, v => Plugin.Config.AutoTellTabsHistoryPreload = v, 0, 200 ); - ImGuiUtil.HelpMarker(HellionStrings.Privacy_AutoTellTabs_Preload_Description); - - _w.Toggle( + _w.ToggleRow( + ImGui.GetID("channels.autotell.greeted"u8), HellionStrings.ChatLog_AutoTellTabs_GreetedToggle_Name, + HellionStrings.ChatLog_AutoTellTabs_GreetedToggle_Description, () => Plugin.Config.AutoTellTabsShowGreetedToggle, v => Plugin.Config.AutoTellTabsShowGreetedToggle = v ); - ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_GreetedToggle_Description); - - _w.Toggle( + _w.ToggleRow( + ImGui.GetID("channels.autotell.popout"u8), HellionStrings.ChatLog_AutoTellTabs_OpenAsPopout_Name, + HellionStrings.ChatLog_AutoTellTabs_OpenAsPopout_Description, () => Plugin.Config.AutoTellTabsOpenAsPopout, v => Plugin.Config.AutoTellTabsOpenAsPopout = v ); - ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_OpenAsPopout_Description); - // Written for this screen and never shown until now. It names the - // one setting in a third-party plugin that silently stops auto-tell - // tabs from ever opening, which is not something a user guesses. + // Written for this screen and never shown until this cycle. It names + // the one setting in a third-party plugin that silently stops + // auto-tell tabs from ever opening, which is not something a user + // works out alone. ImGui.Spacing(); ImGui.TextWrapped(HellionStrings.ChatLog_AutoTellTabs_ConflictHint); } - if (ImGui.CollapsingHeader("Tell auto-open mode", ImGuiTreeNodeFlags.DefaultOpen)) + if (_w.Section(ImGui.GetID("channels.autoopen"u8), "Tell auto-open")) { - _w.EnumCombo( + _w.EnumComboRow( + ImGui.GetID("channels.autoopen.mode"u8), "Tell auto-open mode", + "Where a tell opens when it arrives.", () => Plugin.Config.TellAutoOpenMode, v => Plugin.Config.TellAutoOpenMode = v, v => v.Name() ); - _w.Toggle( + _w.ToggleRow( + ImGui.GetID("channels.autoopen.switch"u8), "Switch to the tab on every tell", + "Otherwise the tab opens in the background after the first one.", () => Plugin.Config.TellAutoOpenSwitchAlways, v => Plugin.Config.TellAutoOpenSwitchAlways = v ); } - if (ImGui.CollapsingHeader("Sidebar")) + if (_w.Section(ImGui.GetID("channels.sidebar"u8), "Sidebar", open: false)) { // Bounds come from the constants rather than repeating the numbers, // so the slider cannot drift away from the clamp in Sidebar.GetWidth. // The stored value is unscaled; display scaling is applied where the // sidebar is drawn. - _w.SliderInt( + _w.SliderIntRow( + ImGui.GetID("channels.sidebar.width"u8), HellionStrings.Settings_ThemeAndLayout_SidebarWidth_Name, + HellionStrings.Settings_ThemeAndLayout_SidebarWidth_Description, () => Plugin.Config.SidebarWidth, v => Plugin.Config.SidebarWidth = v, (int)Sidebar.MinSidebarWidth, (int)Sidebar.MaxSidebarWidth ); - ImGuiUtil.HelpMarker(HellionStrings.Settings_ThemeAndLayout_SidebarWidth_Description); } } } diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs index b7a3a9f..31ec8aa 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs @@ -1,68 +1,79 @@ using Dalamud.Bindings.ImGui; using HellionChat.Resources; -using HellionChat.Util; +using HellionChat.Ui.StyleEngine; namespace HellionChat.Ui.Components.Settings.Tabs; internal sealed class ChatTab { - private readonly Plugin _plugin; private readonly SettingsWidgets _w; - public ChatTab(Plugin plugin) + public ChatTab(Plugin plugin, TokenResolver resolver) { - _plugin = plugin; - _w = new SettingsWidgets(plugin); + _w = new SettingsWidgets(plugin, new SettingsPalette(resolver)); } public void Draw() { - if (ImGui.CollapsingHeader("Display modes", ImGuiTreeNodeFlags.DefaultOpen)) + if (_w.Section(ImGui.GetID("chat.display"u8), "Display modes")) { - _w.Toggle( - "Compact density (card vs compact)", + _w.ToggleRow( + ImGui.GetID("chat.display.density"u8), + "Compact density", + "Trades the card layout for one line per message.", () => Plugin.Config.UseCompactDensity, v => Plugin.Config.UseCompactDensity = v ); - _w.Toggle( + _w.ToggleRow( + ImGui.GetID("chat.display.clock"u8), "24-hour clock", + null, () => Plugin.Config.Use24HourClock, v => Plugin.Config.Use24HourClock = v ); - _w.EnumCombo( + + // Descriptions move out of the help markers and onto the row. They + // were written to be read; a (?) the user has to hover is where an + // explanation goes to be ignored. + _w.EnumComboRow( + ImGui.GetID("chat.display.worldsuffix"u8), HellionStrings.Settings_Chat_WorldSuffix_Name, + HellionStrings.Settings_Chat_WorldSuffix_Description, () => Plugin.Config.WorldSuffixMode, v => Plugin.Config.WorldSuffixMode = v, v => v.Name() ); - ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_WorldSuffix_Description); - _w.EnumCombo( + _w.EnumComboRow( + ImGui.GetID("chat.display.nameform"u8), HellionStrings.Settings_Chat_NameForm_Name, + HellionStrings.Settings_Chat_NameForm_Description, () => Plugin.Config.NameFormMode, v => Plugin.Config.NameFormMode = v, v => v.Name() ); - ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_NameForm_Description); } - if (ImGui.CollapsingHeader("Command help")) + if (_w.Section(ImGui.GetID("chat.commandhelp"u8), "Command help", open: false)) { - _w.EnumCombo( + _w.EnumComboRow( + ImGui.GetID("chat.commandhelp.side"u8), "Command help side", + "Which side the command hint list appears on while typing.", () => Plugin.Config.CommandHelpSide, v => Plugin.Config.CommandHelpSide = v, v => v.Name() ); } - if (ImGui.CollapsingHeader("Plugin disclosure")) + if (_w.Section(ImGui.GetID("chat.disclosure"u8), "Plugin disclosure", open: false)) { - _w.Toggle( + _w.ToggleRow( + ImGui.GetID("chat.disclosure.notify"u8), HellionStrings.Settings_Chat_NotifyPluginDisclosure_Name, + HellionStrings.Settings_Chat_NotifyPluginDisclosure_Description, () => Plugin.Config.NotifyPluginDisclosure, v => Plugin.Config.NotifyPluginDisclosure = v ); - ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_NotifyPluginDisclosure_Description); } } } diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs index d7803bb..53e2d94 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -10,35 +10,37 @@ internal sealed class DataPrivacyTab private readonly Plugin _plugin; private readonly SettingsWidgets _w; - public DataPrivacyTab(Plugin plugin) + public DataPrivacyTab(Plugin plugin, Ui.StyleEngine.TokenResolver resolver) { _plugin = plugin; - _w = new SettingsWidgets(plugin); + _w = new SettingsWidgets(plugin, new SettingsPalette(resolver)); } public void Draw() { - if (ImGui.CollapsingHeader("Logging", ImGuiTreeNodeFlags.DefaultOpen)) + if (_w.Section(ImGui.GetID("privacy.logging"u8), HellionStrings.Retention_Heading)) { - _w.Toggle( + _w.ToggleRow( + ImGui.GetID("privacy.logging.enabled"u8), HellionStrings.Retention_Enabled_Name, + HellionStrings.Retention_Enabled_Description, () => Plugin.Config.RetentionEnabled, v => Plugin.Config.RetentionEnabled = v ); - ImGuiUtil.HelpMarker(HellionStrings.Retention_Enabled_Description); // Down to 0, which DeleteByRetentionPolicy reads as "keep forever" // for channels without an override. The slider started at 1, so the // one value that means "never delete anything" was unreachable // through the UI while the label promised it. - _w.SliderInt( + _w.SliderIntRow( + ImGui.GetID("privacy.logging.default"u8), HellionStrings.Retention_Default_Label, + HellionStrings.Retention_Default_Help, () => Plugin.Config.RetentionDefaultDays, v => Plugin.Config.RetentionDefaultDays = v, 0, 365 ); - ImGuiUtil.HelpMarker(HellionStrings.Retention_Default_Help); // RetentionLastRunAt defaults to MinValue on a fresh install, which // would render as "0001-01-01 00:00" and look like a bug; the "Never" @@ -54,22 +56,31 @@ internal sealed class DataPrivacyTab DrawRetentionOverrides(); } - if (ImGui.CollapsingHeader("Privacy filter", ImGuiTreeNodeFlags.DefaultOpen)) + if ( + _w.Section( + ImGui.GetID("privacy.filter"u8), + HellionStrings.Settings_Section_PrivacyFilter + ) + ) { - _w.Toggle( - "Enable privacy filter", + _w.ToggleRow( + ImGui.GetID("privacy.filter.enabled"u8), + HellionStrings.Privacy_FilterEnabled_Name, + HellionStrings.Privacy_FilterEnabled_Description, () => Plugin.Config.PrivacyFilterEnabled, v => Plugin.Config.PrivacyFilterEnabled = v ); - DrawPrivacyPersistChannelsGrid(); - _w.Toggle( - "Persist unknown channels", + _w.ToggleRow( + ImGui.GetID("privacy.filter.unknown"u8), + HellionStrings.Privacy_PersistUnknown_Name, + null, () => Plugin.Config.PrivacyPersistUnknownChannels, v => Plugin.Config.PrivacyPersistUnknownChannels = v ); + DrawPrivacyPersistChannelsGrid(); } - if (ImGui.CollapsingHeader("Telemetry")) + if (_w.Section(ImGui.GetID("privacy.telemetry"u8), "Telemetry", open: false)) { // Read-only placeholder; no telemetry is wired in v1.7.0. Do not // promote this to a toggle without an explicit Sub-Spec change. diff --git a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs index 956dc61..cd76c42 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs @@ -1,5 +1,6 @@ using Dalamud.Bindings.ImGui; using HellionChat.Resources; +using HellionChat.Ui.StyleEngine; using HellionChat.Util; namespace HellionChat.Ui.Components.Settings.Tabs; @@ -16,10 +17,10 @@ internal sealed class GeneralTab // localised label never affects the ordering. private static readonly LanguageOverride[] LanguageOrder = BuildLanguageOrder(); - public GeneralTab(Plugin plugin, FontManager fonts) + public GeneralTab(Plugin plugin, FontManager fonts, TokenResolver resolver) { _plugin = plugin; - _w = new SettingsWidgets(plugin); + _w = new SettingsWidgets(plugin, new SettingsPalette(resolver)); _fonts = fonts; } @@ -33,17 +34,19 @@ internal sealed class GeneralTab public void Draw() { - if (ImGui.CollapsingHeader("Behavior", ImGuiTreeNodeFlags.DefaultOpen)) + if (_w.Section(ImGui.GetID("general.behaviour"u8), "Behaviour")) { - _w.Toggle( - "Reduce motion (no theme crossfade)", + _w.ToggleRow( + ImGui.GetID("general.behaviour.reducemotion"u8), + "Reduce motion", + "Turns off theme crossfades, hover fades and the drifting motes.", () => Plugin.Config.ReduceMotion, v => Plugin.Config.ReduceMotion = v ); DrawLanguagePicker(); } - if (ImGui.CollapsingHeader("Keybinds", ImGuiTreeNodeFlags.DefaultOpen)) + if (_w.Section(ImGui.GetID("general.keybinds"u8), "Keybinds")) { ImGui.TextDisabled("Click a button, then press the key combination. Esc clears."); DrawKeybind( @@ -61,20 +64,19 @@ internal sealed class GeneralTab DrawKeybindModePicker(); } - if (ImGui.CollapsingHeader("Notifications", ImGuiTreeNodeFlags.DefaultOpen)) + if (_w.Section(ImGui.GetID("general.notifications"u8), "Notifications")) { - _w.Toggle( + _w.ToggleRow( + ImGui.GetID("general.notifications.sounds"u8), Language.Options_PlaySounds_Name, + Language.Options_PlaySounds_Description, () => Plugin.Config.PlaySounds, v => Plugin.Config.PlaySounds = v ); - ImGuiUtil.HelpMarker(Language.Options_PlaySounds_Description); - } - - if (ImGui.CollapsingHeader("Volumes", ImGuiTreeNodeFlags.DefaultOpen)) - { - _w.SliderFloat( + _w.SliderFloatRow( + ImGui.GetID("general.notifications.volume"u8), "Custom sound volume", + null, () => Plugin.Config.CustomSoundVolume, v => Plugin.Config.CustomSoundVolume = v, 0f, @@ -95,18 +97,19 @@ internal sealed class GeneralTab for (var i = 0; i < LanguageOrder.Length; i++) labels[i] = LanguageOrder[i].Name(); - ImGui.SetNextItemWidth(200f); - if (ImGui.Combo(Language.Options_Language_Name, ref selected, labels, labels.Length)) - { - if (selected >= 0 && selected < LanguageOrder.Length) - ApplyLanguage(LanguageOrder[selected]); - } - - ImGuiUtil.HelpMarker( - string.Format(Language.Options_Language_Description, Plugin.PluginName) - + "\n\nSwitching rebuilds the font atlas, so the chat window goes blank" - + " for a moment. Scripts a language needs stay enabled afterwards;" - + " clear them under Appearance -> Fonts -> Extra glyphs." + _w.Row( + ImGui.GetID("general.behaviour.language"u8), + Language.Options_Language_Name, + "Switching rebuilds the font atlas, so the chat goes blank for a moment.", + ctx => + { + ImGui.SetNextItemWidth(ctx.ControlWidth); + if (ImGui.Combo("##hc-language", ref selected, labels, labels.Length)) + { + if (selected >= 0 && selected < LanguageOrder.Length) + ApplyLanguage(LanguageOrder[selected]); + } + } ); } @@ -155,18 +158,23 @@ internal sealed class GeneralTab for (var i = 0; i < values.Length; i++) labels[i] = values[i].Name(); - ImGui.SetNextItemWidth(200f); - if (ImGui.Combo(Language.Options_KeybindMode_Name, ref selected, labels, labels.Length)) - { - if (selected >= 0 && selected < values.Length) + _w.Row( + ImGui.GetID("general.keybinds.mode"u8), + Language.Options_KeybindMode_Name, + Plugin.Config.KeybindMode.Tooltip(), + ctx => { - Plugin.Config.KeybindMode = values[selected]; - _plugin.SaveConfig(); + ImGui.SetNextItemWidth(ctx.ControlWidth); + if (ImGui.Combo("##hc-keybindmode", ref selected, labels, labels.Length)) + { + if (selected >= 0 && selected < values.Length) + { + Plugin.Config.KeybindMode = values[selected]; + _plugin.SaveConfig(); + } + } } - } - - if (Plugin.Config.KeybindMode.Tooltip() is { } tip) - ImGuiUtil.HelpMarker(tip); + ); } // Wires the already-present ImGuiUtil.KeybindInput capture widget (dead/unwired From ae9b5037768692f07d25a51d75f8e7ee575b0974 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 18:44:54 +0200 Subject: [PATCH 089/129] fix(tells): load pinned tell history after login, not before it A pinned tell tab came up empty for the rest of the session, reported by a tester and reproduced from his config. RehydratePinnedTabs runs from a hosted service at plugin start. It queries tell history by character, and CurrentContentId is 0 until a character is logged in -- LastContentId only gets set from the framework tick. The game loads plugins at boot, so the normal path queries for character zero, finds nothing, and there was no second attempt: the service subscribed to Logout but not Login. It defers now when no character is available and completes on the login that follows. The pending flag keeps a later character switch from appending a second copy of the history to tabs that already have it. Two things hid this. Reloading the plugin in a running session, which is what development looks like, always has a character available. And it only shows up if you pin a tell tab at all -- the same path for non-temp tabs already handles the boot case explicitly, one file over: if (pluginInterface.Reason is not PluginLoadReason.Boot) manager.FilterAllTabsAsync(); Also surfaces FilterIncludePreviousSessions in the Chat tab, which decides whether the log shows anything from before the current session and had no control at all -- written only by the first-run wizard, and only if the user reached step 3. Skip the wizard and it stays false forever. It applies immediately rather than at next launch, since the user is looking at the window when they ask for it. --- HellionChat/AutoTellTabsService.cs | 34 +++++++++++++++++++ .../Ui/Components/Settings/Tabs/ChatTab.cs | 29 ++++++++++++++++ HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs | 8 ++--- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/HellionChat/AutoTellTabsService.cs b/HellionChat/AutoTellTabsService.cs index 13d8d13..6ea5682 100644 --- a/HellionChat/AutoTellTabsService.cs +++ b/HellionChat/AutoTellTabsService.cs @@ -39,6 +39,10 @@ internal sealed class AutoTellTabsService : IDisposable private bool _initialized; + // Set when Initialize ran before a character was available; cleared once the + // history has actually been loaded. + private bool _rehydratePending; + internal AutoTellTabsService( Plugin plugin, MessageManager messageManager, @@ -76,12 +80,30 @@ internal sealed class AutoTellTabsService : IDisposable RehydratePinnedTabs(); _messageManager.MessageProcessed += HandleTell; + Plugin.ClientState.Login += OnLogin; Plugin.ClientState.Logout += OnLogout; _initialized = true; } + // Deferred when the plugin starts before a character is logged in, which is + // the normal case: the game loads plugins at boot. CurrentContentId is 0 + // until then, so the history query would look up tells for character zero, + // find none, and leave every pinned tab blank for the whole session. + // + // Only visible to someone who actually pins a tell tab AND starts the game + // with the plugin already installed. Reloading the plugin in a running + // session -- what a developer does all day -- hides it completely. private void RehydratePinnedTabs() { + if (_messageManager.CurrentContentId == 0) + { + _logger.LogDebug("[Pin] Rehydrate deferred: no character yet, waiting for login"); + _rehydratePending = true; + return; + } + + _rehydratePending = false; + var pinned = Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool); _logger.LogDebug($"[Pin] Rehydrate scan: {pinned} pinned tab(s) found"); @@ -122,6 +144,7 @@ internal sealed class AutoTellTabsService : IDisposable return; } + Plugin.ClientState.Login -= OnLogin; Plugin.ClientState.Logout -= OnLogout; _messageManager.MessageProcessed -= HandleTell; _initialized = false; @@ -490,6 +513,17 @@ internal sealed class AutoTellTabsService : IDisposable } } + // Fires on the login that follows a boot-time start, and on every character + // switch after one. Guarded by the pending flag so a switch does not append + // a second copy of the history to tabs that already have it. + private void OnLogin() + { + if (!_rehydratePending) + return; + + RehydratePinnedTabs(); + } + private void OnLogout(int type, int code) { lock (TabsListLock) diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs index 31ec8aa..36f1fd6 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs @@ -6,10 +6,12 @@ namespace HellionChat.Ui.Components.Settings.Tabs; internal sealed class ChatTab { + private readonly Plugin _plugin; private readonly SettingsWidgets _w; public ChatTab(Plugin plugin, TokenResolver resolver) { + _plugin = plugin; _w = new SettingsWidgets(plugin, new SettingsPalette(resolver)); } @@ -53,6 +55,33 @@ internal sealed class ChatTab ); } + if (_w.Section(ImGui.GetID("chat.history"u8), "History")) + { + // The one setting that decides whether the chat log shows anything + // from before this game session. It had no control at all: only the + // first-run wizard ever wrote it, and only if the user actually + // reached step 3 -- skip the wizard and it stays on its default of + // false, leaving the log empty on every launch with no way to fix it. + // + // Named for what it does. The stored name describes filtering, and + // the wizard's own "Load previous session on startup" checkbox + // writes a field nothing reads. + _w.ToggleRow( + ImGui.GetID("chat.history.previoussessions"u8), + "Show history from previous sessions", + "Off means the log starts empty each time the game launches and " + + "only fills with messages received since.", + () => Plugin.Config.FilterIncludePreviousSessions, + v => + { + Plugin.Config.FilterIncludePreviousSessions = v; + // Takes effect at once rather than on the next launch: the + // window is open and the user just asked for the history. + _plugin.MessageManager.FilterAllTabs(); + } + ); + } + if (_w.Section(ImGui.GetID("chat.commandhelp"u8), "Command help", open: false)) { _w.EnumComboRow( diff --git a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs index ab4c223..f4d246a 100644 --- a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs +++ b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs @@ -57,8 +57,8 @@ internal sealed class SurfaceBackdrop // the full height is exactly the case that bands, because its handful of // alpha steps each cover twenty-odd pixels. var fade = MathF.Min(190f * Metrics.Scale, (max.Y - min.Y) * 0.45f); - dl.DrawEdgeTint(min, max, 0x00FFFFFFu | ((uint)(0x22 * strength * opacity) << 24), fade); - dl.DrawEdgeTint(min, max, (uint)(0x30 * strength * opacity) << 24, fade, fromBottom: true); + dl.DrawEdgeTint(min, max, 0x00FFFFFFu | ((uint)(0x10 * strength * opacity) << 24), fade); + dl.DrawEdgeTint(min, max, (uint)(0x38 * strength * opacity) << 24, fade, fromBottom: true); if (darken > 0f) dl.AddRectFilled(min, max, (uint)(0xFF * darken * opacity) << 24); @@ -69,8 +69,8 @@ internal sealed class SurfaceBackdrop dl.AddRectFilledMultiColor( min, new Vector2(max.X, min.Y + (max.Y - min.Y) * accentWashHeight), - ColourUtil.ApplyAlpha(accent, 0.07f * opacity), - ColourUtil.ApplyAlpha(accent, 0.035f * opacity), + ColourUtil.ApplyAlpha(accent, 0.045f * opacity), + ColourUtil.ApplyAlpha(accent, 0.02f * opacity), accent & 0x00FFFFFFu, accent & 0x00FFFFFFu ); From 33bcc38581733cc7dbca4ad5c1097eed12b73bd9 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 18:51:08 +0200 Subject: [PATCH 090/129] feat(style): pick foreground colours by measured contrast The gradient was turned down because text on the lit edge became hard to read, which fixed the symptom and lost the effect. The real problem is that a theme picks one text colour while the same text lands on a base surface, a lit edge and an accent fill, and a value that reads on one can vanish on another. White iconography on a pale violet accent was the reported case. So contrast is computed rather than assumed. ColourUtil gains WCAG relative luminance, the contrast ratio, and EnsureContrast, which walks a foreground away from its background until it clears a threshold -- 4.5:1 for text, 3:1 for icons -- and stops at the first step that does, so a colour keeps as much of its hue as the ratio allows. Luminance is gamma-corrected now. sRGB is gamma-encoded, so averaging raw bytes overstates dark colours badly, and nearly every surface here is dark. Mid grey is 0.216 relative luminance, not 0.5. The tests caught a real error in the first version: direction was chosen by whether the background measured below 0.5 luminance. Pale violet sits at 0.39, counts as dark by that rule, and the function tried to make white whiter. It now picks whichever end reaches further from the background. With foregrounds that follow, the gradient goes back up past where it was. Applied to the sidebar icons and labels and to the segmented control's labels. The remaining call sites are a polish pass of their own -- this is the machinery plus the two places that were reported. --- .../Ui/Components/Settings/TabSidebar.cs | 17 ++++- HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs | 8 +-- .../StyleEngine/Widgets/SegmentedControl.cs | 14 ++-- HellionChat/Util/ColourUtil.cs | 72 +++++++++++++++++-- 4 files changed, 92 insertions(+), 19 deletions(-) diff --git a/HellionChat/Ui/Components/Settings/TabSidebar.cs b/HellionChat/Ui/Components/Settings/TabSidebar.cs index e8237ea..4b08dce 100644 --- a/HellionChat/Ui/Components/Settings/TabSidebar.cs +++ b/HellionChat/Ui/Components/Settings/TabSidebar.cs @@ -65,6 +65,7 @@ internal sealed class TabSidebar TextMuted = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.TextMuted, colors)), Accent = accent, Hover = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceHover, colors)), + Surface = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceBase, colors)), IconFont = iconFont, IconSize = iconSize, }; @@ -84,6 +85,10 @@ internal sealed class TabSidebar public required uint TextMuted { get; init; } public required uint Accent { get; init; } public required uint Hover { get; init; } + + // What the row is actually drawn on, which is what a foreground has to + // clear -- not the theme's nominal background. + public required uint Surface { get; init; } public required ImFontPtr IconFont { get; init; } public required float IconSize { get; init; } } @@ -134,12 +139,20 @@ internal sealed class TabSidebar var contentColour = selected ? p.Text : ColourUtil.Lerp(p.TextMuted, p.Text, hoverAmount); + // The active row is filled with the accent, so the glyph on it has to + // clear that fill rather than the pane behind it. White on a pale violet + // accent measures 2.4:1, well under the 3:1 floor for icons, and that is + // exactly the case that was reported as unreadable. + var iconColour = selected + ? ColourUtil.EnsureContrast(p.Accent, p.Surface, 3f) + : ColourUtil.EnsureContrast(contentColour, p.Surface, 3f); + var iconX = origin.X + 12f * scale; dl.AddText( p.IconFont, p.IconSize, new Vector2(iconX, origin.Y + MetricsMath.Center(height, p.IconSize)), - selected ? p.Accent : contentColour, + iconColour, icon.ToIconString() ); @@ -149,7 +162,7 @@ internal sealed class TabSidebar iconX + p.IconSize + 9f * scale, origin.Y + MetricsMath.Center(height, labelSize.Y) ), - contentColour, + ColourUtil.EnsureContrast(contentColour, p.Surface, 4.5f), label ); diff --git a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs index f4d246a..fb506b1 100644 --- a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs +++ b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs @@ -57,8 +57,8 @@ internal sealed class SurfaceBackdrop // the full height is exactly the case that bands, because its handful of // alpha steps each cover twenty-odd pixels. var fade = MathF.Min(190f * Metrics.Scale, (max.Y - min.Y) * 0.45f); - dl.DrawEdgeTint(min, max, 0x00FFFFFFu | ((uint)(0x10 * strength * opacity) << 24), fade); - dl.DrawEdgeTint(min, max, (uint)(0x38 * strength * opacity) << 24, fade, fromBottom: true); + dl.DrawEdgeTint(min, max, 0x00FFFFFFu | ((uint)(0x26 * strength * opacity) << 24), fade); + dl.DrawEdgeTint(min, max, (uint)(0x44 * strength * opacity) << 24, fade, fromBottom: true); if (darken > 0f) dl.AddRectFilled(min, max, (uint)(0xFF * darken * opacity) << 24); @@ -69,8 +69,8 @@ internal sealed class SurfaceBackdrop dl.AddRectFilledMultiColor( min, new Vector2(max.X, min.Y + (max.Y - min.Y) * accentWashHeight), - ColourUtil.ApplyAlpha(accent, 0.045f * opacity), - ColourUtil.ApplyAlpha(accent, 0.02f * opacity), + ColourUtil.ApplyAlpha(accent, 0.075f * opacity), + ColourUtil.ApplyAlpha(accent, 0.035f * opacity), accent & 0x00FFFFFFu, accent & 0x00FFFFFFu ); diff --git a/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs index 98e78a9..d5ab9bf 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/SegmentedControl.cs @@ -143,15 +143,17 @@ internal static class SegmentedControl MetricsMath.Center(height, textSize.Y) ), ColourUtil.ApplyAlpha( + // Measured against the surface each label actually sits on: + // the accent fill for the selected one, the track for the + // rest. A light/dark guess was not enough -- an accent can + // be mid-luminance and still fail against both. isSelected - // On the accent fill, so the readable colour depends on - // how light that accent is in the active theme. - ? ColourUtil.OnColour( - colors.SelectedAbgr, + ? ColourUtil.EnsureContrast( colors.SelectedLabelAbgr, - colors.TrackAbgr + colors.SelectedAbgr, + 4.5f ) - : colors.LabelAbgr, + : ColourUtil.EnsureContrast(colors.LabelAbgr, colors.TrackAbgr, 4.5f), alpha ), label diff --git a/HellionChat/Util/ColourUtil.cs b/HellionChat/Util/ColourUtil.cs index 5c203a9..a8bd6ac 100755 --- a/HellionChat/Util/ColourUtil.cs +++ b/HellionChat/Util/ColourUtil.cs @@ -108,15 +108,73 @@ internal static class ColourUtil // going fully saturated (effect level stays "subtle"). RGB-only on // purpose -- DrawHoverSheen owns the alpha falloff. // TEST-MIRROR: ../../../Hellion Build test/Util/ColourUtilTintTests.cs - // Relative luminance of an ABGR colour, 0..1, using the sRGB coefficients. - // Alpha is ignored: this answers "is this surface light or dark", and a - // translucent light surface still reads light against the window behind it. + // Relative luminance per WCAG 2.1, 0..1. Channels are linearised first: + // sRGB is gamma-encoded, so averaging the raw bytes overstates the + // brightness of dark colours badly -- and almost every surface in this + // plugin is a dark colour. + // + // Alpha is ignored. This answers "is this light or dark", and a translucent + // light surface still reads light against what is behind it. internal static float Luminance(uint abgr) { - var r = (abgr & 0xFFu) / 255f; - var g = ((abgr >> 8) & 0xFFu) / 255f; - var b = ((abgr >> 16) & 0xFFu) / 255f; - return 0.2126f * r + 0.7152f * g + 0.0722f * b; + static float Linear(uint channel) + { + var c = channel / 255f; + return c <= 0.04045f ? c / 12.92f : MathF.Pow((c + 0.055f) / 1.055f, 2.4f); + } + + return 0.2126f * Linear(abgr & 0xFFu) + + 0.7152f * Linear((abgr >> 8) & 0xFFu) + + 0.0722f * Linear((abgr >> 16) & 0xFFu); + } + + // WCAG contrast ratio between two colours, 1.0 (identical) to 21.0 (black + // on white). 4.5 is the readability floor for body text, 3.0 for large text + // and for icons and other non-text marks. + internal static float ContrastRatio(uint a, uint b) + { + var la = Luminance(a); + var lb = Luminance(b); + var (hi, lo) = la > lb ? (la, lb) : (lb, la); + return (hi + 0.05f) / (lo + 0.05f); + } + + // Pushes a foreground away from its background until it clears the ratio, + // moving whichever direction the background is not. + // + // This is what a fixed palette cannot do. A theme picks one text colour, but + // the same text lands on a base surface, on a lit top edge and on an accent + // fill, and a value that reads on one of those can vanish on another. White + // on pale violet was the reported case. + internal static uint EnsureContrast(uint foregroundAbgr, uint backgroundAbgr, float minRatio) + { + if (ContrastRatio(foregroundAbgr, backgroundAbgr) >= minRatio) + return foregroundAbgr; + + // Direction is chosen by which end actually reaches further, not by + // whether the background counts as dark. A mid-luminance background can + // sit below 0.5 and still be far too light for white text: pale violet + // measures 0.39, so a "background is dark, brighten it" rule tried to + // make white whiter and got nowhere. + var towardWhite = + ContrastRatio(0xFFFFFFFFu, backgroundAbgr) > ContrastRatio(0xFF000000u, backgroundAbgr); + var best = foregroundAbgr; + + // Sixteen steps to full white or full black. Stops at the first value + // that clears, so a colour only travels as far as it has to and keeps + // as much of its hue as the ratio allows. + for (var i = 1; i <= 16; i++) + { + var t = i / 16f; + best = towardWhite + ? LerpTowardWhite(foregroundAbgr, t) + : LerpTowardBlack(foregroundAbgr, t); + + if (ContrastRatio(best, backgroundAbgr) >= minRatio) + return best; + } + + return best; } // Picks whichever of two candidates stands further from the background. From 04f8f1ace81a6ccbb64b535b6dc423b2bbec5b2d Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 19:01:54 +0200 Subject: [PATCH 091/129] feat(settings): opaque settings window, contrast applied throughout The settings window no longer inherits the chat window's transparency. GlobalStyleScope pushes one opacity for every window in the plugin, so a value chosen to keep the chat log out of the way was also deciding how readable a settings dialog is. The stronger reason is that contrast cannot be computed against a background that is not there. Behind a translucent window the real background is the game: a black cave one minute, a snowfield the next. Every foreground measured last commit was measured against a colour it does not actually land on. Opaque makes that measurement true. BgAlpha only reaches the window fill, never the draw list, so the backdrop cannot observe it -- it takes an explicit override instead of guessing from the pushed WindowBg. Contrast now runs through SettingsPalette, so every row and heading gets it without each call site asking: 4.5:1 for labels and titles, 3:1 for descriptions and accents. The lower floor on descriptions is deliberate. It preserves the rank between the two lines of a row, which is why the description is dimmer at all, while still guaranteeing it stays legible. The toggle's border is in there too. It is the only thing marking an off switch, and a border tone that blends into the surface leaves the control invisible. --- .../Ui/Components/Settings/ContentArea.cs | 4 +- .../Ui/Components/Settings/SettingsPalette.cs | 64 ++++++++++++++----- .../Ui/Components/Settings/TabSidebar.cs | 5 +- HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs | 11 +++- HellionChat/Ui/Windows/SettingsWindow.cs | 12 ++++ 5 files changed, 76 insertions(+), 20 deletions(-) diff --git a/HellionChat/Ui/Components/Settings/ContentArea.cs b/HellionChat/Ui/Components/Settings/ContentArea.cs index 71e872a..160eb34 100644 --- a/HellionChat/Ui/Components/Settings/ContentArea.cs +++ b/HellionChat/Ui/Components/Settings/ContentArea.cs @@ -27,7 +27,9 @@ internal sealed class ContentArea return; } - _backdrop.Draw(); + // Fully opaque: SettingsWindow sets BgAlpha = 1, which the backdrop + // cannot see for itself. + _backdrop.Draw(opacityOverride: 1f); renderTab(activeTab); } diff --git a/HellionChat/Ui/Components/Settings/SettingsPalette.cs b/HellionChat/Ui/Components/Settings/SettingsPalette.cs index c7c24da..6d95cfc 100644 --- a/HellionChat/Ui/Components/Settings/SettingsPalette.cs +++ b/HellionChat/Ui/Components/Settings/SettingsPalette.cs @@ -1,6 +1,7 @@ using HellionChat.Themes; using HellionChat.Ui.StyleEngine; using HellionChat.Ui.StyleEngine.Widgets; +using HellionChat.Util; namespace HellionChat.Ui.Components.Settings; @@ -19,35 +20,66 @@ internal sealed class SettingsPalette _palette = new WidgetPalette(resolver); } - internal SettingRowColors Row(ThemeColors c) => - new() + // Everything a caller reads is measured against the surface it lands on + // rather than taken at face value. A theme sets one text colour; the pane + // under it is tinted by the backdrop, and a muted tone that reads on the + // base surface can disappear on the lit edge. + // + // 4.5:1 for the label, 3:1 for the description -- the lower floor keeps the + // rank between the two lines, which is the whole reason the description is + // dimmer, while still guaranteeing it stays readable. + internal SettingRowColors Row(ThemeColors c) + { + var surface = _palette.Abgr(Token.SurfaceBase, c); + return new SettingRowColors { - LabelAbgr = _palette.Abgr(Token.Text, c), - // A step below the label, not level with it: at TextMuted the two - // lines carried the same weight and the row read as two settings. - DescriptionAbgr = _palette.Abgr(Token.TextFaint, c), + LabelAbgr = ColourUtil.EnsureContrast(_palette.Abgr(Token.Text, c), surface, 4.5f), + DescriptionAbgr = ColourUtil.EnsureContrast( + _palette.Abgr(Token.TextFaint, c), + surface, + 3f + ), SurfaceHoverAbgr = _palette.Abgr(Token.SurfaceHover, c), BorderAbgr = _palette.Abgr(Token.Border, c), }; + } - internal ToggleSwitchColors Toggle(ThemeColors c) => - new() + internal ToggleSwitchColors Toggle(ThemeColors c) + { + var surface = _palette.Abgr(Token.SurfaceBase, c); + return new ToggleSwitchColors { - TrackOffAbgr = _palette.Abgr(Token.SurfaceBase, c), + TrackOffAbgr = surface, TrackOnAbgr = _palette.Abgr(Token.AccentPrimary, c), KnobAbgr = _palette.Abgr(Token.Text, c), - BorderAbgr = _palette.Abgr(Token.Border, c), + // The outline is the only thing that marks an off switch, so it has + // to clear the surface it sits on rather than blend into it. + BorderAbgr = ColourUtil.EnsureContrast(_palette.Abgr(Token.Border, c), surface, 3f), }; + } - internal SectionHeaderColors Section(ThemeColors c) => - new() + internal SectionHeaderColors Section(ThemeColors c) + { + var surface = _palette.Abgr(Token.SurfaceBase, c); + return new SectionHeaderColors { - TitleAbgr = _palette.Abgr(Token.Text, c), - DescriptionAbgr = _palette.Abgr(Token.TextMuted, c), - AccentAbgr = _palette.Abgr(Token.AccentPrimary, c), + TitleAbgr = ColourUtil.EnsureContrast(_palette.Abgr(Token.Text, c), surface, 4.5f), + DescriptionAbgr = ColourUtil.EnsureContrast( + _palette.Abgr(Token.TextMuted, c), + surface, + 3f + ), + // The chevron and the hover tint of the heading both use this, so it + // has to clear the icon floor even when a theme picks a dim accent. + AccentAbgr = ColourUtil.EnsureContrast( + _palette.Abgr(Token.AccentPrimary, c), + surface, + 3f + ), BorderAbgr = _palette.Abgr(Token.Border, c), HoverAbgr = _palette.Abgr(Token.SurfaceHover, c), }; + } internal SegmentedControlColors Segmented(ThemeColors c) => new() @@ -55,6 +87,8 @@ internal sealed class SettingsPalette TrackAbgr = _palette.Abgr(Token.SurfaceBase, c), SelectedAbgr = _palette.Abgr(Token.AccentPrimary, c), HoverAbgr = _palette.Abgr(Token.SurfaceHover, c), + // Both are re-measured inside the widget against the fill each one + // actually sits on, which differs per segment. LabelAbgr = _palette.Abgr(Token.TextMuted, c), SelectedLabelAbgr = _palette.Abgr(Token.Text, c), BorderAbgr = _palette.Abgr(Token.Border, c), diff --git a/HellionChat/Ui/Components/Settings/TabSidebar.cs b/HellionChat/Ui/Components/Settings/TabSidebar.cs index 4b08dce..335c252 100644 --- a/HellionChat/Ui/Components/Settings/TabSidebar.cs +++ b/HellionChat/Ui/Components/Settings/TabSidebar.cs @@ -44,8 +44,9 @@ internal sealed class TabSidebar // its own background already; filling over it stacks a second layer and // turns a translucent window solid. This only needs to read as the // darker of two planes, and a tint does that. - var opacity = ((ImGui.GetColorU32(ImGuiCol.WindowBg) >> 24) & 0xFFu) / 255f; - dl.AddRectFilled(min, max, (uint)(0x38 * opacity) << 24); + // Opaque, matching SettingsWindow.BgAlpha; the pushed WindowBg still + // carries the chat window's transparency and would wash this out. + dl.AddRectFilled(min, max, 0x38u << 24); // The icon font has no ASCII glyphs, so anything textual drawn inside a // FontAwesome scope comes out blank -- twice bitten in this plugin. The diff --git a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs index fb506b1..6990e1b 100644 --- a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs +++ b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs @@ -32,7 +32,8 @@ internal sealed class SurfaceBackdrop float accentWashHeight = 0.38f, float darken = 0f, float moteIntensity = 1f, - float strength = 1f + float strength = 1f, + float? opacityOverride = null ) { var dl = ImGui.GetWindowDrawList(); @@ -50,7 +51,13 @@ internal sealed class SurfaceBackdrop // Near-transparent white over black instead: the window colour stays // visible underneath, the ramp only tints it, and the game showing // through breaks up any step that is left. - var opacity = ((ImGui.GetColorU32(ImGuiCol.WindowBg) >> 24) & 0xFFu) / 255f; + // Read from the pushed WindowBg by default, because BgAlpha only reaches + // the window fill and not the draw list. A window that overrides its own + // opacity has to say so -- SetNextWindowBgAlpha is invisible from here, + // so the settings pane would otherwise tint itself for the chat window's + // transparency while being fully opaque. + var opacity = + opacityOverride ?? ((ImGui.GetColorU32(ImGuiCol.WindowBg) >> 24) & 0xFFu) / 255f; // Two short fades rather than one tall ramp: light from the top edge, // shadow gathering at the bottom, flat in between. A ramp stretched over diff --git a/HellionChat/Ui/Windows/SettingsWindow.cs b/HellionChat/Ui/Windows/SettingsWindow.cs index 543b2e1..23b6455 100644 --- a/HellionChat/Ui/Windows/SettingsWindow.cs +++ b/HellionChat/Ui/Windows/SettingsWindow.cs @@ -83,6 +83,18 @@ internal sealed class SettingsWindow : Window var wanted = $"{Language.Settings_Title.Format(Plugin.PluginName)}###chat2-settings"; if (!string.Equals(WindowName, wanted, StringComparison.Ordinal)) WindowName = wanted; + + // Opaque, unlike the chat window. GlobalStyleScope pushes the chat's + // opacity for every window in the plugin, and this one inherited it. + // + // Two reasons it should not. Contrast can only be computed against a + // known background, and behind a translucent window the real background + // is the game -- a black cave one minute, a snowfield the next, so the + // colour every foreground was just measured against is not the colour it + // lands on. And nobody adjusting a plugin needs to watch what is + // happening behind the dialog; the chat window is the one that has to + // stay out of the way. + BgAlpha = 1f; } public override void Draw() From d30922af8c5d84d56bdea71acb026d42c0a56e16 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 19:07:04 +0200 Subject: [PATCH 092/129] feat(settings): convert the appearance tab, finishing the window The last six stock collapsing headers are gone, so all seven tabs now read the same way. Appearance is the one people open most, being where the themes live, and it was still the odd one out. Its four components each needed only the heading, so they get a SectionRenderer rather than the whole widget set -- SettingsWidgets would have handed a colour editor a Plugin reference it has no use for. Three of the six could not take a literal key. The colour editor draws its section seven times with seven titles, so the key becomes a parameter. The theme picker keys its categories by position, since CategoryMap is a fixed list and the index survives those names being translated later. And the custom-theme section takes a fixed key although its label carries a count, or it would reset every time a theme is imported or removed. The theme picker's headers also pick up the disabled state properly now. They sit inside an ImRaii.Disabled while a theme is being edited, and a draw-list header cannot see that push -- so they had stayed at full contrast while everything under them dimmed. --- HellionChat/PluginHostFactory.cs | 17 ++++++-- .../Components/Settings/ChatColourPicker.cs | 12 +++++- .../Ui/Components/Settings/ColorPicker.cs | 17 +++++++- .../Ui/Components/Settings/FontsSection.cs | 20 ++++++++-- .../Ui/Components/Settings/SectionRenderer.cs | 39 +++++++++++++++++++ .../Ui/Components/Settings/ThemePicker.cs | 30 ++++++++++---- 6 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 HellionChat/Ui/Components/Settings/SectionRenderer.cs diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 0de13eb..9c02edc 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -104,6 +104,11 @@ internal static class PluginHostFactory // Transient: each surface owns its motes, so two backdrops on screen do // not drift in lockstep. + services.AddSingleton(sp => new Ui.Components.Settings.SectionRenderer( + sp.GetRequiredService(), + sp.GetRequiredService() + )); + services.AddTransient(sp => new Ui.StyleEngine.SurfaceBackdrop( sp.GetRequiredService(), sp.GetRequiredService() @@ -176,10 +181,12 @@ internal static class PluginHostFactory )); services.AddSingleton(sp => new Ui.Components.Settings.ThemePicker( sp.GetRequiredService(), - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.ColorPicker( - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.LivePreviewPanel( sp.GetRequiredService(), @@ -192,11 +199,13 @@ internal static class PluginHostFactory )); services.AddSingleton(sp => new Ui.Components.Settings.FontsSection( sp.GetRequiredService(), - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.ChatColourPicker( sp.GetRequiredService(), - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.AppearanceTab( sp.GetRequiredService(), diff --git a/HellionChat/Ui/Components/Settings/ChatColourPicker.cs b/HellionChat/Ui/Components/Settings/ChatColourPicker.cs index 733305f..efade1a 100644 --- a/HellionChat/Ui/Components/Settings/ChatColourPicker.cs +++ b/HellionChat/Ui/Components/Settings/ChatColourPicker.cs @@ -17,11 +17,13 @@ internal sealed class ChatColourPicker { private readonly Plugin _plugin; private readonly ThemeRegistry _themes; + private readonly SectionRenderer _sections; private string? _applyDismissedFor; private string? _lastSeenSlug; - public ChatColourPicker(Plugin plugin, ThemeRegistry themes) + public ChatColourPicker(Plugin plugin, ThemeRegistry themes, SectionRenderer sections) { + _sections = sections; _plugin = plugin; _themes = themes; } @@ -32,7 +34,13 @@ internal sealed class ChatColourPicker public void Draw() { - if (!ImGui.CollapsingHeader(HellionStrings.Settings_Section_Colours)) + if ( + !_sections.Draw( + ImGui.GetID("appearance.chatcolours"u8), + HellionStrings.Settings_Section_Colours, + open: false + ) + ) return; DrawPresetButtons(); diff --git a/HellionChat/Ui/Components/Settings/ColorPicker.cs b/HellionChat/Ui/Components/Settings/ColorPicker.cs index b991910..bda8c08 100644 --- a/HellionChat/Ui/Components/Settings/ColorPicker.cs +++ b/HellionChat/Ui/Components/Settings/ColorPicker.cs @@ -9,9 +9,11 @@ namespace HellionChat.Ui.Components.Settings; internal sealed class ColorPicker { private readonly ThemeRegistry _themes; + private readonly SectionRenderer _sections; - public ColorPicker(ThemeRegistry themes) + public ColorPicker(ThemeRegistry themes, SectionRenderer sections) { + _sections = sections; _themes = themes; } @@ -89,6 +91,7 @@ internal sealed class ColorPicker ImGui.Separator(); DrawSection( + ImGui.GetID("colors.surfaces"u8), "Surfaces", buffer, c => @@ -112,6 +115,7 @@ internal sealed class ColorPicker ); DrawSection( + ImGui.GetID("colors.borders"u8), "Borders", buffer, c => new[] { ("Border", c.Border) }, @@ -119,6 +123,7 @@ internal sealed class ColorPicker ); DrawSection( + ImGui.GetID("colors.text"u8), "Text", buffer, c => @@ -138,6 +143,7 @@ internal sealed class ColorPicker ); DrawSection( + ImGui.GetID("colors.brand.primary"u8), "Brand — Primary", buffer, c => @@ -159,6 +165,7 @@ internal sealed class ColorPicker ); DrawSection( + ImGui.GetID("colors.brand.accent"u8), "Brand — Accent", buffer, c => @@ -178,6 +185,7 @@ internal sealed class ColorPicker ); DrawSection( + ImGui.GetID("colors.identity"u8), "Identity", buffer, c => new[] { ("Identity", c.Identity) }, @@ -185,6 +193,7 @@ internal sealed class ColorPicker ); DrawSection( + ImGui.GetID("colors.status"u8), "Status", buffer, c => @@ -210,13 +219,17 @@ internal sealed class ColorPicker } private void DrawSection( + uint key, string title, Theme buffer, Func slots, Func writeBack ) { - if (!ImGui.CollapsingHeader(title, ImGuiTreeNodeFlags.DefaultOpen)) + // Key as a parameter: this runs seven times with seven titles, and + // ImGui would key each header off its label, so a translation could + // collapse two sections onto one shared state. + if (!_sections.Draw(key, title)) { return; } diff --git a/HellionChat/Ui/Components/Settings/FontsSection.cs b/HellionChat/Ui/Components/Settings/FontsSection.cs index ce6c604..279af05 100644 --- a/HellionChat/Ui/Components/Settings/FontsSection.cs +++ b/HellionChat/Ui/Components/Settings/FontsSection.cs @@ -15,9 +15,11 @@ internal sealed class FontsSection { private readonly Plugin _plugin; private readonly FontManager _fontManager; + private readonly SectionRenderer _sections; - public FontsSection(Plugin plugin, FontManager fontManager) + public FontsSection(Plugin plugin, FontManager fontManager, SectionRenderer sections) { + _sections = sections; _plugin = plugin; _fontManager = fontManager; } @@ -30,7 +32,13 @@ internal sealed class FontsSection public void Draw() { - if (!ImGui.CollapsingHeader(HellionStrings.Settings_Section_Fonts)) + if ( + !_sections.Draw( + ImGui.GetID("appearance.fonts"u8), + HellionStrings.Settings_Section_Fonts, + open: false + ) + ) return; // Readout so the user can see which font is actually active. @@ -126,7 +134,13 @@ internal sealed class FontsSection // ExtraGlyphRanges stays reachable regardless of the font source so the // user can verify/override the per-language auto-activation (v1.5.3 note). ImGui.Spacing(); - if (ImGui.CollapsingHeader(Language.Options_ExtraGlyphs_Name)) + if ( + _sections.Draw( + ImGui.GetID("appearance.fonts.glyphs"u8), + Language.Options_ExtraGlyphs_Name, + open: false + ) + ) { ImGuiUtil.HelpMarker( string.Format(Language.Options_ExtraGlyphs_Description, Plugin.PluginName) diff --git a/HellionChat/Ui/Components/Settings/SectionRenderer.cs b/HellionChat/Ui/Components/Settings/SectionRenderer.cs new file mode 100644 index 0000000..b8624cd --- /dev/null +++ b/HellionChat/Ui/Components/Settings/SectionRenderer.cs @@ -0,0 +1,39 @@ +using Dalamud.Bindings.ImGui; +using HellionChat.Themes; +using HellionChat.Ui.StyleEngine; +using HellionChat.Ui.StyleEngine.Widgets; + +namespace HellionChat.Ui.Components.Settings; + +// Section headings for the components that are not built out of setting rows -- +// the theme picker, the colour editor, the font panel. They need the heading and +// nothing else from SettingsWidgets, and pulling that in would hand each of them +// a Plugin reference they have no use for. +internal sealed class SectionRenderer +{ + private readonly ThemeRegistry _themes; + private readonly SettingsPalette _palette; + + private int _frame = -1; + private SectionHeaderColors _colors; + + internal SectionRenderer(ThemeRegistry themes, TokenResolver resolver) + { + _themes = themes; + _palette = new SettingsPalette(resolver); + } + + // Key, not title. ImGui's own storage keys a collapsing header off its + // label, so translated titles would reset every open section on a language + // switch, and two categories translating alike would share one state. + internal bool Draw(uint key, string title, bool open = true, bool disabled = false) + { + if (_frame != ImGui.GetFrameCount()) + { + _frame = ImGui.GetFrameCount(); + _colors = _palette.Section(_themes.Active.Colors); + } + + return SectionHeader.Draw(key, title, null, _colors, defaultOpen: open, disabled: disabled); + } +} diff --git a/HellionChat/Ui/Components/Settings/ThemePicker.cs b/HellionChat/Ui/Components/Settings/ThemePicker.cs index eab7a23..e9a392c 100644 --- a/HellionChat/Ui/Components/Settings/ThemePicker.cs +++ b/HellionChat/Ui/Components/Settings/ThemePicker.cs @@ -32,10 +32,12 @@ internal sealed class ThemePicker private const float CardHeight = 132f; private readonly ThemeRegistry _themes; + private readonly SectionRenderer _sections; private readonly Plugin _plugin; - public ThemePicker(ThemeRegistry themes, Plugin plugin) + public ThemePicker(ThemeRegistry themes, Plugin plugin, SectionRenderer sections) { + _sections = sections; _themes = themes; _plugin = plugin; } @@ -46,12 +48,20 @@ internal sealed class ThemePicker using (ImRaii.Disabled(locked)) { - foreach (var (category, slugs, defaultExpanded) in CategoryMap) + // Keyed by position, not by name. CategoryMap is a fixed literal + // list, so the index is stable, and it survives the category names + // being translated later -- which the titles themselves would not. + for (var i = 0; i < CategoryMap.Length; i++) { - var flags = defaultExpanded - ? ImGuiTreeNodeFlags.DefaultOpen - : ImGuiTreeNodeFlags.None; - if (ImGui.CollapsingHeader(category, flags)) + var (category, slugs, defaultExpanded) = CategoryMap[i]; + if ( + _sections.Draw( + ImGui.GetID($"theme.category.{i}"), + category, + open: defaultExpanded, + disabled: locked + ) + ) { DrawThemeGrid(Resolve(slugs)); } @@ -62,10 +72,14 @@ internal sealed class ThemePicker var customs = _themes.AllCustom().ToList(); if (customs.Count > 0) { + // Fixed key although the label carries a count: keying off the + // text would reset the section every time a theme is imported + // or deleted. if ( - ImGui.CollapsingHeader( + _sections.Draw( + ImGui.GetID("theme.category.custom"u8), $"Custom ({customs.Count})", - ImGuiTreeNodeFlags.DefaultOpen + disabled: locked ) ) { From 9c7be4106d6e0b0ab9dd084e8ebdb60b228b1856 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 19:09:40 +0200 Subject: [PATCH 093/129] feat(popouts): bring the pop-out windows up to the rest Three things, all visible in one screenshot. The tab name was drawn twice. The pop-out has a title bar carrying the tab name and a header row underneath repeating it -- the same string, one line apart. The header now only draws when the title bar is off, which is the case it was written for. The close button was ImGui.Button, so it took the theme's button colours. On several themes that is a bright magenta plate sitting next to plain text, making the way out of the window the loudest thing in it. It uses the plugin's own icon button now: glyph contrast-checked against the surface, danger colour on hover only. And the message area gets the same floor as the main log, with the same restraint -- no accent wash, motes at a tenth. Pop-outs were the last surface still sitting on flat background while every other window had depth. --- HellionChat/PluginHostFactory.cs | 5 +- HellionChat/Ui/Windows/ChannelPopoutWindow.cs | 65 +++++++++++++++---- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 9c02edc..d36659c 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -324,7 +324,10 @@ internal static class PluginHostFactory sp.GetRequiredService() ), sp.GetRequiredService>(), - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() ) ); services.AddSingleton(sp => new Ui.Windows.ChannelPopoutPool( diff --git a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs index a7fa4e1..8bcdb3f 100644 --- a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs +++ b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs @@ -3,7 +3,10 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; +using HellionChat.Themes; using HellionChat.Ui.Components; +using HellionChat.Ui.StyleEngine.Widgets; +using HellionChat.Util; using Microsoft.Extensions.Logging; namespace HellionChat.Ui.Windows; @@ -20,13 +23,19 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow private readonly InputBar _input; private readonly ILogger _logger; private readonly FontManager _fonts; + private readonly Ui.StyleEngine.SurfaceBackdrop _backdrop; + private readonly ThemeRegistry _themes; + private readonly Ui.StyleEngine.TokenResolver _resolver; public ChannelPopoutWindow( int slotIndex, MessageList messages, InputBar input, ILogger logger, - FontManager fonts + FontManager fonts, + Ui.StyleEngine.SurfaceBackdrop backdrop, + ThemeRegistry themes, + Ui.StyleEngine.TokenResolver resolver ) : base($"{Plugin.PluginName}###hellion_popout_{slotIndex}") { @@ -35,6 +44,9 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow _input = input; _logger = logger; _fonts = fonts; + _backdrop = backdrop; + _themes = themes; + _resolver = resolver; IsOpen = false; RespectCloseHotkey = false; ShowCloseButton = false; @@ -103,7 +115,11 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow if (Bound is null) return; - DrawHeader(Bound); + // Only when the window has no title bar of its own. With the bar on, + // this row repeated the tab name directly underneath it -- the same + // string twice, one line apart. + if (!Plugin.Config.ShowPopOutTitleBar) + DrawHeader(Bound); // The header close button can unbind us mid-frame (CloseRequested -> // pool.TryClose -> Unbind nulls Bound). Re-check before the body so we @@ -125,7 +141,13 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow ) { if (body.Success) + { + // Same floor as the main window's log, and the same reasoning: + // no accent wash and barely any motes, because a chat log is read + // line by line. + _backdrop.Draw(accentWashHeight: 0f, moteIntensity: 0.10f, strength: 0.45f); _messages.Draw(Bound); + } } _input.Draw(Bound); @@ -137,18 +159,37 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow // close button is the canonical "send the tab back" affordance for v1.8.0. // PartnerHonorific is deferred (HonorificService has no per-target title, // plan §D / Sub-Spec WARN-8) — no honorific row here. + var colors = _themes.Active.Colors; + var text = ColourUtil.RgbaToAbgr(_resolver.Resolve(Ui.StyleEngine.Token.Text, colors)); + var surface = ColourUtil.RgbaToAbgr( + _resolver.Resolve(Ui.StyleEngine.Token.SurfaceBase, colors) + ); + var danger = ColourUtil.RgbaToAbgr( + _resolver.Resolve(Ui.StyleEngine.Token.StatusDanger, colors) + ); + ImGui.TextUnformatted(tab.Name); - ImGui.SameLine(); - using (_fonts.FontAwesome.Push()) + + // The plugin's own icon button rather than ImGui.Button. The stock one + // took the theme's button colours, which on several themes is a bright + // magenta plate next to plain text -- the loudest thing in the window + // being the way out of it. + var size = ImGui.GetFrameHeight(); + ImGui.SameLine(ImGui.GetContentRegionAvail().X - size); + var (clicked, _) = IconButton.Draw( + ImGui.GetID($"popout.close.{_slotIndex}"), + new Vector2(size, size), + FontAwesomeIcon.Times, + ColourUtil.EnsureContrast(text, surface, 3f), + danger, + _fonts.FontAwesome + ); + + if (clicked) { - ImGui.SameLine(ImGui.GetContentRegionAvail().X - ImGui.GetFrameHeight()); - if (ImGui.Button($"{FontAwesomeIcon.Times.ToIconString()}##popin-{_slotIndex}")) - { - // Pop-In: release the slot via the pool (not a bare Unbind, which - // would orphan the slot — the pool owns the slot bookkeeping). - CloseRequested?.Invoke(tab.Identifier); - } + // Pop-In: release the slot via the pool (not a bare Unbind, which + // would orphan the slot — the pool owns the slot bookkeeping). + CloseRequested?.Invoke(tab.Identifier); } - ImGui.Separator(); } } From 051fd648579d2f1fa1fc025b2a3051d4e51419e8 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 19:15:00 +0200 Subject: [PATCH 094/129] fix(settings): use the translations that were already sitting there The window came out half German and half English on a German client. Thirteen of those labels had a translated resource all along, reachable from no line of code -- the same defect this cycle has now hit four times. Most visible: all seven tab names in the sidebar. Settings_Tab_General and its siblings exist in 25 languages and the sidebar drew "General", "Appearance", "Chat" as literals. That is the first thing anyone sees in this window. Plus reduce motion, custom sound volume, show hide button, window opacity, the window style heading, and the themes folder button. Deliberately left alone where the wording matched but the context did not. "Never" exists as a world-suffix option, not as "the sweep has never run", and "Opacity" exists for a tab rather than a window; reusing either would read wrong in any language that inflects them differently. Those want their own keys, not a borrowed one. What stays English is what has no key at all: the section headings, the keybind labels, and the descriptions written during this cycle. Roughly a hundred strings, and adding them means 25 files each -- that is the localisation pass, not this commit. --- .../Ui/Components/Settings/TabSidebar.cs | 40 +++++++++++++++---- .../Ui/Components/Settings/Tabs/GeneralTab.cs | 4 +- .../Ui/Components/Settings/Tabs/WindowTab.cs | 12 ++++-- .../Settings/ThemeImportExportRow.cs | 3 +- 4 files changed, 46 insertions(+), 13 deletions(-) diff --git a/HellionChat/Ui/Components/Settings/TabSidebar.cs b/HellionChat/Ui/Components/Settings/TabSidebar.cs index 335c252..8cefa32 100644 --- a/HellionChat/Ui/Components/Settings/TabSidebar.cs +++ b/HellionChat/Ui/Components/Settings/TabSidebar.cs @@ -2,6 +2,7 @@ using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility.Raii; +using HellionChat.Resources; using HellionChat.Themes; using HellionChat.Ui.StyleEngine; using HellionChat.Util; @@ -71,13 +72,38 @@ internal sealed class TabSidebar IconSize = iconSize, }; - DrawEntry("general", FontAwesomeIcon.SlidersH, "General", palette); - DrawEntry("appearance", FontAwesomeIcon.Palette, "Appearance", palette); - DrawEntry("chat", FontAwesomeIcon.Comments, "Chat", palette); - DrawEntry("window", FontAwesomeIcon.WindowMaximize, "Window", palette); - DrawEntry("channels", FontAwesomeIcon.Hashtag, "Channels", palette); - DrawEntry("data-privacy", FontAwesomeIcon.Shield, "Data & Privacy", palette); - DrawEntry("about", FontAwesomeIcon.InfoCircle, "About", palette); + DrawEntry( + "general", + FontAwesomeIcon.SlidersH, + HellionStrings.Settings_Tab_General, + palette + ); + DrawEntry( + "appearance", + FontAwesomeIcon.Palette, + HellionStrings.Settings_Tab_Appearance, + palette + ); + DrawEntry("chat", FontAwesomeIcon.Comments, HellionStrings.Settings_Tab_Chat, palette); + DrawEntry( + "window", + FontAwesomeIcon.WindowMaximize, + HellionStrings.Settings_Tab_Window, + palette + ); + DrawEntry("channels", FontAwesomeIcon.Hashtag, HellionStrings.Settings_Tab_Tabs, palette); + DrawEntry( + "data-privacy", + FontAwesomeIcon.Shield, + HellionStrings.Settings_Card_DataManagement_Title, + palette + ); + DrawEntry( + "about", + FontAwesomeIcon.InfoCircle, + HellionStrings.Settings_Tab_Information, + palette + ); } private readonly record struct SidebarPalette diff --git a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs index cd76c42..10a7653 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs @@ -38,7 +38,7 @@ internal sealed class GeneralTab { _w.ToggleRow( ImGui.GetID("general.behaviour.reducemotion"u8), - "Reduce motion", + HellionStrings.Settings_ThemeAndLayout_ReduceMotion_Name, "Turns off theme crossfades, hover fades and the drifting motes.", () => Plugin.Config.ReduceMotion, v => Plugin.Config.ReduceMotion = v @@ -75,7 +75,7 @@ internal sealed class GeneralTab ); _w.SliderFloatRow( ImGui.GetID("general.notifications.volume"u8), - "Custom sound volume", + HellionStrings.Settings_General_CustomSoundVolume_Name, null, () => Plugin.Config.CustomSoundVolume, v => Plugin.Config.CustomSoundVolume = v, diff --git a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs index adee5d5..f4feb8f 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs @@ -1,4 +1,5 @@ using Dalamud.Bindings.ImGui; +using HellionChat.Resources; using HellionChat.Ui.StyleEngine; namespace HellionChat.Ui.Components.Settings.Tabs; @@ -41,7 +42,12 @@ internal sealed class WindowTab ); } - if (_w.Section(ImGui.GetID("window.style"u8), "Window style")) + if ( + _w.Section( + ImGui.GetID("window.style"u8), + HellionStrings.Settings_ThemeAndLayout_WindowStyle_Heading + ) + ) { _w.ToggleRow( ImGui.GetID("window.style.titlebar"u8), @@ -59,7 +65,7 @@ internal sealed class WindowTab ); _w.ToggleRow( ImGui.GetID("window.style.hidebutton"u8), - "Show hide button", + Language.Options_ShowHideButton_Name, null, () => Plugin.Config.ShowHideButton, v => Plugin.Config.ShowHideButton = v @@ -70,7 +76,7 @@ internal sealed class WindowTab { _w.SliderFloatRow( ImGui.GetID("window.opacity.active"u8), - "Window opacity", + HellionStrings.Theme_WindowOpacity_Label, null, () => Plugin.Config.WindowOpacity, v => Plugin.Config.WindowOpacity = v, diff --git a/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs b/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs index 17a9743..4ae2f24 100644 --- a/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs +++ b/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Security; using Dalamud.Bindings.ImGui; +using HellionChat.Resources; using HellionChat.Themes; using Microsoft.Extensions.Logging; @@ -32,7 +33,7 @@ internal sealed class ThemeImportExportRow } ImGui.SameLine(); - if (ImGui.Button("Open themes folder")) + if (ImGui.Button(HellionStrings.Settings_Themes_OpenFolder)) { OpenThemesFolder(); } From d1e12ce866daf5b8fe30e4d130efe2df55fd66df Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 19:21:24 +0200 Subject: [PATCH 095/129] fix(popouts): restore a way to close a pop-out Last commit hid the header row when the title bar was on, to stop the tab name appearing twice. The close button was in that row, and the title bar carries none of its own -- ShowCloseButton is off because closing has to go through the pool to release the slot. So a pop-out with its title bar on could not be closed at all. Pop-in moves into the input row, alongside the settings and hide buttons that are already there. It is red, contrast-checked against the button plate it sits on, and carries a tooltip, since a bare X next to an emoji picker does not say where the tab is going. Wired by setter after construction rather than through the constructor: the window is what the button has to call, and it does not exist yet while its own input row is being built. Routing it through the graph would close a factory-callsite cycle MS.DI cannot detect. The header row is now just the tab name, drawn only when there is no title bar to carry it. InputBar_PopIn_Tooltip ships in all 25 languages, including the Designer entry. New strings get translated with the change from here on, not batched. --- .../Resources/HellionStrings.Designer.cs | 1 + HellionChat/Resources/HellionStrings.ca.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.cs.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.da.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.de.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.el.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.es.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.fi.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.fr.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.hu.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.it.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.ja.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.ko.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.nb.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.nl.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.pl.resx | 36 ++++++------ .../Resources/HellionStrings.pt-BR.resx | 36 ++++++------ .../Resources/HellionStrings.pt-PT.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.resx | 54 ++++++++--------- HellionChat/Resources/HellionStrings.ro.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.ru.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.sv.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.tr.resx | 36 ++++++------ HellionChat/Resources/HellionStrings.uk.resx | 36 ++++++------ .../Resources/HellionStrings.zh-Hans.resx | 36 ++++++------ .../Resources/HellionStrings.zh-Hant.resx | 36 ++++++------ HellionChat/Ui/Components/InputBar.cs | 39 ++++++++++++- HellionChat/Ui/Windows/ChannelPopoutWindow.cs | 58 ++++++------------- 28 files changed, 541 insertions(+), 475 deletions(-) diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index a99b701..f2956b1 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -420,6 +420,7 @@ internal class HellionStrings internal static string InputBar_Settings_Tooltip => Get(nameof(InputBar_Settings_Tooltip)); internal static string InputBar_HideChat_Tooltip => Get(nameof(InputBar_HideChat_Tooltip)); + internal static string InputBar_PopIn_Tooltip => Get(nameof(InputBar_PopIn_Tooltip)); internal static string Settings_QuickPicker_Themes_Header => Get(nameof(Settings_QuickPicker_Themes_Header)); internal static string Settings_QuickPicker_Tabs_Header => Get(nameof(Settings_QuickPicker_Tabs_Header)); internal static string Settings_ThemeAndLayout_ReduceMotion_Name => Get(nameof(Settings_ThemeAndLayout_ReduceMotion_Name)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index 97190aa..f3dab9a 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Traductors de la comunitat de Chat 2 (upstream) - + Tells actius @@ -504,7 +504,7 @@ Fixada: sobreviu al reconnectar. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Nota: Si XIV Messenger o un plugin similar suprimeix els tells, desactiva l'opció "Suppress DMs" allà perquè Hellion Chat pugui rebre tells i obrir les pestanyes automàtiques. - + Historial de tells a les pestanyes automàtiques @@ -559,7 +559,7 @@ Només té efecte quan les pestanyes auto-tell estan activades a la pestanya Chat. - + Configuració reestructurada @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 ha reestructurat la configuració en pestanyes temàtiques. La teva base de dades de xat i l'historial de missatges no han canviat. La configuració s'ha restablert als valors per defecte. Si vols tornar a seleccionar el teu perfil de privadesa, el botó Reobre es troba a la pestanya Privadesa. Una còpia de seguretat de la configuració anterior es troba a HellionChat.json.pre-v10-backup al costat del fitxer de configuració actiu. - + General @@ -590,9 +590,9 @@ Quant a - + - + Theme @@ -606,14 +606,14 @@ Marques de temps - + Marc de la finestra - + - + Mostra el botó del selector de símbols al costat de l'entrada del xat @@ -621,7 +621,7 @@ Afegeix un petit botó a l'esquerra de l'indicador de canal que obre una finestra emergent amb les icones de FFXIV i una llista de símbols seleccionada. Desactiva-ho si prefereixes una barra d'entrada més senzilla. - + Emmagatzematge @@ -632,9 +632,9 @@ Manteniment - + - + Sistema @@ -654,7 +654,7 @@ Si fas servir diverses linkshells, el mantenidor recomana una pestanya per shell per tenir una visió general més neta. Duplica la pestanya i restringeix la selecció de canal en cada còpia. - + Icona de la pestanya @@ -956,7 +956,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Entrada @@ -1155,4 +1155,6 @@ Amaga el xat (Retorn per recuperar-lo) - +Retorna aquesta pestanya a la finestra principal + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index 49b2d85..cadaf6d 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Komunitní překladatelé Chat 2 (upstream) - + Aktivní telly @@ -504,7 +504,7 @@ Připnuto: přežívá relog. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Poznámka: Pokud XIV Messenger nebo podobný plugin potlačuje telly, deaktivuj tam možnost „Suppress DMs", aby Hellion Chat mohl přijímat telly a otevírat auto-záložky. - + Historie tellů v auto-záložkách @@ -559,7 +559,7 @@ Funguje pouze tehdy, jsou-li auto-tell záložky zapnuty v záložce Chat. - + Nastavení bylo přestrukturováno @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 přestrukturoval nastavení do tematických záložek. Tvoje databáze chatu a historie zpráv zůstávají beze změny. Nastavení bylo resetováno na výchozí hodnoty. Chceš-li znovu vybrat profil soukromí, tlačítko Znovu otevřít je v záložce Soukromí. Záloha předchozí konfigurace se nachází v HellionChat.json.pre-v10-backup vedle aktivního konfiguračního souboru. - + Obecné @@ -590,9 +590,9 @@ O pluginu - + - + Theme @@ -606,14 +606,14 @@ Časová razítka - + Rám okna - + - + Zobrazit tlačítko pro výběr symbolů vedle chatového vstupu @@ -621,7 +621,7 @@ Přidá malé tlačítko vlevo od indikátoru kanálu, které otevře popup s ikonami FFXIV a kurátovaným seznamem symbolů. Vypni, chceš-li mít čistší vstupní lištu. - + Úložiště @@ -632,9 +632,9 @@ Údržba - + - + Systém @@ -654,7 +654,7 @@ Pokud používáš více linkshellů, maintainer doporučuje mít jednu záložku na shell pro přehlednější zobrazení. Zduplikuj záložku a v každé kopii omezte výběr kanálů. - + Ikona záložky @@ -955,7 +955,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Vstup @@ -1154,4 +1154,6 @@ Skrýt chat (Enter jej vrátí) - +Vrátit tuto kartu do hlavního okna + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index a480fc4..4adc356 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Chat 2 community-oversættere (upstream) - + Aktive tells @@ -504,7 +504,7 @@ Fastgjort: overlever genlog. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Bemærk: Hvis XIV Messenger eller et lignende plugin undertrykker tells, skal du deaktivere indstillingen "Suppress DMs" der, så Hellion Chat kan modtage tells og åbne auto-tabs. - + Tell-historik i auto-tabs @@ -559,7 +559,7 @@ Træder kun i kraft når auto-tell-tabs er aktiveret under fanen Chat. - + Indstillinger omstruktureret @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 har omstruktureret indstillingerne i tematiske faner. Din chat-database og beskedhistorik er uændret. Indstillinger er nulstillet til standarder. Hvis du vil vælge din privatlivsprofil på ny, finder du Genåbn-knappen under fanen Privatliv. En sikkerhedskopi af den forrige konfiguration ligger ved siden af den aktive konfigurationsfil som HellionChat.json.pre-v10-backup. - + Generelt @@ -590,9 +590,9 @@ Om - + - + Theme @@ -606,14 +606,14 @@ Tidsstempler - + Vinduesramme - + - + Vis symbolvælger-knap ved siden af chat-input @@ -621,7 +621,7 @@ Tilføjer en lille knap til venstre for kanalindikatoren, der åbner et popup med FFXIV-ikoner og en udvalgt symbolliste. Deaktivér for en slankere inputlinje. - + Lagring @@ -632,9 +632,9 @@ Vedligeholdelse - + - + System @@ -654,7 +654,7 @@ Bruger du flere linkshells, anbefaler vedligeholderen én tab pr. shell for et bedre overblik. Dupliker tab'en og begræns kanalvalget i hver kopi. - + Tab-ikon @@ -955,7 +955,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Input @@ -1154,4 +1154,6 @@ Skjul chat (Enter henter den tilbage) - +Send denne fane tilbage til hovedvinduet + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index d176caf..c6dff2a 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Chat-2-Community-Übersetzer (Upstream) - + Aktive Tells @@ -504,7 +504,7 @@ Breite der Tab-Sidebar in Pixeln. Default (44 px) ist Icon-only; breiter machen damit Sektion-Header wie „Aktive Tells (3)" nicht abgeschnitten werden. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Hinweis: Falls XIV Messanger oder ein ähnliches Plugin Tells unterdrückt, dort die Option „Suppress DMs" deaktivieren, damit Hellion Chat Tells empfangen und die Auto-Tabs öffnen kann. - + Tell-Verlauf in Auto-Tabs @@ -559,7 +559,7 @@ Greift nur, wenn Auto-Tell-Tabs im Chat-Tab aktiviert sind. - + Settings umstrukturiert @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 hat die Settings in thematische Tabs umstrukturiert. Deine Chat-Datenbank und dein Nachrichtenverlauf bleiben unverändert. Settings wurden auf Defaults zurückgesetzt. Falls du das Privacy-Profil neu wählen willst, findest du den Reopen-Button im Datenschutz-Tab. Ein Backup der vorherigen Config liegt unter HellionChat.json.pre-v10-backup neben der aktiven Config-Datei. - + Allgemein @@ -590,9 +590,9 @@ Über - + - + Theme @@ -606,14 +606,14 @@ Zeitstempel - + Fenster-Rahmen - + - + Symbol-Picker-Button neben dem Chat-Eingang anzeigen @@ -621,7 +621,7 @@ Fügt einen kleinen Button links neben dem Kanal-Indikator ein. Klick öffnet ein Popup mit FFXIV-Glyphen und einer kuratierten Symbol-Liste. Ausschalten für eine schlankere Eingabezeile. - + Speicherung @@ -632,9 +632,9 @@ Wartung - + - + System @@ -654,7 +654,7 @@ Wenn du mehrere Linkshells benutzt, empfiehlt der Maintainer einen Tab pro Shell für eine sauberere Übersicht. Tab duplizieren und je Kopie die Kanalauswahl einschränken. - + Tab-Icon @@ -950,7 +950,7 @@ Deaktiviert die Theme-Überblendung, die Hover-Animationen von Seitenleiste und Karten sowie das Pulsieren ungelesener Tabs. Theme-Wechsel und Hover-Zustände greifen dann sofort. - + Eingabe @@ -1149,4 +1149,6 @@ Chat ausblenden (Enter holt ihn zurück) - +Diesen Tab ins Hauptfenster zurückholen + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index ca60161..d282972 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Μεταφραστές κοινότητας Chat 2 (upstream) - + Ενεργά tells @@ -504,7 +504,7 @@ Καρφιτσωμένο: επιβιώνει relog. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Σημείωση: Αν το XIV Messenger ή παρόμοιο plugin αποκλείει tells, απενεργοποίησε την επιλογή "Suppress DMs" εκεί ώστε το Hellion Chat να μπορεί να λαμβάνει tells και να ανοίγει τις αυτόματες καρτέλες. - + Ιστορικό tell σε αυτόματες καρτέλες @@ -559,7 +559,7 @@ Ισχύει μόνο όταν οι auto-tell tabs είναι ενεργοποιημένες στην καρτέλα Chat. - + Οι ρυθμίσεις αναδιαρθρώθηκαν @@ -567,7 +567,7 @@ Το Hellion Chat 0.5.0 αναδιάρθρωσε τις ρυθμίσεις σε θεματικές καρτέλες. Η βάση δεδομένων chat και το ιστορικό μηνυμάτων σου παραμένουν αναλλοίωτα. Οι ρυθμίσεις έχουν επαναφερθεί στις προεπιλογές. Αν θέλεις να επιλέξεις ξανά το προφίλ απορρήτου σου, το κουμπί Επαναφορά βρίσκεται στην καρτέλα Απόρρητο. Αντίγραφο ασφαλείας της προηγούμενης διαμόρφωσης βρίσκεται στο HellionChat.json.pre-v10-backup δίπλα στο ενεργό αρχείο διαμόρφωσης. - + Γενικά @@ -590,9 +590,9 @@ Πληροφορίες - + - + Theme @@ -606,14 +606,14 @@ Χρονοσφραγίδες - + Πλαίσιο παραθύρου - + - + Εμφάνιση κουμπιού symbol-picker δίπλα στο πεδίο chat @@ -621,7 +621,7 @@ Προσθέτει ένα μικρό κουμπί αριστερά της ένδειξης καναλιού που ανοίγει ένα popup με εικονίδια FFXIV και μια επιμελημένη λίστα συμβόλων. Απενεργοποίησε αν προτιμάς πιο λιτή γραμμή εισαγωγής. - + Αποθήκευση @@ -632,9 +632,9 @@ Συντήρηση - + - + System @@ -654,7 +654,7 @@ Αν χρησιμοποιείς πολλαπλά linkshells, ο maintainer συνιστά μία καρτέλα ανά shell για πιο καθαρή επισκόπηση. Αντίγραψε την καρτέλα και περιόρισε την επιλογή καναλιών σε κάθε αντίγραφο. - + Εικονίδιο καρτέλας @@ -955,7 +955,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Εισαγωγή @@ -1154,4 +1154,6 @@ Απόκρυψη συνομιλίας (Enter για επαναφορά) - +Επιστροφή αυτής της καρτέλας στο κύριο παράθυρο + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index 0b97cf2..bd5ae8a 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Traductores de la comunidad de Chat 2 (upstream) - + Tells activos @@ -504,7 +504,7 @@ Fijada: sobrevive al relog. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Nota: Si XIV Messenger u otro plugin similar suprime los tells, desactiva la opción "Suppress DMs" allí para que Hellion Chat pueda recibir tells y abrir las pestañas automáticas. - + Historial de tells en pestañas automáticas @@ -559,7 +559,7 @@ Solo tiene efecto cuando las pestañas de tell automáticas están activadas en la pestaña Chat. - + Ajustes reestructurados @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 ha reestructurado los ajustes en pestañas temáticas. Tu base de datos de chat y tu historial de mensajes no han cambiado. Los ajustes se han restablecido a los valores predeterminados. Si quieres volver a seleccionar tu perfil de privacidad, el botón Reabrir está en la pestaña Privacidad. Una copia de seguridad de la configuración anterior se encuentra en HellionChat.json.pre-v10-backup junto al archivo de configuración activo. - + General @@ -590,9 +590,9 @@ Acerca de - + - + Theme @@ -606,14 +606,14 @@ Marcas de tiempo - + Marco de ventana - + - + Mostrar botón de selector de símbolos junto a la entrada del chat @@ -621,7 +621,7 @@ Añade un pequeño botón a la izquierda del indicador de canal que abre un desplegable con iconos de FFXIV y una lista de símbolos curada. Desactívalo si prefieres una barra de entrada más sencilla. - + Almacenamiento @@ -632,9 +632,9 @@ Mantenimiento - + - + Sistema @@ -654,7 +654,7 @@ Si usas varios linkshells, el mantenedor recomienda una pestaña por linkshell para una vista más clara. Duplica la pestaña y restringe la selección de canales en cada copia. - + Icono de pestaña @@ -956,7 +956,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Entrada @@ -1155,4 +1155,6 @@ Ocultar el chat (Intro para recuperarlo) - +Devolver esta pestaña a la ventana principal + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index 2ab10f9..cb9aeb9 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Chat 2:n yhteisökääntäjät (upstream) - + Aktiiviset tellit @@ -504,7 +504,7 @@ Kiinnitetty: selviää relogista. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Huomio: Jos XIV Messenger tai vastaava lisäosa estää tellit, poista siellä käytöstä "Suppress DMs" -asetus, jotta Hellion Chat voi vastaanottaa tellejä ja avata auto-välilehdet. - + Tell-historia auto-välilehdissä @@ -559,7 +559,7 @@ Tulee voimaan vain, kun auto-tell-välilehdet ovat käytössä Chat-välilehdellä. - + Asetukset uudelleenjärjestetty @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 on uudelleenjärjestänyt asetukset temaattisiin välilehtiin. Chat-tietokantasi ja viestihistoriasi pysyvät muuttumattomina. Asetukset on nollattu oletuksiin. Jos haluat valita tietosuojaprofiilin uudelleen, Avaa uudelleen -painike on Tietosuoja-välilehdellä. Varmuuskopio aiemmasta asetustiedostosta löytyy nimellä HellionChat.json.pre-v10-backup aktiivisen asetustiedoston vierestä. - + Yleiset @@ -590,9 +590,9 @@ Tietoja - + - + Teema @@ -606,14 +606,14 @@ Aikaleimat - + Ikkunakehys - + - + Näytä symbolinvalitsinpainike chat-syötteen vieressä @@ -621,7 +621,7 @@ Lisää pienen painikkeen kanavan ilmaisimen vasemmalle puolelle, joka avaa ponnahdusikkunan FFXIV-kuvakkeilla ja kuratoitulla symbolilistalla. Poista käytöstä, jos haluat siistimmän syöterivin. - + Tallennus @@ -632,9 +632,9 @@ Ylläpito - + - + Järjestelmä @@ -654,7 +654,7 @@ Jos käytät useita linkshell-ryhmiä, ylläpitäjä suosittelee yhtä välilehteä kullekin, jotta yleiskatsaus pysyy selkeänä. Kopioi välilehti ja rajaa kanavan valinta kussakin kopiossa. - + Välilehden kuvake @@ -955,7 +955,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Syöte @@ -1154,4 +1154,6 @@ Piilota chat (Enter palauttaa sen) - +Palauta tämä välilehti pääikkunaan + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index b80db73..76947ba 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Traducteurs communautaires de Chat 2 (en amont) - + Tells actifs @@ -504,7 +504,7 @@ Épinglé : survit à la reconnexion. - + Onglets de tell automatiques @@ -545,7 +545,7 @@ Remarque : si XIV Messenger ou un plugin similaire supprime les tells, désactivez l'option « Suppress DMs » à cet endroit afin que Hellion Chat puisse recevoir les tells et ouvrir les onglets automatiques. - + Historique des tells dans les onglets automatiques @@ -559,7 +559,7 @@ N'a d'effet que lorsque les onglets de tell automatiques sont activés dans l'onglet Chat. - + Paramètres restructurés @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 a restructuré les paramètres en onglets thématiques. Votre base de données de chat et votre historique de messages restent inchangés. Les paramètres ont été réinitialisés à leurs valeurs par défaut. Si vous souhaitez resélectionner votre profil de confidentialité, le bouton Rouvrir se trouve dans l'onglet Confidentialité. Une sauvegarde de la configuration précédente est disponible dans HellionChat.json.pre-v10-backup, à côté du fichier de configuration actif. - + Général @@ -590,9 +590,9 @@ À propos - + - + Thème @@ -606,14 +606,14 @@ Horodatages - + Cadre de la fenêtre - + - + Afficher le bouton de sélection de symboles à côté de la saisie @@ -621,7 +621,7 @@ Ajoute un petit bouton à gauche de l'indicateur de canal qui ouvre une fenêtre contextuelle contenant des icônes FFXIV et une liste de symboles soigneusement sélectionnés. Désactivez si vous préférez une barre de saisie plus épurée. - + Stockage @@ -632,9 +632,9 @@ Maintenance - + - + Système @@ -654,7 +654,7 @@ Si vous utilisez plusieurs linkshells, le mainteneur recommande un onglet par linkshell pour une meilleure vue d'ensemble. Dupliquez l'onglet et restreignez la sélection de canaux dans chaque copie. - + Icône d'onglet @@ -956,7 +956,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Saisie @@ -1155,4 +1155,6 @@ Masquer le chat (Entrée pour le rouvrir) - +Renvoyer cet onglet vers la fenêtre principale + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index e0a6b28..e333950 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Chat 2 közösségi fordítók (upstream) - + Aktív tellek @@ -504,7 +504,7 @@ Rögzített: túléli az újrabejelentkezést. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Megjegyzés: Ha az XIV Messenger vagy egy hasonló plugin elnyomja a telleket, kapcsold ki ott a „Suppress DMs" opciót, hogy a Hellion Chat fogadhassa a telleket és megnyithassa az auto-füleket. - + Tell-előzmények az auto-fülekben @@ -559,7 +559,7 @@ Csak akkor lép életbe, ha az auto-tell-fülek be vannak kapcsolva a Chat fülön. - + Beállítások átstrukturálva @@ -567,7 +567,7 @@ A Hellion Chat 0.5.0 tematikus fülekbe rendezte a beállításokat. A chat-adatbázisod és az üzenettörténeted változatlan maradt. A beállítások visszaálltak az alapértelmezettekre. Ha újra ki szeretnéd választani az adatvédelmi profilodat, a Varázsló újra megnyitása gomb az Adatvédelem fülön található. Az előző konfiguráció biztonsági másolata HellionChat.json.pre-v10-backup néven található az aktív konfigurációs fájl mellett. - + Általános @@ -590,9 +590,9 @@ Névjegy - + - + Theme @@ -606,14 +606,14 @@ Időbélyegek - + Ablakkeret - + - + Szimbólumválasztó gomb megjelenítése a chat-beviteli mező mellett @@ -621,7 +621,7 @@ Egy kis gombot helyez a csatornajelző bal oldalára, amely egy FFXIV-ikonokat és válogatott szimbólumokat tartalmazó felugró ablakot nyit meg. Kapcsold ki, ha karcsúbb beviteli sort szeretnél. - + Tárolás @@ -632,9 +632,9 @@ Karbantartás - + - + Rendszer @@ -654,7 +654,7 @@ Ha több linkshellt használsz, a karbantartó azt javasolja, hogy minden shellnek legyen külön füle az áttekinthetőség érdekében. Másold le a fület, és minden másolatban szűkítsd a csatornaválasztást. - + Fül ikonja @@ -955,7 +955,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Bevitel @@ -1154,4 +1154,6 @@ Csevegés elrejtése (Enter visszahozza) - +Lap visszahelyezése a főablakba + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index e526a6a..9d685db 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Traduttori della community di Chat 2 (upstream) - + Tell attivi @@ -504,7 +504,7 @@ Fisso: sopravvive al relog. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Nota: se XIV Messenger o un plugin simile sopprime i tell, disattiva lì l'opzione "Suppress DMs" in modo che Hellion Chat possa ricevere i tell e aprire i tab automatici. - + Cronologia tell nei tab automatici @@ -559,7 +559,7 @@ Ha effetto solo quando i tab tell automatici sono attivati nel tab Chat. - + Impostazioni ristrutturate @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 ha ristrutturato le impostazioni in tab tematici. Il database della chat e la cronologia dei messaggi rimangono invariati. Le impostazioni sono state ripristinate ai valori predefiniti. Se vuoi riselezionare il profilo privacy, il pulsante Riapri si trova nel tab Privacy. Un backup della configurazione precedente si trova in HellionChat.json.pre-v10-backup accanto al file di configurazione attivo. - + Generale @@ -590,9 +590,9 @@ Info - + - + Theme @@ -606,14 +606,14 @@ Timestamp - + Cornice finestra - + - + Mostra pulsante selettore simboli accanto all'input della chat @@ -621,7 +621,7 @@ Aggiunge un piccolo pulsante a sinistra dell'indicatore del canale che apre un popup con le icone FFXIV e un elenco di simboli selezionati. Disattiva se preferisci una barra di input più essenziale. - + Archiviazione @@ -632,9 +632,9 @@ Manutenzione - + - + Sistema @@ -654,7 +654,7 @@ Se usi più linkshell, il maintainer consiglia un tab per shell per una panoramica più ordinata. Duplica il tab e limita la selezione dei canali in ogni copia. - + Icona tab @@ -956,7 +956,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Inserimento @@ -1155,4 +1155,6 @@ Nascondi la chat (Invio per riaprirla) - +Riporta questa scheda nella finestra principale + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index 5b44b9b..d5c382e 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Chat 2 コミュニティ翻訳者(アップストリーム) - + アクティブなテル @@ -504,7 +504,7 @@ ピン留め済み: 再ログイン後も残ります。 - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ 注意: XIV Messenger や類似のプラグインがテルを抑制している場合、そちらの「Suppress DMs」オプションを無効にしてください。無効にすることで Hellion Chat がテルを受信し、自動タブを開けるようになります。 - + 自動タブのテル履歴 @@ -559,7 +559,7 @@ チャットタブで自動テルタブが有効になっている場合にのみ適用されます。 - + 設定が再構成されました @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 で設定がテーマ別タブに再構成されました。チャットデータベースとメッセージ履歴はそのまま保持されます。設定はデフォルト値にリセットされました。プライバシープロファイルを再選択したい場合は、プライバシータブの「再表示」ボタンをご利用ください。以前の設定のバックアップは、アクティブな設定ファイルの隣に HellionChat.json.pre-v10-backup として保存されています。 - + 一般 @@ -590,9 +590,9 @@ 情報 - + - + テーマ @@ -606,14 +606,14 @@ タイムスタンプ - + ウィンドウフレーム - + - + チャット入力欄の隣にシンボルピッカーボタンを表示する @@ -621,7 +621,7 @@ チャンネルインジケーターの左に小さなボタンを追加します。クリックすると FFXIV アイコンとシンボル一覧のポップアップが開きます。入力バーをシンプルにしたい場合は無効にしてください。 - + ストレージ @@ -632,9 +632,9 @@ メンテナンス - + - + システム @@ -654,7 +654,7 @@ 複数のリンクシェルを使用している場合、メンテナーはより整理された見通しのために各シェルに専用タブを作成することを推奨しています。タブを複製して各コピーのチャンネル選択を絞り込んでください。 - + タブアイコン @@ -956,7 +956,7 @@ AI-assisted machine translation. Pending native-speaker review. - + 入力 @@ -1155,4 +1155,6 @@ チャットを非表示(Enterで再表示) - +このタブをメインウィンドウに戻す + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 34150ed..136321f 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Chat 2 커뮤니티 번역자 (업스트림) - + 활성 귓속말 @@ -504,7 +504,7 @@ 고정됨: 재접속 후에도 유지됩니다. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ 참고: XIV Messenger 또는 유사한 플러그인이 귓속말을 차단하는 경우 해당 플러그인의 "Suppress DMs" 옵션을 비활성화해야 Hellion Chat이 귓속말을 받고 자동 탭을 열 수 있습니다. - + 자동 탭의 귓속말 기록 @@ -559,7 +559,7 @@ 채팅 탭에서 자동 귓속말 탭이 활성화된 경우에만 적용됩니다. - + 설정이 재구성되었습니다 @@ -567,7 +567,7 @@ Hellion Chat 0.5.0에서 설정이 주제별 탭으로 재구성되었습니다. 채팅 데이터베이스와 메시지 기록은 변경되지 않았습니다. 설정이 기본값으로 초기화되었습니다. 개인정보 프로필을 다시 선택하려면 개인정보 탭의 다시 열기 버튼을 사용하세요. 이전 설정의 백업이 활성 설정 파일 옆에 HellionChat.json.pre-v10-backup으로 저장되어 있습니다. - + 일반 @@ -590,9 +590,9 @@ 정보 - + - + 테마 @@ -606,14 +606,14 @@ 타임스탬프 - + 창 프레임 - + - + 채팅 입력창 옆에 기호 선택기 버튼 표시 @@ -621,7 +621,7 @@ 채널 표시기 왼쪽에 작은 버튼을 추가합니다. 클릭하면 FFXIV 아이콘과 엄선된 기호 목록이 있는 팝업이 열립니다. 입력창을 간결하게 유지하려면 비활성화하세요. - + 저장소 @@ -632,9 +632,9 @@ 유지 관리 - + - + 시스템 @@ -654,7 +654,7 @@ 링크셸을 여러 개 사용한다면 관리자는 각 셸마다 탭 하나를 사용하여 더 깔끔한 개요를 유지할 것을 권장합니다. 탭을 복제하고 각 복사본에서 채널 선택을 제한하세요. - + 탭 아이콘 @@ -956,7 +956,7 @@ AI-assisted machine translation. Pending native-speaker review. - + 입력 @@ -1155,4 +1155,6 @@ 채팅 숨기기 (Enter로 다시 표시) - +이 탭을 기본 창으로 되돌리기 + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index da3d432..4f8d7df 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Chat 2 fellesskapsoversetterne (oppstrøms) - + Aktive tells @@ -504,7 +504,7 @@ Festet: overlever relog. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Merk: Hvis XIV Messenger eller et lignende plugin undertrykker tells, deaktiver "Suppress DMs"-alternativet der slik at Hellion Chat kan motta tells og åpne auto-fanene. - + Tell-historikk i auto-faner @@ -559,7 +559,7 @@ Gjelder bare når auto-tell-faner er aktivert i Chat-fanen. - + Innstillinger omstrukturert @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 har omstrukturert innstillingene i tematiske faner. Chat-databasen og meldingshistorikken din er uendret. Innstillinger har blitt tilbakestilt til standardverdier. Hvis du vil velge personvernprofil på nytt, finner du Åpne igjen-knappen i Personvern-fanen. En sikkerhetskopi av forrige konfig ligger på HellionChat.json.pre-v10-backup ved siden av den aktive konfig-filen. - + Generelt @@ -590,9 +590,9 @@ Om - + - + Theme @@ -606,14 +606,14 @@ Tidsstempler - + Vindusramme - + - + Vis symbolvelger-knapp ved siden av chat-inndata @@ -621,7 +621,7 @@ Legger til en liten knapp til venstre for kanalindikatoren som åpner et popup-vindu med FFXIV-ikoner og en kuratert symbolliste. Deaktiver for en slankere inndatalinje. - + Lagring @@ -632,9 +632,9 @@ Vedlikehold - + - + System @@ -654,7 +654,7 @@ Hvis du bruker flere linkshells, anbefaler vedlikeholderen én fane per shell for en ryddigere oversikt. Dupliser fanen og begrens kanalvalget i hver kopi. - + Fane-ikon @@ -955,7 +955,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Input @@ -1154,4 +1154,6 @@ Skjul chatten (Enter henter den tilbake) - +Send denne fanen tilbake til hovedvinduet + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 2ff3a87..cc90d4e 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Chat 2 community-vertalers (upstream) - + Actieve tells @@ -504,7 +504,7 @@ Vastgepind: overleeft relog. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Let op: Als XIV Messenger of een vergelijkbare plugin tells onderdrukt, schakel dan de optie "Suppress DMs" daar uit zodat Hellion Chat tells kan ontvangen en de auto-tabbladen kan openen. - + Tell-geschiedenis in auto-tabbladen @@ -559,7 +559,7 @@ Werkt alleen als auto-tell-tabbladen zijn ingeschakeld in het Chat-tabblad. - + Instellingen geherstructureerd @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 heeft de instellingen geherstructureerd in thematische tabbladen. Je chatdatabase en berichtengeschiedenis blijven ongewijzigd. Instellingen zijn teruggezet naar de standaardwaarden. Als je je privacyprofiel opnieuw wilt kiezen, staat de knop Wizard opnieuw tonen in het tabblad Privacy. Een back-up van de vorige configuratie staat bij HellionChat.json.pre-v10-backup naast het actieve configuratiebestand. - + Algemeen @@ -590,9 +590,9 @@ Over - + - + Theme @@ -606,14 +606,14 @@ Tijdstempels - + Vensterkader - + - + Symboolkiezer naast de chatinvoer tonen @@ -621,7 +621,7 @@ Voegt een kleine knop links van de kanaalkiezer toe die een popup opent met FFXIV-pictogrammen en een samengestelde symbolenlijst. Schakel uit als je een slankere invoerbalk verkiest. - + Opslag @@ -632,9 +632,9 @@ Onderhoud - + - + Systeem @@ -654,7 +654,7 @@ Als je meerdere linkshells gebruikt, raadt de maintainer aan om één tabblad per shell te gebruiken voor een overzichtelijker geheel. Dupliceer het tabblad en beperk de kanaalselectie in elke kopie. - + Tabblad-pictogram @@ -956,7 +956,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Invoer @@ -1155,4 +1155,6 @@ Chat verbergen (Enter haalt hem terug) - +Dit tabblad terugzetten in het hoofdvenster + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index 6470bb7..b4a318d 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Tłumacze społeczności Chat 2 (upstream) - + Aktywne tells @@ -504,7 +504,7 @@ Przypięta: przeżywa relog. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Uwaga: Jeśli XIV Messenger lub podobny plugin blokuje tells, wyłącz tam opcję „Suppress DMs", aby Hellion Chat mógł odbierać tells i otwierać auto-zakładki. - + Historia tell w auto-zakładkach @@ -559,7 +559,7 @@ Działa tylko wtedy, gdy auto-zakładki tell są włączone w zakładce Chat. - + Ustawienia przeorganizowane @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 przeorganizował ustawienia w tematyczne zakładki. Twoja baza danych czatu i historia wiadomości pozostają bez zmian. Ustawienia zostały zresetowane do domyślnych. Jeśli chcesz ponownie wybrać profil prywatności, przycisk Otwórz ponownie znajduje się w zakładce Prywatność. Kopia zapasowa poprzedniej konfiguracji znajduje się w pliku HellionChat.json.pre-v10-backup obok aktywnego pliku konfiguracji. - + Ogólne @@ -590,9 +590,9 @@ O pluginie - + - + Theme @@ -606,14 +606,14 @@ Znaczniki czasu - + Ramka okna - + - + Pokaż przycisk wyboru symboli obok pola wprowadzania czatu @@ -621,7 +621,7 @@ Dodaje mały przycisk po lewej stronie wskaźnika kanału, który otwiera popup z ikonami FFXIV i listą wybranych symboli. Wyłącz, jeśli wolisz bardziej minimalistyczny pasek wprowadzania. - + Przechowywanie @@ -632,9 +632,9 @@ Konserwacja - + - + System @@ -654,7 +654,7 @@ Jeśli używasz wielu linkshells, maintainer zaleca po jednej zakładce na każdą dla lepszego przeglądu. Zduplikuj zakładkę i ogranicz wybór kanałów w każdej kopii. - + Ikona zakładki @@ -955,7 +955,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Wejście @@ -1154,4 +1154,6 @@ Ukryj czat (Enter go przywraca) - +Przywróć tę kartę do okna głównego + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index db32670..be06fb1 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Tradutores da comunidade do Chat 2 (upstream) - + Tells ativos @@ -504,7 +504,7 @@ Fixado: sobrevive ao relog. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Atenção: se o XIV Messenger ou um plugin similar suprimir tells, desative a opção "Suppress DMs" nele para que o Hellion Chat possa receber tells e abrir as abas automáticas. - + Histórico de tell nas abas automáticas @@ -559,7 +559,7 @@ Só tem efeito quando as abas de tell automáticas estão ativadas na aba Chat. - + Configurações reestruturadas @@ -567,7 +567,7 @@ O Hellion Chat 0.5.0 reestruturou as configurações em abas temáticas. Seu banco de dados de chat e histórico de mensagens permanecem inalterados. As configurações foram redefinidas para os padrões. Se quiser selecionar seu perfil de privacidade novamente, o botão Reabrir está na aba Privacidade. Um backup da configuração anterior está em HellionChat.json.pre-v10-backup ao lado do arquivo de configuração ativo. - + Geral @@ -590,9 +590,9 @@ Sobre - + - + Theme @@ -606,14 +606,14 @@ Timestamps - + Moldura da janela - + - + Mostrar botão de seleção de símbolos ao lado da entrada de chat @@ -621,7 +621,7 @@ Adiciona um pequeno botão à esquerda do indicador de canal que abre um popup com ícones do FFXIV e uma lista de símbolos selecionados. Desative se preferir uma barra de entrada mais simples. - + Armazenamento @@ -632,9 +632,9 @@ Manutenção - + - + Sistema @@ -654,7 +654,7 @@ Se você usa várias linkshells, o mantenedor recomenda uma aba por shell para uma visão mais organizada. Duplique a aba e restrinja a seleção de canais em cada cópia. - + Ícone da aba @@ -956,7 +956,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Entrada @@ -1155,4 +1155,6 @@ Ocultar o chat (Enter para trazer de volta) - +Devolver esta aba à janela principal + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index a5fa9d5..2fb94f0 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Tradutores da comunidade do Chat 2 (upstream) - + Tells ativos @@ -504,7 +504,7 @@ Fixado: sobrevive ao relog. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Nota: Se o XIV Messenger ou um plugin semelhante suprimir tells, desativa a opção "Suppress DMs" lá para que o Hellion Chat possa receber tells e abrir os separadores automáticos. - + Histórico de tells em separadores automáticos @@ -559,7 +559,7 @@ Só tem efeito quando os separadores de tell automáticos estão ativados no separador Chat. - + Definições reestruturadas @@ -567,7 +567,7 @@ O Hellion Chat 0.5.0 reestruturou as definições em separadores temáticos. A tua base de dados de chat e o histórico de mensagens permanecem inalterados. As definições foram repostas para os valores predefinidos. Se quiseres voltar a selecionar o teu perfil de privacidade, o botão Reabrir está no separador Privacidade. Uma cópia de segurança da configuração anterior encontra-se em HellionChat.json.pre-v10-backup junto ao ficheiro de configuração ativo. - + Geral @@ -590,9 +590,9 @@ Sobre - + - + Theme @@ -606,14 +606,14 @@ Marcas de tempo - + Moldura da janela - + - + Mostrar botão do seletor de símbolos junto à entrada de chat @@ -621,7 +621,7 @@ Adiciona um pequeno botão à esquerda do indicador de canal que abre uma janela pop-up com ícones de FFXIV e uma lista curada de símbolos. Desativa se preferires uma barra de entrada mais minimalista. - + Armazenamento @@ -632,9 +632,9 @@ Manutenção - + - + Sistema @@ -654,7 +654,7 @@ Se usares várias linkshells, o maintainer recomenda um separador por shell para uma visão geral mais limpa. Duplica o separador e restringe a seleção de canais em cada cópia. - + Ícone do separador @@ -955,7 +955,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Entrada @@ -1154,4 +1154,6 @@ Ocultar o chat (Enter para o repor) - +Devolver este separador à janela principal + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index c6a3dd0..3346cf6 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Chat 2 community translators (upstream) - + Active tells @@ -504,7 +504,7 @@ Pinned: survives relog. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Note: If XIV Messenger or a similar plugin suppresses tells, disable the "Suppress DMs" option there so that Hellion Chat can receive tells and open the auto-tabs. - + Tell history in auto-tabs @@ -559,7 +559,7 @@ Only takes effect when auto-tell tabs are enabled in the Chat tab. - + Settings restructured @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 has restructured the settings into thematic tabs. Your chat database and message history remain unchanged. Settings have been reset to defaults. If you want to re-select your privacy profile, the Reopen button is in the Privacy tab. A backup of the previous config is located at HellionChat.json.pre-v10-backup next to the active config file. - + General @@ -590,7 +590,7 @@ About - + Theme @@ -604,11 +604,11 @@ Timestamps - + Window frame - + Show symbol-picker button next to chat input @@ -616,7 +616,7 @@ Adds a small button left of the channel indicator that opens a popup with FFXIV icons and a curated symbol list. Disable if you prefer a leaner input bar. - + Storage @@ -627,7 +627,7 @@ Maintenance - + System @@ -647,7 +647,7 @@ If you use multiple linkshells, the maintainer recommends one tab per shell for a cleaner overview. Duplicate the tab and restrict the channel selection in each copy. - + Tab icon @@ -961,7 +961,7 @@ Disables the theme crossfade, the sidebar and card-row hover animations, and the unread-tab pulse. Theme switches and hover states apply instantly instead. - + A tell could not be delivered. @@ -975,7 +975,7 @@ Show a toast when a tell you sent could not be delivered (recipient offline, in an instance, or blocking you). - + Notification sound @@ -992,7 +992,7 @@ Hellion sound - + Jump to the latest message @@ -1003,7 +1003,7 @@ Insert linked item <item> - + Warn before sending plugin-only symbols @@ -1014,7 +1014,7 @@ This message contains plugin-only symbols that other players may see as empty boxes. Press Enter again to send anyway. - + World suffix @@ -1046,7 +1046,7 @@ Initials - + Inactive window opacity @@ -1054,7 +1054,7 @@ Background opacity of the main chat window while it is not focused. The slider above sets the focused value. A per-window override in Dalamud's window pinning menu still takes precedence over both. - + Custom sound volume @@ -1062,7 +1062,7 @@ Playback volume for the three bundled custom notification sounds. Does not affect the 16 game sounds. - + Input @@ -1079,7 +1079,7 @@ Which sound plays per tab is set in the Channels tab. - + Messages @@ -1099,7 +1099,7 @@ Novice network - + Theme @@ -1119,7 +1119,7 @@ Animations - + Hide @@ -1130,7 +1130,7 @@ Frame - + Channels @@ -1150,7 +1150,7 @@ This volume applies to all tabs. - + Extensions @@ -1175,4 +1175,6 @@ Hide chat (Enter to bring back) - +Return this tab to the main window + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index 2dbcf08..dbf9ea2 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Traducători comunitate Chat 2 (upstream) - + Tell-uri active @@ -504,7 +504,7 @@ Fixat: supraviețuiește relog-ului. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Notă: Dacă XIV Messenger sau un plugin similar suprimă tell-urile, dezactivează opțiunea „Suppress DMs" de acolo pentru ca Hellion Chat să poată primi tell-uri și să deschidă tab-urile automate. - + Istoric tell în tab-uri automate @@ -559,7 +559,7 @@ Intră în vigoare doar când tab-urile auto-tell sunt activate în tab-ul Chat. - + Setări restructurate @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 a restructurat setările în tab-uri tematice. Baza ta de date de chat și istoricul mesajelor rămân nemodificate. Setările au fost resetate la valorile implicite. Dacă vrei să reselecți profilul de confidențialitate, butonul Redeschide se află în tab-ul Confidențialitate. O copie de rezervă a configurației anterioare se află la HellionChat.json.pre-v10-backup lângă fișierul de configurare activ. - + General @@ -590,9 +590,9 @@ Despre - + - + Temă @@ -606,14 +606,14 @@ Marcaje de timp - + Cadru fereastră - + - + Arată butonul selector de simboluri lângă câmpul de chat @@ -621,7 +621,7 @@ Adaugă un buton mic în stânga indicatorului de canal care deschide un popup cu icoane FFXIV și o listă de simboluri curată. Dezactivează dacă preferi o bară de introducere mai simplă. - + Stocare @@ -632,9 +632,9 @@ Întreținere - + - + System @@ -654,7 +654,7 @@ Dacă folosești mai multe linkshell-uri, maintainer-ul recomandă un tab per shell pentru o prezentare mai clară. Duplică tab-ul și restrânge selecția de canal în fiecare copie. - + Icoană tab @@ -956,7 +956,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Introducere @@ -1155,4 +1155,6 @@ Ascunde chatul (Enter îl readuce) - +Readu această filă în fereastra principală + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index 2e4dc70..4519c3e 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Переводчики сообщества Chat 2 (upstream) - + Активные ЛС @@ -504,7 +504,7 @@ Закреплено — переживает релог. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Примечание: если XIV Messenger или похожий плагин блокирует ЛС, отключите там опцию «Suppress DMs», чтобы Hellion Chat мог получать ЛС и открывать авто-вкладки. - + История ЛС в авто-вкладках @@ -559,7 +559,7 @@ Действует только если авто-вкладки ЛС включены на вкладке Чат. - + Настройки реструктурированы @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 реструктурировал настройки по тематическим вкладкам. Ваша база данных чата и история сообщений остались без изменений. Настройки сброшены к значениям по умолчанию. Если вы хотите повторно выбрать профиль конфиденциальности, кнопка «Открыть снова» находится на вкладке Конфиденциальность. Резервная копия предыдущей конфигурации расположена по адресу HellionChat.json.pre-v10-backup рядом с активным файлом конфигурации. - + Общие @@ -590,9 +590,9 @@ О программе - + - + Theme @@ -606,14 +606,14 @@ Временны́е метки - + Рамка окна - + - + Показывать кнопку выбора символов рядом с полем ввода чата @@ -621,7 +621,7 @@ Добавляет небольшую кнопку слева от индикатора канала, открывающую всплывающее окно с иконками FFXIV и подобранным списком символов. Отключите для более компактной строки ввода. - + Хранение @@ -632,9 +632,9 @@ Обслуживание - + - + Система @@ -654,7 +654,7 @@ Если вы используете несколько Linkshell, разработчик рекомендует создать отдельную вкладку для каждой — для более удобного обзора. Продублируйте вкладку и ограничьте выбор каналов в каждой копии. - + Иконка вкладки @@ -956,7 +956,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Ввод @@ -1155,4 +1155,6 @@ Скрыть чат (Enter вернёт его) - +Вернуть эту вкладку в главное окно + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index ccc9f9e..c2fffbc 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Chat 2 community-översättare (upstream) - + Aktiva tells @@ -504,7 +504,7 @@ Fäst: överlever omloggning. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Notering: Om XIV Messenger eller ett liknande plugin undertrycker tells, inaktivera alternativet "Suppress DMs" där så att Hellion Chat kan ta emot tells och öppna auto-flikar. - + Tell-historik i auto-flikar @@ -559,7 +559,7 @@ Träder bara i kraft när auto-tell-flikar är aktiverade i fliken Chatt. - + Inställningar omstrukturerade @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 har omstrukturerat inställningarna i tematiska flikar. Din chattdatabas och meddelandehistorik är oförändrade. Inställningarna har återställts till standardvärden. Om du vill välja om din sekretesprofil finns Återöppna-knappen i fliken Sekretess. En säkerhetskopia av den tidigare konfigurationen finns vid HellionChat.json.pre-v10-backup bredvid den aktiva konfigurationsfilen. - + Allmänt @@ -590,9 +590,9 @@ Om - + - + Theme @@ -606,14 +606,14 @@ Tidsstämplar - + Fönsterram - + - + Visa symbolväljarknapp bredvid chattinmatningen @@ -621,7 +621,7 @@ Lägger till en liten knapp till vänster om kanalindikatorerna som öppnar ett popup-fönster med FFXIV-ikoner och en utvald symbollista. Inaktivera om du föredrar ett smalare inmatningsfält. - + Lagring @@ -632,9 +632,9 @@ Underhåll - + - + System @@ -654,7 +654,7 @@ Om du använder flera linkshells rekommenderar underhållaren en flik per shell för en tydligare översikt. Duplicera fliken och begränsa kanalvalet i varje kopia. - + Flikikon @@ -956,7 +956,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Inmatning @@ -1155,4 +1155,6 @@ Dölj chatten (Enter tar tillbaka den) - +Återför den här fliken till huvudfönstret + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index 52cdb22..fff6706 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Chat 2 topluluk çevirmenleri (upstream) - + Aktif tell'ler @@ -504,7 +504,7 @@ Sabitlenmiş: yeniden girişten sonra hayatta kalır. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Not: XIV Messenger veya benzer bir eklenti tell'leri engelliyorsa, Hellion Chat'in tell'leri alıp otomatik sekmeleri açabilmesi için oradaki "Suppress DMs" seçeneğini devre dışı bırak. - + Otomatik sekmelerdeki tell geçmişi @@ -559,7 +559,7 @@ Yalnızca Sohbet sekmesinde otomatik tell sekmeleri etkinleştirildiğinde geçerli olur. - + Ayarlar yeniden yapılandırıldı @@ -567,7 +567,7 @@ Hellion Chat 0.5.0, ayarları tematik sekmelere göre yeniden yapılandırdı. Sohbet veritabanın ve mesaj geçmişin değişmedi. Ayarlar varsayılanlara sıfırlandı. Gizlilik profilini yeniden seçmek istiyorsan, Yeniden Aç düğmesi Gizlilik sekmesindedir. Önceki yapılandırmanın yedeği, aktif yapılandırma dosyasının yanında HellionChat.json.pre-v10-backup olarak bulunmaktadır. - + Genel @@ -590,9 +590,9 @@ Hakkında - + - + Theme @@ -606,14 +606,14 @@ Zaman damgaları - + Pencere çerçevesi - + - + Sohbet girişinin yanında sembol seçici düğmesini göster @@ -621,7 +621,7 @@ Kanal göstergesinin soluna, FFXIV ikonları ve özenle seçilmiş bir sembol listesiyle bir açılır pencere açan küçük bir düğme ekler. Daha sade bir giriş çubuğu tercih ediyorsan devre dışı bırak. - + Depolama @@ -632,9 +632,9 @@ Bakım - + - + Sistem @@ -654,7 +654,7 @@ Birden fazla linkshell kullanıyorsan, geliştirici daha temiz bir genel bakış için her kabuk için bir sekme kullanmanı önerir. Sekmeyi çoğalt ve her kopyada kanal seçimini kısıtla. - + Sekme ikonu @@ -955,7 +955,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Giriş @@ -1154,4 +1154,6 @@ Sohbeti gizle (Geri getirmek için Enter) - +Bu sekmeyi ana pencereye geri al + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 87379fc..d4e3acf 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Перекладачі спільноти Chat 2 (upstream) - + Активні tells @@ -504,7 +504,7 @@ Закріплено — виживає після релогу. - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ Примітка: якщо XIV Messenger або подібний плагін пригнічує tells, вимкніть там опцію «Suppress DMs», щоб Hellion Chat міг отримувати tells і відкривати авто-вкладки. - + Історія tells в авто-вкладках @@ -559,7 +559,7 @@ Діє лише тоді, коли авто-tell-вкладки увімкнено на вкладці «Чат». - + Налаштування реструктуровано @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 реструктурував налаштування на тематичні вкладки. База даних чату та історія повідомлень залишились без змін. Налаштування скинуто до стандартних. Якщо Ви хочете повторно вибрати профіль конфіденційності, кнопка «Показати знову» знаходиться на вкладці «Конфіденційність». Резервна копія попередньої конфігурації знаходиться за адресою HellionChat.json.pre-v10-backup поруч із активним конфігураційним файлом. - + Загальні @@ -590,9 +590,9 @@ Про плагін - + - + Тема @@ -606,14 +606,14 @@ Мітки часу - + Рамка вікна - + - + Показувати кнопку вибору символів поруч із полем введення чату @@ -621,7 +621,7 @@ Додає невелику кнопку ліворуч від індикатора каналу, що відкриває спливаюче вікно з іконками FFXIV і підібраним списком символів. Вимкніть, якщо надаєте перевагу більш компактній панелі введення. - + Зберігання @@ -632,9 +632,9 @@ Обслуговування - + - + Система @@ -654,7 +654,7 @@ Якщо Ви використовуєте кілька linkshells, розробник рекомендує по одній вкладці на кожну для зручнішого огляду. Продублюйте вкладку й обмежте вибір каналів у кожній копії. - + Іконка вкладки @@ -955,7 +955,7 @@ AI-assisted machine translation. Pending native-speaker review. - + Введення @@ -1154,4 +1154,6 @@ Сховати чат (Enter поверне його) - +Повернути цю вкладку до головного вікна + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index 44b8bad..fddbc58 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Chat 2 社区翻译者(上游) - + 活跃密语 @@ -504,7 +504,7 @@ 已固定,重新登录后仍保留。 - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ 注意:如果 XIV Messenger 或类似插件屏蔽了密语,请在该插件中禁用"Suppress DMs"选项,以便 Hellion Chat 能正常接收密语并打开自动标签页。 - + 自动标签页中的密语历史 @@ -559,7 +559,7 @@ 仅在聊天标签页中启用了自动密语标签页时生效。 - + 设置已重新整理 @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 已将设置重新整理为分主题的标签页。你的聊天数据库和消息历史保持不变。设置已重置为默认值。如需重新选择隐私方案,可在隐私标签页中找到"再次显示向导"按钮。上一版配置的备份文件位于活动配置文件旁边,文件名为 HellionChat.json.pre-v10-backup。 - + 通用 @@ -590,9 +590,9 @@ 关于 - + - + 主题 @@ -606,14 +606,14 @@ 时间戳 - + 窗口边框 - + - + 在聊天输入框旁显示符号选取按钮 @@ -621,7 +621,7 @@ 在频道指示器左侧添加一个小按钮,点击可打开包含 FFXIV 图标和精选符号列表的弹出窗口。如果你希望输入栏更简洁,可以禁用此功能。 - + 存储 @@ -632,9 +632,9 @@ 维护 - + - + 系统 @@ -654,7 +654,7 @@ 如果你使用多个通讯贝,维护者建议为每个通讯贝单独创建一个标签页,以获得更清晰的概览。复制该标签页,并在每个副本中分别限定频道选择即可。 - + 标签页图标 @@ -956,7 +956,7 @@ AI-assisted machine translation. Pending native-speaker review. - + 输入 @@ -1155,4 +1155,6 @@ 隐藏聊天(按 Enter 恢复) - +将此标签页放回主窗口 + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index fa8c758..87f88c3 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -457,7 +457,7 @@ Chat 2 社群譯者(上游) - + 進行中的悄悄話 @@ -504,7 +504,7 @@ 已釘選,重新登入後仍會保留。 - + Auto-Tell-Tabs @@ -545,7 +545,7 @@ 注意:若 XIV Messenger 或類似插件抑制了悄悄話,請在該插件中停用「Suppress DMs」選項,使 Hellion Chat 能夠接收悄悄話並開啟自動標籤頁。 - + 自動標籤頁中的悄悄話歷史 @@ -559,7 +559,7 @@ 只有在聊天標籤頁中啟用 Auto-Tell 標籤頁後才會生效。 - + 設定已重新整理 @@ -567,7 +567,7 @@ Hellion Chat 0.5.0 已將設定重新整理為主題式標籤頁。你的聊天資料庫和訊息歷史保持不變。設定已重設為預設值。若要重新選擇隱私設定檔,請至隱私標籤頁中點擊重新開啟按鈕。先前設定的備份位於 HellionChat.json.pre-v10-backup,與目前使用中的設定檔位於同一目錄。 - + 一般 @@ -590,9 +590,9 @@ 關於 - + - + 佈景主題 @@ -606,14 +606,14 @@ 時間戳 - + 視窗框架 - + - + 在聊天輸入框旁顯示符號選擇器按鈕 @@ -621,7 +621,7 @@ 在頻道指示器左側新增一個小按鈕,點擊後開啟含有 FFXIV 圖示和精選符號列表的彈出視窗。若偏好更簡潔的輸入列,可停用此選項。 - + 儲存 @@ -632,9 +632,9 @@ 維護 - + - + 系統 @@ -654,7 +654,7 @@ 若你使用多個通訊貝,維護者建議每個通訊貝建立一個標籤頁以獲得更清晰的概覽。複製標籤頁,並在每個副本中限制頻道選擇。 - + 標籤頁圖示 @@ -956,7 +956,7 @@ AI-assisted machine translation. Pending native-speaker review. - + 輸入 @@ -1155,4 +1155,6 @@ 隱藏聊天(按 Enter 恢復) - +將此分頁移回主視窗 + + \ No newline at end of file diff --git a/HellionChat/Ui/Components/InputBar.cs b/HellionChat/Ui/Components/InputBar.cs index 12c6e8e..cb78a47 100644 --- a/HellionChat/Ui/Components/InputBar.cs +++ b/HellionChat/Ui/Components/InputBar.cs @@ -51,6 +51,13 @@ internal sealed class InputBar // Null in pop-outs (those have their own close button). Hides the main window. private readonly Action? _onHideWindow; + // Set only for pop-outs, and after construction: the window does not exist + // yet while its own input row is being built, and routing it through the DI + // graph would close a factory-callsite cycle MS.DI cannot see. Its presence + // is what puts the pop-in button in the row, so the main window cannot grow + // one by accident. + internal Action? OnPopIn { get; set; } + private string _pendingMessage = string.Empty; private bool _isFocused; private bool _wasInputTextHovered; @@ -541,7 +548,7 @@ internal sealed class InputBar tooltip = HellionStrings.InputBar_Settings_Tooltip; // Hides the window (1.5.6 UserHide). One-way — Enter brings it back. - // Main window only (pop-outs have their own close); last in the row. + // Main window only; last in the row there. if (Plugin.Config.ShowHideButton && _onHideWindow is not null) { ImGui.SameLine(); @@ -550,6 +557,36 @@ internal sealed class InputBar if (ImGui.IsItemHovered()) tooltip = HellionStrings.InputBar_HideChat_Tooltip; } + + // Pop-in, in the pop-out windows only. It sits here rather than in a + // header row because a pop-out with its title bar on had no header at + // all -- and the title bar carries no close button, since closing has + // to go through the pool to release the slot. + if (OnPopIn is not null) + { + ImGui.SameLine(); + // Red, and contrast-checked against the button plate it sits on + // rather than taken raw: several themes ship a danger colour that + // is nearly invisible on their own button fill. + var danger = ColourUtil.RgbaToAbgr( + _resolver.Resolve(Token.StatusDanger, _themes.Active.Colors) + ); + var plate = ColourUtil.Vector4ToAbgr(ImGui.GetStyle().Colors[(int)ImGuiCol.Button]); + using ( + ImRaii.PushColor( + ImGuiCol.Text, + ColourUtil.RgbaToVector4( + ColourUtil.RgbaToAbgr(ColourUtil.EnsureContrast(danger, plate, 3f)) + ) + ) + ) + { + if (ImGui.Button(FontAwesomeIcon.Times.ToIconString())) + OnPopIn(); + } + if (ImGui.IsItemHovered()) + tooltip = HellionStrings.InputBar_PopIn_Tooltip; + } } if (tooltip is not null) diff --git a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs index 8bcdb3f..8ad82cb 100644 --- a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs +++ b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs @@ -47,6 +47,14 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow _backdrop = backdrop; _themes = themes; _resolver = resolver; + // The pop-in button lives in the input row. Wired here rather than + // through the constructor because this window is what it has to call. + _input.OnPopIn = () => + { + if (Bound is { } tab) + CloseRequested?.Invoke(tab.Identifier); + }; + IsOpen = false; RespectCloseHotkey = false; ShowCloseButton = false; @@ -115,11 +123,14 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow if (Bound is null) return; - // Only when the window has no title bar of its own. With the bar on, - // this row repeated the tab name directly underneath it -- the same - // string twice, one line apart. + // No header row at all any more. With the title bar on it repeated the + // tab name one line below itself; with the bar off, hiding it took the + // only way out of the window with it, because the title bar carries no + // close button either -- closing has to go through the pool so the slot + // is released. Pop-in lives in the input row now, where the other window + // actions already are. if (!Plugin.Config.ShowPopOutTitleBar) - DrawHeader(Bound); + DrawTitle(Bound); // The header close button can unbind us mid-frame (CloseRequested -> // pool.TryClose -> Unbind nulls Bound). Re-check before the body so we @@ -153,43 +164,10 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow _input.Draw(Bound); } - private void DrawHeader(Tab tab) + // Name only. Shown when the window has no title bar to carry it. + private void DrawTitle(Tab tab) { - // Identifier + close action. Pop-In/Pin are wired in the same row; the - // close button is the canonical "send the tab back" affordance for v1.8.0. - // PartnerHonorific is deferred (HonorificService has no per-target title, - // plan §D / Sub-Spec WARN-8) — no honorific row here. - var colors = _themes.Active.Colors; - var text = ColourUtil.RgbaToAbgr(_resolver.Resolve(Ui.StyleEngine.Token.Text, colors)); - var surface = ColourUtil.RgbaToAbgr( - _resolver.Resolve(Ui.StyleEngine.Token.SurfaceBase, colors) - ); - var danger = ColourUtil.RgbaToAbgr( - _resolver.Resolve(Ui.StyleEngine.Token.StatusDanger, colors) - ); - ImGui.TextUnformatted(tab.Name); - - // The plugin's own icon button rather than ImGui.Button. The stock one - // took the theme's button colours, which on several themes is a bright - // magenta plate next to plain text -- the loudest thing in the window - // being the way out of it. - var size = ImGui.GetFrameHeight(); - ImGui.SameLine(ImGui.GetContentRegionAvail().X - size); - var (clicked, _) = IconButton.Draw( - ImGui.GetID($"popout.close.{_slotIndex}"), - new Vector2(size, size), - FontAwesomeIcon.Times, - ColourUtil.EnsureContrast(text, surface, 3f), - danger, - _fonts.FontAwesome - ); - - if (clicked) - { - // Pop-In: release the slot via the pool (not a bare Unbind, which - // would orphan the slot — the pool owns the slot bookkeeping). - CloseRequested?.Invoke(tab.Identifier); - } + ImGui.Separator(); } } From f205dd4e54619dc997344676498ccfc7281ff1d0 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 19:34:27 +0200 Subject: [PATCH 096/129] chore(release): close the v1.11.0 cycle Version to 1.11.0 in the csproj, changelog and roadmap entries for the local state. Not published: the public release stays at v1.5.6 and the download links are untouched. repo.json goes back to 1.5.6.0. It had been carrying the development version while all three download links pointed at the v1.5.6 archive, which is a trap waiting for a merge: Dalamud would offer an update to a version the download does not contain, install v1.5.6 again, and offer the same update on the next launch. main is unaffected -- it only ever saw 1.5.6.0 -- but the feature branches have been inheriting the mismatch since v1.6.0. The manifest version belongs to what the links actually serve. A local state is tracked by the csproj and the changelog, which is what they are for. Testing channel deliberately not opened yet. Publishing 1.11.0 as a test build would ship a modern settings window alongside a first-run wizard and a message list that are still stock ImGui -- and 1.10.0 would ship the pinned-tell-history bug that 1.11.0 fixes. --- HellionChat/HellionChat.csproj | 2 +- docs/CHANGELOG.md | 64 ++++++++++++++++++++++++++++++++++ docs/ROADMAP.md | 6 +++- repo.json | 4 +-- 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj index 2f0cb6f..e732c5d 100644 --- a/HellionChat/HellionChat.csproj +++ b/HellionChat/HellionChat.csproj @@ -1,7 +1,7 @@ - 1.10.0 + 1.11.0 enable enable diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d53edfe..b71f97f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,70 @@ releases as an overview and links to the release pages for details. --- +## [1.11.0] — unreleased (local only) + +The settings window, rebuilt on the widgets v1.10.0 introduced, plus the defects the rebuild kept +turning up. **Not published** — the public release stays at v1.5.6. + +### Added + +- Every settings tab now draws the same way: section headings as tracked small caps with a rule + that fades out, rows with the label left and the control right-aligned, sliding switches instead + of checkboxes, and a segmented control where two radio buttons used to pretend to be two + settings. +- Explanations moved out of the help markers and onto the rows. Many were translated into 25 + languages and had never appeared on screen. +- Surfaces have depth: a gradient, an accent wash from the top edge, and slow drifting motes. The + chat log and the pop-outs stand on the same floor, at a fraction of the intensity — a settings + page is read in glances, a chat log line by line. `Reduce motion` turns the motes off entirely. +- Foreground colours are chosen by measured WCAG contrast against the surface they land on rather + than taken from the theme unchanged, so a pale accent cannot swallow white iconography. +- The settings window is opaque, unlike the chat window. Contrast cannot be computed against a + background that is the game world. +- Three settings that steered real behaviour and had no control at all are reachable again: + interface language, the global sound toggle, and the keybind mode. +- Whether the log shows history from before the current session now has a control. It was written + only by the first-run wizard, and only if the user reached step 3. +- Per-channel retention is visible in Data & Privacy. Three of the four wizard profiles write one + and switch the sweep on, and the window only ever showed the global default. + +### Fixed + +- Pinned tell tabs came up empty for the whole session. The history query runs at plugin start, + where no character is logged in yet, so it looked up tells for character zero and never tried + again. It defers to the login now. +- Every enum dropdown listed eight rows regardless of how many values it had, several of them + blank. The fourth argument of the binding is the popup height, not the item count. +- Six settings had a control, a saved value and no reader anywhere in the plugin. Three sat + together under timestamps, where only the 24-hour clock actually works. Removed rather than + wired up: their own strings disagree about what they were meant to do. +- The same privacy grid was drawn in two tabs, all 89 entries of it. +- Descriptions were sized as one line while being drawn with a wrap width — clipped in a setting + row, overflowing in a section heading. +- Row separators used an unscaled offset with a scaled stroke, so at UI scale 2 half the line + landed in the next row. +- A pop-out with its title bar on could not be closed at all. Pop-in now sits in the input row. +- BetterTTV's shared-emote endpoint went behind authentication; the 403 response was handed to a + list deserializer and took the 65 working global emotes down with it on every start. + +### Changed + +- Thirteen labels that had translated resources but were drawn as English literals now use them, + including all seven tab names in the sidebar. +- Twelve duplicated per-tab helpers merged into one place. The enum combos were calling + `Enum.GetValues` inside their draw call, allocating five arrays per frame while the window was + open. + +### Known issues + +- Roughly a hundred strings in the settings window are still English on non-English clients: the + section headings, the keybind labels, and the descriptions written during this cycle. They have + no resource key yet. Translation is planned as its own pass before the tester beta; new strings + ship translated from here on. +- The first-run wizard, the input bar buttons and the message list itself are still stock ImGui. + +--- + ## [1.10.0] — unreleased (local only) The style foundation the v2.x plan has been carrying since May. Custom-drawn chrome instead of diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 97a82fa..44f21d0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,7 +14,7 @@ be a poor fit for the plugin's privacy-first scope during brainstorming. The published release is **v1.5.6**. Development since then runs as a UI rebuild towards v2.0.0 and is not published: the whole window layer is being rewritten from ImGui defaults to custom drawing. -Versions v1.6.0 through v1.10.0 are local development states, and `repo.json` deliberately keeps +Versions v1.6.0 through v1.11.0 are local development states, and `repo.json` deliberately keeps its download links on v1.5.6 so nobody updates into a partial state. Where it stands: @@ -24,6 +24,10 @@ Where it stands: - **v1.9.0** — polish and a concurrency hardening pass. - **v1.10.0** — the style foundation: a scale-aware layout layer, held hover state, and five drawn widgets, applied to the sidebar, the tab strip and the status bar. +- **v1.11.0** — the settings window rebuilt on those widgets, and the defects that surfaced while + doing it: three settings with no control, a pinned tell tab that never loaded its history, six + switches that changed nothing, and dropdowns listing blank rows. Contrast is measured against the + surface now rather than taken from the theme. - **v1.11.0 onwards** — settings window, then the sidebar moving from tab rows to channel rows, then typography and message cards. diff --git a/repo.json b/repo.json index 15cf316..51e45e2 100644 --- a/repo.json +++ b/repo.json @@ -3,7 +3,7 @@ "Author": "Jon Kazama (Hellion Forge)", "Name": "Hellion Chat", "InternalName": "HellionChat", - "AssemblyVersion": "1.10.0.0", + "AssemblyVersion": "1.5.6.0", "Description": "A Hellion Forge plugin — privacy-focused chat replacement for FINAL FANTASY XIV, built for EU, US and JP data rules.\n\nBy default only your own conversations are stored. Public chat, NPC dialogue, system messages and battle logs are discarded at the storage layer unless you opt in. Retention windows are configurable per channel, history can be wiped retroactively, and everything can be exported on demand.\n\nFeatures:\n- Channel whitelist with a Privacy-First default\n- Per-channel retention with a daily background sweep\n- Retroactive cleanup with preview and Ctrl+Shift confirm\n- Export to Markdown, JSON or CSV\n- First-run wizard with four profiles: Privacy-First, Casual, Roleplay, Full History\n- Multi-language UI (24 locales) with live language switching\n- Own config and database — no shared state with other plugins\n\nBased on Chat 2 by Infi and Anna (EUPL-1.2).\nSupport: https://discord.gg/X9V7Kcv5gR", "ApplicableVersion": "any", "RepoUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat", @@ -25,7 +25,7 @@ "DownloadLinkInstall": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip", "DownloadLinkUpdate": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip", "DownloadLinkTesting": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip", - "TestingAssemblyVersion": "1.10.0.0", + "TestingAssemblyVersion": "1.5.6.0", "IconUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/icon.png", "ImageUrls": [ "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/chatWindow.png", From 8bf351ba0223b3f3c5ea17f5ddf1defbaa79f115 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 19:37:07 +0200 Subject: [PATCH 097/129] docs: correct the untranslated string count Measured across the whole UI rather than estimated from the settings window: around 270 visible literals without a resource key, not the hundred the changelog claimed. The settings window is the bulk of it, but the first-run wizard, the input bar and the about tab carry their share. Smoke test for the cycle came back green -- everything works and does what it says. The untranslated strings are the only known issue. --- docs/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b71f97f..00d2802 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -67,9 +67,9 @@ turning up. **Not published** — the public release stays at v1.5.6. ### Known issues -- Roughly a hundred strings in the settings window are still English on non-English clients: the - section headings, the keybind labels, and the descriptions written during this cycle. They have - no resource key yet. Translation is planned as its own pass before the tester beta; new strings +- Around 270 visible strings across the plugin are still English on non-English clients, most of + them in the settings window: section headings, keybind labels, and the descriptions written + during this cycle. They have no resource key yet. Translation is planned as its own pass before the tester beta; new strings ship translated from here on. - The first-run wizard, the input bar buttons and the message list itself are still stock ImGui. From 125a57167ecbe89c4547e67b080dd6836a875798 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 20:03:03 +0200 Subject: [PATCH 098/129] fix(privacy): purge the full-text index when messages are deleted messages_fts stores sender and content as plain text, and no delete path touched it. ClearMessages, CleanupRetainOnly and the retention sweep all removed rows from `messages` alone, so the readable text of every "deleted" message stayed on disk. It was self-sealing. InitFtsReadyCache treats a non-empty index as ready, so after a wipe the index stayed full, the flag stayed true, and the rebuild that would have cleared it never ran again. This is not hypothetical: the retention sweep runs unattended every 24 hours, so any user with retention on has been accumulating orphaned plain text since the index shipped. And the plugin says otherwise in two places -- the clear button promises "Removes all message history. Cannot be restored", and PRIVACY.md documents targeted deletion as a feature. Wiping the index rather than deleting matched rows, because message_guid is a GUID string while messages.Id is a BLOB and the two cannot be joined in SQL. The index is derived data; it rebuilds from the surviving rows on the next start, which is both cheaper and provably complete. CleanupRetainOnly also skips VACUUM when nothing matched, the way DeleteByRetentionPolicy already did. Rewriting the whole file for zero deleted rows costs seconds on a large database and gains nothing. Six tests drive the real store against a real database, since the defect was in what the SQL did not touch rather than in any computed value. --- HellionChat/MessageStore.cs | 39 ++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index c46f841..b77701e 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -464,11 +464,35 @@ internal class MessageStore : IDisposable cmd.ExecuteNonQuery(); } + // Drops the full-text index and marks it for a rebuild. + // + // messages_fts stores sender_text and content_text in the clear, and no + // delete path touched it: ClearMessages, CleanupRetainOnly and the retention + // sweep all removed rows from `messages` only. The plain text of every + // "deleted" message stayed on disk. + // + // Worse, it was self-sealing. InitFtsReadyCache treats a non-empty index as + // ready, so after a wipe the index stayed full, the readiness flag stayed + // true, and the rebuild that would have cleared it never ran again. + // + // Wiping rather than deleting matched rows: message_guid is stored as a GUID + // string while messages.Id is a BLOB, so the two cannot be joined in SQL. + // The index is derived data and rebuilds from the surviving rows on the next + // start, which is the cheap and provably complete option. + // + // Caller must already hold _readLock. + private void InvalidateFtsIndex() + { + Connection.Execute("DELETE FROM messages_fts;"); + _ftsReady = false; + } + internal void ClearMessages() { lock (_readLock) { Connection.Execute("DELETE FROM messages;"); + InvalidateFtsIndex(); PerformMaintenance(); } } @@ -568,7 +592,11 @@ internal class MessageStore : IDisposable } if (deleted > 0) + { + InvalidateFtsIndex(); PerformMaintenance(); + } + return deleted; } } @@ -592,7 +620,16 @@ internal class MessageStore : IDisposable cmd.CommandTimeout = 600; deleted = cmd.ExecuteNonQuery(); } - PerformMaintenance(); + + // Skipped when nothing matched: VACUUM rewrites the whole file, and + // running it for zero deleted rows costs seconds on a large database + // for no benefit. DeleteByRetentionPolicy already guards this way. + if (deleted > 0) + { + InvalidateFtsIndex(); + PerformMaintenance(); + } + return deleted; } } From d0eb2934ed704bc2ea96dca3465fcce20a23708c Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 20:19:27 +0200 Subject: [PATCH 099/129] feat(db): one gate for every long-running database operation Generalises the retention-sweep lock, which already solved this for a single case: it stopped a manual sweep from racing the automatic one, and nothing else. Export, cleanup and clear need the same protection against each other, and for a sharper reason. An export leaves a reader open on the primary connection deliberately outside _readLock, because the enumerator is consumed lazily by its caller. A VACUUM starting while that reader lives meets an active reader on a connection Microsoft documents as not thread-safe, and PerformMaintenance sets no command timeout, so it inherits five seconds before throwing -- after the DELETE has already committed. TryBegin refuses rather than queues. Every one of these is user-initiated, and a wipe that fires minutes after the click is worse than one that declines. End is idempotent and does not check which operation ends, so a worker that throws before acquiring can still release from its finally block. Current is volatile because the draw thread reads it every frame to decide which buttons are disabled. Blocking on the lock to find that out would freeze the game for the length of a VACUUM, which is the exact failure this is meant to prevent. Pure state machine, so the transitions are pinned without a database or an ImGui frame -- including that exactly one of 64 competing callers wins. --- HellionChat/Plugin.cs | 29 ++++++------ HellionChat/Util/DbOperationGate.cs | 70 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 HellionChat/Util/DbOperationGate.cs diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 8ed0f49..c43a207 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -184,11 +184,16 @@ public sealed class Plugin : IAsyncDalamudPlugin // tears down (the worker logs "rebuild failed" via Log on error paths). private CancellationTokenSource? _ftsRebuildCts; - // Serialises retention sweeps so a manual trigger and the 24h auto-sweep - // can't run in parallel. Volatile because the ImGui thread reads it outside - // the lock to gate the manual button. - internal readonly object RetentionSweepLock = new(); - internal volatile bool RetentionSweepRunning; + // Serialises every long-running database operation against every other one, + // not just retention sweeps against each other. An export leaves a reader + // open on the primary connection outside _readLock by design -- the + // enumerator is consumed lazily -- and a VACUUM meeting that reader hits a + // connection Microsoft documents as not thread-safe. + // + // Replaces the retention-only pair, which solved the same problem for one + // case. The draw thread reads Current every frame to disable buttons and + // must never block doing so. + internal readonly Util.DbOperationGate DbOperations = new(); // B3: neutral owner of the Config.Tabs LIST-structure lock so both the // worker-thread mutator (AutoTellTabsService) and the framework-thread @@ -989,13 +994,10 @@ public sealed class Plugin : IAsyncDalamudPlugin // IsBackground = true so a stuck sweep never blocks plugin unload. new Thread(() => { - // Bail early if a manual sweep is already in flight. - lock (RetentionSweepLock) - { - if (RetentionSweepRunning) - return; - RetentionSweepRunning = true; - } + // Bails when anything else already owns the store, not only another + // sweep: a user-triggered export or cleanup counts too. + if (!DbOperations.TryBegin(Util.DbOperation.RetentionSweep)) + return; try { @@ -1042,8 +1044,7 @@ public sealed class Plugin : IAsyncDalamudPlugin } finally { - lock (RetentionSweepLock) - RetentionSweepRunning = false; + DbOperations.End(); } }) { diff --git a/HellionChat/Util/DbOperationGate.cs b/HellionChat/Util/DbOperationGate.cs new file mode 100644 index 0000000..7adc8c6 --- /dev/null +++ b/HellionChat/Util/DbOperationGate.cs @@ -0,0 +1,70 @@ +namespace HellionChat.Util; + +// Which long-running database operation currently owns the store. +internal enum DbOperation +{ + None, + RetentionSweep, + Export, + Cleanup, + Clear, +} + +// One gate for every operation that holds the message store for longer than a +// frame. Generalises the retention-sweep lock, which already did exactly this +// for a single case. +// +// The reason it has to cover all of them together, not one each: an export +// leaves a reader open on the primary connection deliberately outside _readLock, +// because the enumerator is consumed lazily by its caller. If a VACUUM starts +// while that reader lives, it meets an active reader on a connection Microsoft +// documents as not thread-safe, and PerformMaintenance sets no command timeout +// so it inherits five seconds before throwing. +// +// Pure state machine, no ImGui and no database, so the build suite can pin the +// transitions without standing up either. +internal sealed class DbOperationGate +{ + private readonly object _lock = new(); + + // Volatile because the draw thread reads it every frame to decide which + // buttons are disabled, and must never block on the lock to do so -- that + // would freeze the game for the length of a VACUUM. + private volatile DbOperation _current = DbOperation.None; + + internal DbOperation Current => _current; + + internal bool IsBusy => _current != DbOperation.None; + + // False when another operation already owns the store. Callers must not + // queue or wait: everything here is user-initiated, and a queued wipe that + // fires minutes later is worse than one that refuses. + internal bool TryBegin(DbOperation operation) + { + if (operation == DbOperation.None) + throw new ArgumentOutOfRangeException( + nameof(operation), + "None is the idle state, not an operation to begin." + ); + + lock (_lock) + { + if (_current != DbOperation.None) + return false; + + _current = operation; + return true; + } + } + + // Idempotent, and deliberately not checking which operation ends. A worker + // that throws must still be able to release from a finally block without + // knowing whether it ever acquired. + internal void End() + { + lock (_lock) + { + _current = DbOperation.None; + } + } +} From 6a3bbe23575881d0963162a5526c97e946fab0ab Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 20:28:28 +0200 Subject: [PATCH 100/129] fix(ci): let the version check tell a release from a local build The check demanded csproj, repo.json and every DownloadLink carry the same version. That is right for a published release and impossible for anything else, so it went red the moment v1.11.0 closed -- and pre-push blocks on it. The two states were conflated. repo.json is the distribution manifest, so its version has to describe what the links actually serve. Claiming 1.11.0 while every link serves v1.5.6 makes Dalamud offer an update, install the old build, and offer the same update again on the next launch. Satisfying the old rule meant building exactly that. Now: the manifest must always agree with itself and with its links, and the build must never be older than what is published. --release additionally demands the three match, which is the mode for cutting a tag and still catches the v1.2.2 burn it was written for. The link check moved from the csproj version to the manifest version, which is the pairing that protects users. In the old form a manifest could name a version none of its links served and pass, as long as the csproj agreed -- the exact mismatch it now rejects. --- scripts/verify-version-consistency.sh | 58 +++++++++++++++++++++------ 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/scripts/verify-version-consistency.sh b/scripts/verify-version-consistency.sh index c0b2ae7..a1066fc 100755 --- a/scripts/verify-version-consistency.sh +++ b/scripts/verify-version-consistency.sh @@ -1,10 +1,29 @@ #!/usr/bin/env bash # verify-version-consistency.sh — Block A of preflight. -# csproj is 3-digit SemVer; repo.json AssemblyVersion is 4-digit (.0 suffix). +# +# csproj is 3-digit SemVer; repo.json AssemblyVersion is 4-digit. +# +# Two states, and conflating them is what made this check unsatisfiable: +# +# Published csproj == repo.json == the tag in every DownloadLink. +# Unreleased csproj is ahead; repo.json and the links stay on whatever is +# actually downloadable. +# +# repo.json is the distribution manifest, so its version has to describe what the +# links serve. A manifest claiming 1.11.0 while every link serves v1.5.6 makes +# Dalamud offer an update, install the old build, and offer the same update on +# the next launch. +# +# Default run allows both states and enforces what holds in each. Pass --release +# to demand the published one; that is the mode for cutting a tag, and it is what +# catches the v1.2.2 burn (csproj bumped, repo.json forgotten). set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" +RELEASE_MODE=0 +[ "${1:-}" = "--release" ] && RELEASE_MODE=1 + CSPROJ="$ROOT/HellionChat/HellionChat.csproj" REPO_JSON="$ROOT/repo.json" @@ -14,23 +33,38 @@ ok() { echo "verify-version-consistency: OK — $1"; } CSPROJ_VER="$(grep -oE '[^<]+' "$CSPROJ" | head -1 | sed -E 's/<[^>]+>//g')" [ -n "$CSPROJ_VER" ] || fail "$CSPROJ has no element" -EXPECTED_4DIGIT="${CSPROJ_VER}.0" - REPO_VER="$(jq -r '.[0].AssemblyVersion' "$REPO_JSON")" -[ "$REPO_VER" = "$EXPECTED_4DIGIT" ] \ - || fail "csproj=$CSPROJ_VER expects repo.json AssemblyVersion=$EXPECTED_4DIGIT but got $REPO_VER. Fix: align in $REPO_JSON." - TEST_VER="$(jq -r '.[0].TestingAssemblyVersion' "$REPO_JSON")" -[ "$TEST_VER" = "$EXPECTED_4DIGIT" ] \ - || fail "TestingAssemblyVersion=$TEST_VER must match $EXPECTED_4DIGIT. Fix: align in $REPO_JSON." -TAG="v$CSPROJ_VER" +# Always: the manifest must agree with itself. +[ "$TEST_VER" = "$REPO_VER" ] \ + || fail "TestingAssemblyVersion=$TEST_VER must match AssemblyVersion=$REPO_VER in $REPO_JSON." + +# Always: every link must serve exactly the version the manifest claims. This is +# the check that actually protects users -- a mismatch here is an update loop. +REPO_TAG="v${REPO_VER%.*}" for KEY in DownloadLinkInstall DownloadLinkUpdate DownloadLinkTesting; do URL="$(jq -r ".[0].$KEY" "$REPO_JSON")" case "$URL" in - *"/$TAG/"*) ;; - *) fail "$KEY=$URL does not contain tag $TAG. Fix: update $REPO_JSON $KEY to releases/download/$TAG/latest.zip." ;; + *"/$REPO_TAG/"*) ;; + *) fail "$KEY=$URL does not serve $REPO_TAG, which is what AssemblyVersion=$REPO_VER claims. Either point the link at $REPO_TAG or set AssemblyVersion to the version the link serves." ;; esac done -ok "csproj=$CSPROJ_VER, repo.json=$EXPECTED_4DIGIT, tag $TAG present in DownloadLinks" +# Always: the build must never be older than what is published. +LOWER="$(printf '%s\n%s\n' "$CSPROJ_VER" "${REPO_VER%.*}" | sort -V | head -1)" +[ "$LOWER" = "${REPO_VER%.*}" ] \ + || fail "csproj=$CSPROJ_VER is older than the published ${REPO_VER%.*}. A build behind the manifest cannot be right." + +if [ "$RELEASE_MODE" -eq 1 ]; then + [ "$REPO_VER" = "${CSPROJ_VER}.0" ] \ + || fail "release mode: csproj=$CSPROJ_VER requires repo.json AssemblyVersion=${CSPROJ_VER}.0 but got $REPO_VER. Fix: align $REPO_JSON and point the DownloadLinks at v$CSPROJ_VER." + ok "release: csproj=$CSPROJ_VER, repo.json=$REPO_VER, links serve $REPO_TAG" + exit 0 +fi + +if [ "$REPO_VER" = "${CSPROJ_VER}.0" ]; then + ok "published: csproj=$CSPROJ_VER, repo.json=$REPO_VER, links serve $REPO_TAG" +else + ok "unreleased: csproj=$CSPROJ_VER ahead of published ${REPO_VER%.*}, links serve $REPO_TAG consistently" +fi From 22de2de2347b20d1314f370d8455afb499124828 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 20:37:40 +0200 Subject: [PATCH 101/129] docs(db): correct why VACUUM fails against an open reader The comment said PerformMaintenance inherits a five-second timeout before throwing. It does inherit that timeout, but it is not what happens here: a VACUUM on a connection with a live reader fails instantly with 'cannot VACUUM - SQL statements in progress'. That is SQLITE_ERROR, not SQLITE_BUSY. Busy handling only covers contention between different connections, so no timeout applies and no retry helps. The practical difference matters for the error message the UI will show: the DELETE has already committed when it fires, so the rows are gone and only the compaction is missing -- 'deleted but not compacted', not 'failed'. And PerformMaintenance batches VACUUM, REINDEX and ANALYZE in one statement, so a failing VACUUM takes the other two with it. --- HellionChat/Util/DbOperationGate.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/HellionChat/Util/DbOperationGate.cs b/HellionChat/Util/DbOperationGate.cs index 7adc8c6..da720aa 100644 --- a/HellionChat/Util/DbOperationGate.cs +++ b/HellionChat/Util/DbOperationGate.cs @@ -14,12 +14,17 @@ internal enum DbOperation // frame. Generalises the retention-sweep lock, which already did exactly this // for a single case. // -// The reason it has to cover all of them together, not one each: an export -// leaves a reader open on the primary connection deliberately outside _readLock, -// because the enumerator is consumed lazily by its caller. If a VACUUM starts -// while that reader lives, it meets an active reader on a connection Microsoft -// documents as not thread-safe, and PerformMaintenance sets no command timeout -// so it inherits five seconds before throwing. +// The reason it has to cover all of them together, not one each: an export can +// leave a reader open on the primary connection outside _readLock, because +// StreamForExport hands back an enumerator its caller consumes lazily. A VACUUM +// starting while that reader lives fails immediately with "cannot VACUUM - SQL +// statements in progress". +// +// That is SQLITE_ERROR, not SQLITE_BUSY, so no timeout applies and no retry +// helps -- busy handling only covers contention between different connections. +// And it fails after the DELETE has committed, so the rows are gone and the file +// is not compacted. PerformMaintenance runs VACUUM, REINDEX and ANALYZE as one +// batch, so the latter two never run either. // // Pure state machine, no ImGui and no database, so the build suite can pin the // transitions without standing up either. From 90bf986f76985a7da8e7c3384d8330268bf161b5 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 20:47:45 +0200 Subject: [PATCH 102/129] refactor(export): read text from chunks, write the file atomically Two changes to MessageExporter before it gets a caller. It read SenderSource and ContentSource, the raw SeStrings. TextValue on one holding an auto-translate phrase reaches SeStringEvaluator, which asserts it is on the main thread and throws unconditionally when a macro resolves a global number. An export belongs on a worker, so that would abort it partway and leave half a file. The plan called for resolving text in batches on the framework thread. Not needed: Message.Sender and Message.Content are already-resolved chunk lists -- ChunkUtil turns auto-translate into text at ingest, and the full-text index reads them exactly this way. Same strings, no evaluator, no thread affinity, and no batching machinery. Second, the file handling. The format was validated after the StreamWriter was opened, so an unknown format left a zero-byte file where a previous export had been. It is checked first now, and the write goes to a .part file that is moved into place at the end. A crash halfway used to leave a file that opens cleanly and is quietly incomplete -- which on the path a GDPR access request goes out on is worse than an obvious failure. Almost none of this is reachable from the build suite: ExportToFile takes IEnumerable, Message needs SeString, and xUnit cannot load Dalamud.dll -- even an empty list fails, because the runtime resolves the parameter type before the body runs. So the format mapping is pinned there and the rest by a new self-test, which builds probe messages with deliberately empty SeStrings: if the exporter ever reads them again, the text comes out blank and it fails. --- HellionChat/Export/MessageExporter.cs | 85 ++++++++-- HellionChat/Plugin.cs | 1 + HellionChat/SelfTests/ExportRoundTripStep.cs | 163 +++++++++++++++++++ 3 files changed, 234 insertions(+), 15 deletions(-) create mode 100644 HellionChat/SelfTests/ExportRoundTripStep.cs diff --git a/HellionChat/Export/MessageExporter.cs b/HellionChat/Export/MessageExporter.cs index c5c02fb..3f33006 100644 --- a/HellionChat/Export/MessageExporter.cs +++ b/HellionChat/Export/MessageExporter.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text; using HellionChat.Code; +using HellionChat.Util; namespace HellionChat.Export; @@ -33,7 +34,19 @@ internal static class ExportFormatExt } // Serializes message snapshots to Markdown, JSON, or CSV. -// Caller handles pre-filtering except sender substring, which requires deserialized SeString.TextValue. +// +// Text comes from the chunk lists, never from SenderSource/ContentSource. Those +// are raw SeStrings, and reading TextValue on one containing an auto-translate +// phrase reaches SeStringEvaluator, which asserts it is on the main thread and +// throws unconditionally when a macro resolves a global number. An export runs on +// a worker, so that would abort it partway and leave half a file behind. +// +// The chunks are already resolved: ChunkUtil turns auto-translate into text at +// ingest, and the full-text index reads them exactly this way. Same strings, no +// evaluator, no thread affinity. +// +// The caller pre-filters by channel and date via StreamForExport; only the sender +// substring is applied here. internal static class MessageExporter { internal record FilterDescription( @@ -50,22 +63,64 @@ internal static class MessageExporter FilterDescription filter ) { + // Rejected before the file is touched. The old order opened the stream + // first, so an unknown format left a zero-byte file where the user's + // previous export had been. + if (!Enum.IsDefined(format)) + throw new ArgumentOutOfRangeException(nameof(format), format, null); + var matching = filter.SenderSubstring is { Length: > 0 } needle ? messages.Where(m => MatchesSender(m, needle)) : messages; - using var writer = new StreamWriter(path, append: false, encoding: Encoding.UTF8); - return format switch + // Written beside the target and moved into place at the end. A crash or + // an unplugged drive halfway through would otherwise leave a file that + // opens fine and is quietly incomplete -- and this is the path a GDPR + // access request goes out on, where "looks complete" is the dangerous + // failure. + var temp = path + ".part"; + int written; + try { - ExportFormat.Markdown => WriteMarkdown(writer, matching, filter), - ExportFormat.Json => WriteJson(writer, matching, filter), - ExportFormat.Csv => WriteCsv(writer, matching, filter), - _ => throw new ArgumentOutOfRangeException(nameof(format), format, null), - }; + using (var writer = new StreamWriter(temp, append: false, encoding: Encoding.UTF8)) + { + written = format switch + { + ExportFormat.Markdown => WriteMarkdown(writer, matching, filter), + ExportFormat.Json => WriteJson(writer, matching, filter), + _ => WriteCsv(writer, matching, filter), + }; + } + + File.Move(temp, path, overwrite: true); + return written; + } + catch + { + TryDeleteTemp(temp); + throw; + } + } + + // Best effort: the export already failed, and a leftover .part file is a + // smaller problem than masking the original exception with an IO one. + private static void TryDeleteTemp(string temp) + { + try + { + if (File.Exists(temp)) + File.Delete(temp); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } } private static bool MatchesSender(Message m, string needle) => - m.SenderSource.TextValue.Contains(needle, StringComparison.OrdinalIgnoreCase); + SenderText(m).Contains(needle, StringComparison.OrdinalIgnoreCase); + + private static string SenderText(Message m) => ChunkUtil.ToRawString(m.Sender); + + private static string ContentText(Message m) => ChunkUtil.ToRawString(m.Content); private static int WriteMarkdown( StreamWriter w, @@ -94,8 +149,8 @@ internal static class MessageExporter } var chatType = (ChatType)(ushort)m.Code.Type; - var sender = m.SenderSource.TextValue.Trim().Trim('<', '>', '[', ']', ':').Trim(); - var content = m.ContentSource.TextValue; + var sender = SenderText(m).Trim().Trim('<', '>', '[', ']', ':').Trim(); + var content = ContentText(m); if (string.IsNullOrEmpty(sender)) w.WriteLine($"**[{localDate:HH:mm}] {chatType}:** {content}"); @@ -174,8 +229,8 @@ internal static class MessageExporter w.Write($",\"target_kind\":{m.Code.Target}"); w.Write($",\"receiver\":{m.Receiver}"); w.Write($",\"content_id\":{m.ContentId}"); - w.Write($",\"sender\":{JsonString(m.SenderSource.TextValue)}"); - w.Write($",\"content\":{JsonString(m.ContentSource.TextValue)}"); + w.Write($",\"sender\":{JsonString(SenderText(m))}"); + w.Write($",\"content\":{JsonString(ContentText(m))}"); w.Write("}"); } @@ -203,9 +258,9 @@ internal static class MessageExporter w.Write(','); w.Write(CsvString(chatType.ToString())); w.Write(','); - w.Write(CsvString(m.SenderSource.TextValue)); + w.Write(CsvString(SenderText(m))); w.Write(','); - w.Write(CsvString(m.ContentSource.TextValue)); + w.Write(CsvString(ContentText(m))); w.Write(','); w.Write(m.Receiver); w.Write(','); diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index c43a207..868a543 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -422,6 +422,7 @@ public sealed class Plugin : IAsyncDalamudPlugin await _lifecycle.LoadAsync(cancellationToken).ConfigureAwait(false); SelfTestRegistry.RegisterTestSteps([ + new SelfTests.ExportRoundTripStep(), new SelfTests.ThemeSwitchSelfTestStep(this), new SelfTests.ThemeCrossfadeSelfTestStep(this), new SelfTests.FontManagerCtorSmokeStep(this), diff --git a/HellionChat/SelfTests/ExportRoundTripStep.cs b/HellionChat/SelfTests/ExportRoundTripStep.cs new file mode 100644 index 0000000..c7e7731 --- /dev/null +++ b/HellionChat/SelfTests/ExportRoundTripStep.cs @@ -0,0 +1,163 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Text; +using Dalamud.Plugin.SelfTest; +using HellionChat.Code; +using HellionChat.Export; +using HellionChat.Util; + +namespace HellionChat.SelfTests; + +// v1.12.0/A2: the exporter now reads text from the chunk lists instead of the +// raw SeStrings. That change is invisible to the build suite -- ExportToFile +// takes IEnumerable, Message needs SeString, and xUnit cannot load +// Dalamud.dll, so even an empty list fails before the body runs. +// +// So it is verified here, against real messages, with the three properties that +// actually matter: +// +// 1. Text survives the round trip. If the exporter ever reads SenderSource or +// ContentSource again, a message built without them comes out blank. +// 2. The format guard runs before the file is opened. It used to run after, +// so an unknown format left a zero-byte file where a previous export had +// been. +// 3. The write is atomic. A failure partway must not leave a file that opens +// cleanly and is quietly incomplete -- this is the path a GDPR access +// request goes out on. +internal sealed class ExportRoundTripStep : ISelfTestStep +{ + public string Name => "Hellion Chat - Export round trip"; + + public SelfTestStepResult RunStep() + { + var dir = Path.Combine(Path.GetTempPath(), $"hellionchat-selftest-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + + var failures = new List(); + try + { + CheckTextSurvives(dir, failures); + CheckUnknownFormatKeepsExistingFile(dir, failures); + CheckNoLeftoverPartFile(dir, failures); + } + catch (Exception e) + { + failures.Add($"threw: {e.GetType().Name}: {e.Message}"); + } + finally + { + TryCleanup(dir); + } + + foreach (var f in failures) + ImGui.Text(f); + + SelfTestReport.Append(Name, failures.Count == 0 ? "PASS" : "FAIL", failures); + return failures.Count == 0 ? SelfTestStepResult.Pass : SelfTestStepResult.Fail; + } + + private static void CheckTextSurvives(string dir, List failures) + { + const string sender = "Selftest Sender"; + const string content = "selftest content marker"; + + var path = Path.Combine(dir, "roundtrip.csv"); + var written = MessageExporter.ExportToFile( + path, + ExportFormat.Csv, + [Probe(sender, content)], + new MessageExporter.FilterDescription(null, null, null, null) + ); + + if (written != 1) + failures.Add($"expected 1 message written, got {written}"); + + var text = File.ReadAllText(path); + if (!text.Contains(sender, StringComparison.Ordinal)) + failures.Add("sender missing from export -- reading SeString again?"); + if (!text.Contains(content, StringComparison.Ordinal)) + failures.Add("content missing from export -- reading SeString again?"); + + // The sender filter runs inside the exporter and must see the same text. + var filtered = Path.Combine(dir, "filtered.csv"); + var hits = MessageExporter.ExportToFile( + filtered, + ExportFormat.Csv, + [Probe(sender, content), Probe("Someone Else", "other")], + new MessageExporter.FilterDescription(null, null, null, "selftest sen") + ); + + if (hits != 1) + failures.Add($"sender filter matched {hits} messages, expected 1"); + } + + private static void CheckUnknownFormatKeepsExistingFile(string dir, List failures) + { + var path = Path.Combine(dir, "previous.md"); + File.WriteAllText(path, "an earlier export"); + + try + { + MessageExporter.ExportToFile( + path, + (ExportFormat)99, + [], + new MessageExporter.FilterDescription(null, null, null, null) + ); + failures.Add("unknown format did not throw"); + } + catch (ArgumentOutOfRangeException) { } + + if (File.ReadAllText(path) != "an earlier export") + failures.Add("unknown format destroyed the existing file"); + } + + private static void CheckNoLeftoverPartFile(string dir, List failures) + { + var path = Path.Combine(dir, "clean.json"); + MessageExporter.ExportToFile( + path, + ExportFormat.Json, + [Probe("A", "b")], + new MessageExporter.FilterDescription(null, null, null, null) + ); + + if (!File.Exists(path)) + failures.Add("export produced no file"); + if (File.Exists(path + ".part")) + failures.Add("temporary file left behind after a successful export"); + } + + // Built with empty SeStrings on purpose: the whole point is that the text + // comes from the chunks. + private static Message Probe(string sender, string content) + { + static List Text(string s) => + [new TextChunk(ChunkSource.Content, null, null, null, null, false, s)]; + + return new Message( + 0, + 0, + 0, + new ChatCode(XivChatType.Say, 0, 0), + Text(sender), + Text(content), + new Dalamud.Game.Text.SeStringHandling.SeString(), + new Dalamud.Game.Text.SeStringHandling.SeString() + ); + } + + // Nothing to undo between runs: every file lives in a fresh temp directory + // that RunStep deletes in its own finally. + public void CleanUp() { } + + private static void TryCleanup(string dir) + { + try + { + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} From d59ee6222328076525127e85ff8b910d95471263 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 21:25:10 +0200 Subject: [PATCH 103/129] feat(privacy): reconnect the message export The exporter has worked since v1.4.8. The form that drives it went out with the old settings window in May, which left PRIVACY.md promising an access request the plugin had no way to answer. New section in the data and privacy tab: time range, sender substring, channel groups, format, and a save dialog. Form state lives in the tab, not the config -- a filter describes one action, and a stale "last 7 days, sender Mira" reappearing weeks later is a worse start than an empty form. StreamForExport now takes a caller-owned connection. The reader stays open for as long as the file is written, seconds to minutes on a large history, and chat keeps arriving throughout -- so the primary connection would be read here and written by UpsertMessage at once, and SqliteConnection is not thread-safe. Holding the read lock instead would trade that for freezing the game. ChannelGroups lifts the eight groups out of the deleted tab and finishes them: 37 of 89 channels belonged to no group and were therefore unreachable in the UI. Game Master channels follow ChatTypeExt.Parent(), so GmTell sits with the other tells rather than under system traffic -- an access request that quietly drops part of what it promises is the dangerous kind of gap. Also here: - OpenSecondaryConnection disposes on a failing pragma. Open can succeed and journal_mode=WAL still time out, and with Pooling=false the connection then survives until a finalizer reaches it. Affects the full-text rebuild worker too. - StreamForExport builds its logger before the reader, so a throwing CreateLogger cannot leave a reader nobody owns. - The export thread takes the gate itself instead of the caller taking it first. Acquiring before Start would strand the gate for the session if thread creation failed, and the gate also holds back the sweep. - Notifications are skipped once teardown has started. The thread has no cancellation path and finishing the file is right, but reporting it to a plugin that is gone is not. - Transient widget rows that return their value instead of saving it. Writing the config file on every keystroke of a sender filter would be both pointless and slow. - Five translated keys for "another database operation is running", in all 25 languages. Two of the four operation names have no trigger yet; they arrive with the cleanup and maintenance sections. --- HellionChat/MessageStore.cs | 84 +++-- HellionChat/Plugin.cs | 5 + HellionChat/PluginHostFactory.cs | 3 +- HellionChat/Privacy/ChannelGroups.cs | 156 +++++++++ .../Resources/HellionStrings.Designer.cs | 5 + HellionChat/Resources/HellionStrings.ca.resx | 17 +- HellionChat/Resources/HellionStrings.cs.resx | 17 +- HellionChat/Resources/HellionStrings.da.resx | 17 +- HellionChat/Resources/HellionStrings.de.resx | 17 +- HellionChat/Resources/HellionStrings.el.resx | 17 +- HellionChat/Resources/HellionStrings.es.resx | 17 +- HellionChat/Resources/HellionStrings.fi.resx | 17 +- HellionChat/Resources/HellionStrings.fr.resx | 17 +- HellionChat/Resources/HellionStrings.hu.resx | 17 +- HellionChat/Resources/HellionStrings.it.resx | 17 +- HellionChat/Resources/HellionStrings.ja.resx | 17 +- HellionChat/Resources/HellionStrings.ko.resx | 17 +- HellionChat/Resources/HellionStrings.nb.resx | 17 +- HellionChat/Resources/HellionStrings.nl.resx | 17 +- HellionChat/Resources/HellionStrings.pl.resx | 17 +- .../Resources/HellionStrings.pt-BR.resx | 17 +- .../Resources/HellionStrings.pt-PT.resx | 17 +- HellionChat/Resources/HellionStrings.resx | 17 +- HellionChat/Resources/HellionStrings.ro.resx | 17 +- HellionChat/Resources/HellionStrings.ru.resx | 17 +- HellionChat/Resources/HellionStrings.sv.resx | 17 +- HellionChat/Resources/HellionStrings.tr.resx | 17 +- HellionChat/Resources/HellionStrings.uk.resx | 17 +- .../Resources/HellionStrings.zh-Hans.resx | 17 +- .../Resources/HellionStrings.zh-Hant.resx | 17 +- .../Ui/Components/Settings/SettingsWidgets.cs | 114 +++++++ .../Settings/Tabs/DataPrivacyTab.cs | 315 +++++++++++++++++- HellionChat/Util/DbOperationGate.cs | 20 +- 33 files changed, 1056 insertions(+), 71 deletions(-) create mode 100644 HellionChat/Privacy/ChannelGroups.cs diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index b77701e..21c3bd0 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -710,9 +710,21 @@ internal class MessageStore : IDisposable internal SqliteConnection OpenSecondaryConnection() { var conn = new SqliteConnection(BuildConnectionString(DbPath)); - conn.Open(); - ApplyPragmas(conn); - return conn; + try + { + conn.Open(); + ApplyPragmas(conn); + return conn; + } + catch + { + // Open can succeed and ApplyPragmas still throw: journal_mode=WAL + // needs a lock and gives up after DefaultTimeout. Without this the + // connection is neither returned nor closed, and with Pooling=false + // it survives until a finalizer gets to it. + conn.Dispose(); + throw; + } } // Worker-only mutator. The bulk-insert worker is the single legitimate @@ -976,53 +988,57 @@ internal class MessageStore : IDisposable // 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. - // Lock caveat: lock guards command setup and ExecuteReader; the returned - // MessageEnumerator is iterated lazily by the caller outside the lock. - // Acceptable for v1.4.8 -- DbViewer iterates on its filter-worker Task and - // any clash with UpsertMessage on the primary Connection is rare and - // serialised by SQLite's own connection-level lock. v1.5.x DI cycle should - // address this with a snapshot-to-list or connection pool. + // Caller is responsible for disposing the enumerator and the connection. + // + // Takes a caller-owned connection from OpenSecondaryConnection rather than + // using the primary one, and therefore takes no lock. The reader stays open + // for as long as the export writes, which is seconds to minutes on a large + // history, and chat keeps arriving throughout -- so the primary connection + // would be read here and written by UpsertMessage at the same time, and + // SqliteConnection is not thread-safe. Holding _readLock for the whole + // export would trade that for freezing the game instead. + // + // WAL gives readers their own snapshot, so a live write cannot tear the + // export mid-file either. internal MessageEnumerator StreamForExport( + SqliteConnection conn, IReadOnlyCollection? chatTypes, DateTimeOffset? from, DateTimeOffset? to ) { - lock (_readLock) - { - var cmd = Connection.CreateCommand(); + var cmd = conn.CreateCommand(); - var clauses = new List { "deleted = false" }; - if (chatTypes is { Count: > 0 }) - clauses.Add($"ChatType IN ({BindIntList(cmd, "exct", chatTypes)})"); - if (from is not null) - clauses.Add("Date >= $From"); - if (to is not null) - clauses.Add("Date <= $To"); + var clauses = new List { "deleted = false" }; + if (chatTypes is { Count: > 0 }) + clauses.Add($"ChatType IN ({BindIntList(cmd, "exct", chatTypes)})"); + if (from is not null) + clauses.Add("Date >= $From"); + if (to is not null) + clauses.Add("Date <= $To"); - cmd.CommandText = - @" + cmd.CommandText = + @" SELECT Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, Sender, Content, SenderSource, ContentSource, ExtraChatChannel FROM messages WHERE " - + string.Join(" AND ", clauses) - + @" + + string.Join(" AND ", clauses) + + @" ORDER BY Date ASC;"; - cmd.CommandTimeout = 600; + cmd.CommandTimeout = 600; - if (from is not null) - cmd.Parameters.AddWithValue("$From", from.Value.ToUnixTimeMilliseconds()); - if (to is not null) - cmd.Parameters.AddWithValue("$To", to.Value.ToUnixTimeMilliseconds()); + if (from is not null) + cmd.Parameters.AddWithValue("$From", from.Value.ToUnixTimeMilliseconds()); + if (to is not null) + cmd.Parameters.AddWithValue("$To", to.Value.ToUnixTimeMilliseconds()); - return new MessageEnumerator( - cmd.ExecuteReader(), - _loggerFactory.CreateLogger() - ); - } + // Logger first: an argument list evaluates left to right, so a throwing + // CreateLogger -- which is what a disposed host gives you -- would leave + // an open reader that no MessageEnumerator owns. + var logger = _loggerFactory.CreateLogger(); + return new MessageEnumerator(cmd.ExecuteReader(), logger); } // Returns the most recent messages, oldest-first. diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 868a543..d77c654 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -169,6 +169,11 @@ public sealed class Plugin : IAsyncDalamudPlugin // because the tick reads it from a different thread than the writer. private volatile bool _isDisposing; + // Read by background workers that outlive a teardown -- the export thread + // finishes its file either way, but a notification for a plugin the user + // just unloaded belongs to nobody. + internal bool IsDisposing => _isDisposing; + // v1.9.0 B5: last full Draw() wall-time in ms, written once per frame at // the end of the UiBuilder.Draw handler. Covers the GlobalStyleScope push // and the font push (§7.5 First-Frame-HITCH must include atlas/style diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index d36659c..eee2b8d 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -234,7 +234,8 @@ internal static class PluginHostFactory )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.DataPrivacyTab( sp.GetRequiredService(), - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService>() )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.AboutTab( sp.GetRequiredService(), diff --git a/HellionChat/Privacy/ChannelGroups.cs b/HellionChat/Privacy/ChannelGroups.cs new file mode 100644 index 0000000..1adae18 --- /dev/null +++ b/HellionChat/Privacy/ChannelGroups.cs @@ -0,0 +1,156 @@ +using HellionChat.Code; +using HellionChat.Resources; + +namespace HellionChat.Privacy; + +// The eight buckets the privacy surface sorts channels into. Sixty checkboxes in +// one flat list is not a choice anybody makes; eight named groups is. +// +// Headings are functions, not strings, so a language switch at runtime relabels +// them on the next frame. A captured string would keep the language the window +// happened to be opened in. +// +// Lives here rather than in a tab because the retention and cleanup screens +// will need the same grouping, and three copies would drift the moment a patch +// adds a channel. Only the export uses it so far. +// +// Game Master channels follow ChatTypeExt.Parent(), which already pairs each of +// them with its player counterpart. Filing them all under system traffic reads +// tidier but puts GmTell -- a private two-person conversation -- outside the +// direct-messages group, and an access request that quietly drops part of what +// it promises is the dangerous kind of gap. +// +// Every ChatType belongs to exactly one group, and a build-suite test pins that. +// A channel in no group cannot be picked in any of these screens, which reads as +// a missing checkbox rather than as an omission. +internal static class ChannelGroups +{ + internal static readonly (Func Heading, ChatType[] Types)[] All = + [ + ( + () => HellionStrings.Privacy_Group_DirectMessages, + [ChatType.TellIncoming, ChatType.TellOutgoing, ChatType.GmTell] + ), + ( + () => HellionStrings.Privacy_Group_PartyAlliance, + [ + ChatType.Party, + ChatType.CrossParty, + ChatType.Alliance, + ChatType.PvpTeam, + ChatType.PvpTeamAnnouncement, + ChatType.PvpTeamLoginLogout, + ChatType.GmParty, + ] + ), + ( + () => HellionStrings.Privacy_Group_FreeCompany, + [ + ChatType.FreeCompany, + ChatType.FreeCompanyAnnouncement, + ChatType.FreeCompanyLoginLogout, + ChatType.GmFreeCompany, + ] + ), + ( + () => HellionStrings.Privacy_Group_Linkshells, + [ + ChatType.Linkshell1, + ChatType.Linkshell2, + ChatType.Linkshell3, + ChatType.Linkshell4, + ChatType.Linkshell5, + ChatType.Linkshell6, + ChatType.Linkshell7, + ChatType.Linkshell8, + ChatType.GmLinkshell1, + ChatType.GmLinkshell2, + ChatType.GmLinkshell3, + ChatType.GmLinkshell4, + ChatType.GmLinkshell5, + ChatType.GmLinkshell6, + ChatType.GmLinkshell7, + ChatType.GmLinkshell8, + ] + ), + ( + () => HellionStrings.Privacy_Group_CrossLinkshells, + [ + ChatType.CrossLinkshell1, + ChatType.CrossLinkshell2, + ChatType.CrossLinkshell3, + ChatType.CrossLinkshell4, + ChatType.CrossLinkshell5, + ChatType.CrossLinkshell6, + ChatType.CrossLinkshell7, + ChatType.CrossLinkshell8, + ] + ), + ( + () => HellionStrings.Privacy_Group_ExtraChat, + [ + ChatType.ExtraChatLinkshell1, + ChatType.ExtraChatLinkshell2, + ChatType.ExtraChatLinkshell3, + ChatType.ExtraChatLinkshell4, + ChatType.ExtraChatLinkshell5, + ChatType.ExtraChatLinkshell6, + ChatType.ExtraChatLinkshell7, + ChatType.ExtraChatLinkshell8, + ] + ), + ( + () => HellionStrings.Privacy_Group_PublicChat, + [ + ChatType.Say, + ChatType.Shout, + ChatType.Yell, + ChatType.NoviceNetwork, + ChatType.NoviceNetworkSystem, + ChatType.CustomEmote, + ChatType.StandardEmote, + ChatType.GmSay, + ChatType.GmShout, + ChatType.GmYell, + ChatType.GmNoviceNetwork, + ] + ), + ( + () => HellionStrings.Privacy_Group_SystemLogs, + [ + ChatType.System, + ChatType.Notice, + ChatType.Urgent, + ChatType.Echo, + ChatType.NpcDialogue, + ChatType.NpcAnnouncement, + ChatType.LootNotice, + ChatType.LootRoll, + ChatType.RetainerSale, + ChatType.Crafting, + ChatType.Gathering, + ChatType.Sign, + ChatType.RandomNumber, + ChatType.MessageBook, + ChatType.Alarm, + ChatType.Orchestrion, + ChatType.GlamourNotifications, + ChatType.PeriodicRecruitmentNotification, + ChatType.GatheringSystem, + ChatType.Progress, + ChatType.Debug, + ChatType.Error, + ChatType.Item, + ChatType.Action, + ChatType.BattleSystem, + ChatType.Damage, + ChatType.Healing, + ChatType.Miss, + ChatType.GainBuff, + ChatType.GainDebuff, + ChatType.LoseBuff, + ChatType.LoseDebuff, + ] + ), + ]; +} diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index f2956b1..c57dec5 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -293,6 +293,11 @@ internal class HellionStrings internal static string Settings_Database_Storage_Heading => Get(nameof(Settings_Database_Storage_Heading)); internal static string Settings_Database_Viewer_Heading => Get(nameof(Settings_Database_Viewer_Heading)); internal static string Settings_Database_Stats_Heading => Get(nameof(Settings_Database_Stats_Heading)); + internal static string Settings_Database_Busy => Get(nameof(Settings_Database_Busy)); + internal static string Settings_Database_Op_RetentionSweep => Get(nameof(Settings_Database_Op_RetentionSweep)); + internal static string Settings_Database_Op_Export => Get(nameof(Settings_Database_Op_Export)); + internal static string Settings_Database_Op_Cleanup => Get(nameof(Settings_Database_Op_Cleanup)); + internal static string Settings_Database_Op_Clear => Get(nameof(Settings_Database_Op_Clear)); // Hellion Chat — Default tab presets (channel-themed) internal static string Tabs_Presets_System => Get(nameof(Tabs_Presets_System)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index f3dab9a..73d9adb 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1157,4 +1157,19 @@ Retorna aquesta pestanya a la finestra principal - \ No newline at end of file + + Hi ha una altra operació de base de dades en curs: {0} + + + neteja de retenció + + + exportació + + + neteja + + + esborrat de l'historial + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index cadaf6d..08fa793 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1156,4 +1156,19 @@ Vrátit tuto kartu do hlavního okna - \ No newline at end of file + + Právě probíhá jiná operace s databází: {0} + + + úklid podle doby uchování + + + export + + + úklid + + + mazání historie + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index 4adc356..9f028c9 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1156,4 +1156,19 @@ Send denne fane tilbage til hovedvinduet - \ No newline at end of file + + En anden databasehandling kører: {0} + + + oprydning efter opbevaringsregler + + + eksport + + + oprydning + + + sletning af historikken + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index c6dff2a..59b1471 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1151,4 +1151,19 @@ Diesen Tab ins Hauptfenster zurückholen - \ No newline at end of file + + Es läuft gerade eine andere Datenbankoperation: {0} + + + Aufbewahrungslauf + + + Export + + + Bereinigung + + + Löschen des Verlaufs + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index d282972..35e5dfb 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1156,4 +1156,19 @@ Επιστροφή αυτής της καρτέλας στο κύριο παράθυρο - \ No newline at end of file + + Εκτελείται ήδη μια άλλη λειτουργία βάσης δεδομένων: {0} + + + εκκαθάριση διατήρησης + + + εξαγωγή + + + εκκαθάριση + + + διαγραφή ιστορικού + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index bd5ae8a..ed433c9 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1157,4 +1157,19 @@ Devolver esta pestaña a la ventana principal - \ No newline at end of file + + Ya se está ejecutando otra operación de base de datos: {0} + + + limpieza de retención + + + exportación + + + limpieza + + + borrado del historial + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index cb9aeb9..f28a7ee 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1156,4 +1156,19 @@ Palauta tämä välilehti pääikkunaan - \ No newline at end of file + + Toinen tietokantatoiminto on käynnissä: {0} + + + säilytysajo + + + vienti + + + siivous + + + historian tyhjennys + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index 76947ba..f6a634f 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1157,4 +1157,19 @@ Renvoyer cet onglet vers la fenêtre principale - \ No newline at end of file + + Une autre opération de base de données est en cours : {0} + + + nettoyage de rétention + + + export + + + nettoyage + + + effacement de l'historique + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index e333950..2583443 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1156,4 +1156,19 @@ Lap visszahelyezése a főablakba - \ No newline at end of file + + Már fut egy másik adatbázisművelet: {0} + + + megőrzési takarítás + + + exportálás + + + takarítás + + + az előzmények törlése + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index 9d685db..a55e756 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1157,4 +1157,19 @@ Riporta questa scheda nella finestra principale - \ No newline at end of file + + È già in corso un'altra operazione sul database: {0} + + + pulizia di conservazione + + + esportazione + + + pulizia + + + cancellazione della cronologia + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index d5c382e..4fda299 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1157,4 +1157,19 @@ このタブをメインウィンドウに戻す - \ No newline at end of file + + 別のデータベース処理を実行中です: {0} + + + 保存期間の整理 + + + エクスポート + + + クリーンアップ + + + 履歴の削除 + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 136321f..ce3f4cf 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1157,4 +1157,19 @@ 이 탭을 기본 창으로 되돌리기 - \ No newline at end of file + + 다른 데이터베이스 작업이 실행 중입니다: {0} + + + 보존 기간 정리 + + + 내보내기 + + + 정리 + + + 기록 삭제 + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index 4f8d7df..b4e659c 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1156,4 +1156,19 @@ Send denne fanen tilbake til hovedvinduet - \ No newline at end of file + + En annen databaseoperasjon kjører: {0} + + + oppbevaringsopprydding + + + eksport + + + opprydding + + + sletting av historikken + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index cc90d4e..13ec0ac 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1157,4 +1157,19 @@ Dit tabblad terugzetten in het hoofdvenster - \ No newline at end of file + + Er wordt al een andere databasebewerking uitgevoerd: {0} + + + bewaartermijnopschoning + + + export + + + opschoning + + + wissen van de geschiedenis + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index b4a318d..783f642 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1156,4 +1156,19 @@ Przywróć tę kartę do okna głównego - \ No newline at end of file + + Trwa już inna operacja na bazie danych: {0} + + + porządkowanie według czasu przechowywania + + + eksport + + + porządkowanie + + + usuwanie historii + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index be06fb1..4f23d63 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1157,4 +1157,19 @@ Devolver esta aba à janela principal - \ No newline at end of file + + Outra operação de banco de dados está em andamento: {0} + + + limpeza de retenção + + + exportação + + + limpeza + + + exclusão do histórico + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 2fb94f0..07a382b 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1156,4 +1156,19 @@ Devolver este separador à janela principal - \ No newline at end of file + + Está em curso outra operação de base de dados: {0} + + + limpeza de retenção + + + exportação + + + limpeza + + + eliminação do histórico + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index 3346cf6..fc01aec 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1177,4 +1177,19 @@ Return this tab to the main window - \ No newline at end of file + + Another database operation is running: {0} + + + retention sweep + + + export + + + cleanup + + + clearing the history + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index dbf9ea2..fc18940 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1157,4 +1157,19 @@ Readu această filă în fereastra principală - \ No newline at end of file + + Rulează deja o altă operațiune pe baza de date: {0} + + + curățarea după perioada de păstrare + + + export + + + curățare + + + ștergerea istoricului + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index 4519c3e..2ea927c 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1157,4 +1157,19 @@ Вернуть эту вкладку в главное окно - \ No newline at end of file + + Уже выполняется другая операция с базой данных: {0} + + + очистка по сроку хранения + + + экспорт + + + очистка + + + удаление истории + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index c2fffbc..3c3e042 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1157,4 +1157,19 @@ Återför den här fliken till huvudfönstret - \ No newline at end of file + + En annan databasåtgärd pågår: {0} + + + gallring enligt lagringstid + + + export + + + rensning + + + radering av historiken + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index fff6706..587bd23 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1156,4 +1156,19 @@ Bu sekmeyi ana pencereye geri al - \ No newline at end of file + + Başka bir veritabanı işlemi çalışıyor: {0} + + + saklama temizliği + + + dışa aktarma + + + temizlik + + + geçmişin silinmesi + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index d4e3acf..06c1328 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1156,4 +1156,19 @@ Повернути цю вкладку до головного вікна - \ No newline at end of file + + Уже виконується інша операція з базою даних: {0} + + + очищення за строком зберігання + + + експорт + + + очищення + + + видалення історії + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index fddbc58..c74a6dc 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1157,4 +1157,19 @@ 将此标签页放回主窗口 - \ No newline at end of file + + 另一项数据库操作正在进行:{0} + + + 保留期清理 + + + 导出 + + + 清理 + + + 清空历史记录 + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index 87f88c3..ee01999 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1157,4 +1157,19 @@ 將此分頁移回主視窗 - \ No newline at end of file + + 另一項資料庫作業正在進行:{0} + + + 保留期清理 + + + 匯出 + + + 清理 + + + 清除歷史紀錄 + + \ No newline at end of file diff --git a/HellionChat/Ui/Components/Settings/SettingsWidgets.cs b/HellionChat/Ui/Components/Settings/SettingsWidgets.cs index 826ec2d..bb15801 100644 --- a/HellionChat/Ui/Components/Settings/SettingsWidgets.cs +++ b/HellionChat/Ui/Components/Settings/SettingsWidgets.cs @@ -1,5 +1,6 @@ using System.Numerics; using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Utility.Raii; using HellionChat.Themes; using HellionChat.Ui.StyleEngine; using HellionChat.Ui.StyleEngine.Widgets; @@ -242,6 +243,119 @@ internal sealed class SettingsWidgets _plugin.SaveConfig(); } + // Transient rows for form state that never reaches the config: an export + // filter, a cleanup preview. They hand the value back instead of taking a + // setter, and they do not call SaveConfig -- there is nothing to save, and + // writing the config file on every keystroke of a sender filter would be + // both pointless and slow. + internal bool ToggleRow(uint id, string label, string? description, bool value) + { + EnsureFrame(); + var hit = false; + + var rowClicked = SettingRow.Draw( + id, + label, + description, + _row, + ctx => + { + var size = ToggleSwitch.CalcSize(); + var pos = ctx.AlignRight(size); + ImGui.SetCursorScreenPos(pos); + if (ImGui.InvisibleButton($"##hc-sw-{id}", size)) + hit = true; + ToggleSwitch.Draw(id, pos, value, _toggle); + } + ); + + return rowClicked || hit ? !value : value; + } + + internal string TextRow(uint id, string label, string? description, string value) + { + EnsureFrame(); + var current = value; + SettingRow.Draw( + id, + label, + description, + _row, + ctx => + { + // PushId rather than an interpolated label: the binding only + // offers a ref-string InputText for a literal label, and the ID + // stack separates the rows just as well. RAII because a throw + // inside InputText would otherwise leave the stack unbalanced + // and trip the assert in End(). + using var scope = ImRaii.PushId((int)id); + ImGui.SetNextItemWidth(ctx.ControlWidth); + // 511, not 512: the binding reserves maxLength + 1 and rents from + // the array pool once that reaches 512. + ImGui.InputText("##hc-tr", ref current, 511); + } + ); + return current; + } + + internal int SliderIntRow( + uint id, + string label, + string? description, + int value, + int min, + int max + ) + { + EnsureFrame(); + var current = value; + SettingRow.Draw( + id, + label, + description, + _row, + ctx => + { + ImGui.SetNextItemWidth(ctx.ControlWidth); + ImGui.SliderInt($"##hc-si-{id}", ref current, min, max, "%d"); + } + ); + return current; + } + + internal int SegmentRow( + uint id, + string label, + string? description, + string[] labels, + int selected + ) + { + EnsureFrame(); + + // Clamped rather than trusted: the generic overload derives the index + // from the value and falls back to 0, this one takes whatever the caller + // passes. Array.IndexOf returns -1 on a miss, and the caller then indexes + // its value array with the result. + var current = Math.Clamp(selected, 0, Math.Max(0, labels.Length - 1)); + var picked = current; + var colors = _segmented; + + SettingRow.Draw( + id, + label, + description, + _row, + ctx => + { + ImGui.SetCursorScreenPos(new Vector2(ctx.ControlOrigin.X, ctx.ControlOrigin.Y)); + picked = SegmentedControl.Draw(id, ctx.ControlWidth, labels, current, colors); + } + ); + + return picked; + } + internal void Toggle(string label, Func get, Action set) { var current = get(); diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs index 53e2d94..25578fd 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -1,18 +1,52 @@ using Dalamud.Bindings.ImGui; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.Utility.Raii; using HellionChat.Code; +using HellionChat.Export; +using HellionChat.Privacy; using HellionChat.Resources; using HellionChat.Util; +using Microsoft.Extensions.Logging; namespace HellionChat.Ui.Components.Settings.Tabs; internal sealed class DataPrivacyTab { private readonly Plugin _plugin; + private readonly ILogger _logger; private readonly SettingsWidgets _w; - public DataPrivacyTab(Plugin plugin, Ui.StyleEngine.TokenResolver resolver) + // Export form state, deliberately not in the config. A filter describes one + // action, not a preference; "last 7 days, sender Mira" reappearing three + // weeks later is a worse starting point than an empty form. + private int _exportRangeDays = 30; + private string _exportSender = string.Empty; + private readonly HashSet _exportChannels = []; + private ExportFormat _exportFormat = ExportFormat.Markdown; + + // Covers the file dialog too, not just the worker: without it a second click + // opens a second dialog, and the two workers then race for the gate so one + // of them reports a failure the user did not cause. + private bool _exportDialogOpen; + + // Written by the export thread, read by the draw thread every frame. + private volatile bool _exportRunning; + + private static readonly ExportFormat[] FormatValues = EnumValues.All; + + // Five years. The old form had no upper bound at all, and retention caps at + // 365 days, but a history kept forever outlives that by a lot. Ctrl+Click on + // the slider still types an exact value. + private const int MaxExportRangeDays = 1825; + + public DataPrivacyTab( + Plugin plugin, + Ui.StyleEngine.TokenResolver resolver, + ILogger logger + ) { _plugin = plugin; + _logger = logger; _w = new SettingsWidgets(plugin, new SettingsPalette(resolver)); } @@ -80,6 +114,17 @@ internal sealed class DataPrivacyTab DrawPrivacyPersistChannelsGrid(); } + if ( + _w.Section( + ImGui.GetID("privacy.export"u8), + HellionStrings.Settings_Section_Export, + open: false + ) + ) + { + DrawExportSection(); + } + if (_w.Section(ImGui.GetID("privacy.telemetry"u8), "Telemetry", open: false)) { // Read-only placeholder; no telemetry is wired in v1.7.0. Do not @@ -135,6 +180,274 @@ internal sealed class DataPrivacyTab } } + // GDPR Art. 15. The backend has been able to do this since v1.4.8; the form + // that drives it was removed with the old settings window in May, which left + // the promise in PRIVACY.md without a way to keep it. + private void DrawExportSection() + { + ImGuiUtil.HelpText(HellionStrings.Export_Help); + + _exportRangeDays = _w.SliderIntRow( + ImGui.GetID("privacy.export.range"u8), + HellionStrings.Export_Range_Label, + null, + _exportRangeDays, + 0, + MaxExportRangeDays + ); + + _exportSender = _w.TextRow( + ImGui.GetID("privacy.export.sender"u8), + HellionStrings.Export_Sender_Label, + null, + _exportSender + ); + + var picked = _w.SegmentRow( + ImGui.GetID("privacy.export.format"u8), + HellionStrings.Export_Format_Label, + null, + Array.ConvertAll(FormatValues, FormatLabel), + Array.IndexOf(FormatValues, _exportFormat) + ); + _exportFormat = FormatValues[picked]; + + DrawExportChannels(); + + // Read once: IsBusy and Current are two reads of the same volatile + // field, and between them the operation can finish -- which would print + // "another operation is running: " with nothing after the colon. + var current = _plugin.DbOperations.Current; + var blocked = _exportDialogOpen || _exportRunning || current != DbOperation.None; + + ImGui.Spacing(); + using (ImRaii.Disabled(blocked)) + { + if (ImGui.Button(HellionStrings.Export_Button)) + PromptExport(); + } + + if (_exportRunning) + ImGuiUtil.HelpText(HellionStrings.Export_Running); + else if (current != DbOperation.None) + ImGuiUtil.HelpText( + string.Format(HellionStrings.Settings_Database_Busy, OperationName(current)) + ); + } + + // Whole groups rather than sixty individual channels. Nobody makes a + // sixty-way choice, and the eight groups are the same ones the privacy + // wizard already uses, so the two screens describe the world the same way. + private void DrawExportChannels() + { + ImGui.Spacing(); + ImGui.TextUnformatted(HellionStrings.Export_Channels_Heading); + ImGuiUtil.HelpText(HellionStrings.Export_Channels_AllOff); + + for (var i = 0; i < ChannelGroups.All.Length; i++) + { + var (heading, types) = ChannelGroups.All[i]; + + // foreach rather than types.All(_exportChannels.Contains): an + // instance method group allocates a delegate on every frame. + // + // Invariant this relies on: the set only ever changes in whole + // groups, below. A half-filled group would read as off here and the + // toggle could then only complete it, never clear it. + var selected = true; + foreach (var type in types) + { + if (_exportChannels.Contains(type)) + continue; + selected = false; + break; + } + + var next = _w.ToggleRow( + ImGui.GetID($"privacy.export.group.{i}"), + heading(), + Preview(types), + selected + ); + + if (next == selected) + continue; + + foreach (var type in types) + { + if (next) + _exportChannels.Add(type); + else + _exportChannels.Remove(type); + } + } + } + + // First few channel names so a group heading is not the only thing a user + // has to go on. Names are localised and the language can change at runtime, + // so this is built per frame rather than cached. + private static string Preview(ChatType[] types) + { + const int Shown = 3; + var names = string.Join(", ", types.Take(Shown).Select(t => t.Name())); + return types.Length > Shown ? names + ", …" : names; + } + + // Mapped per value rather than by position: a segmented control takes its + // labels as an array, and pairing them by index would relabel every segment + // the day somebody reorders the enum. + private static string FormatLabel(ExportFormat format) => + format switch + { + ExportFormat.Markdown => HellionStrings.Export_Format_Markdown, + ExportFormat.Json => HellionStrings.Export_Format_Json, + ExportFormat.Csv => HellionStrings.Export_Format_Csv, + _ => format.ToString(), + }; + + private static string OperationName(DbOperation op) => + op switch + { + DbOperation.RetentionSweep => HellionStrings.Settings_Database_Op_RetentionSweep, + DbOperation.Export => HellionStrings.Settings_Database_Op_Export, + DbOperation.Cleanup => HellionStrings.Settings_Database_Op_Cleanup, + DbOperation.Clear => HellionStrings.Settings_Database_Op_Clear, + _ => string.Empty, + }; + + // The whole filter is captured here, not read again in the callback. The + // dialog is modal to itself but not to the settings window, so the format + // segment and the channel toggles stay live while it is open. + private void PromptExport() + { + var format = _exportFormat; + var types = + _exportChannels.Count > 0 ? _exportChannels.Select(t => (int)(ushort)t).ToList() : null; + DateTimeOffset? from = + _exportRangeDays > 0 ? DateTimeOffset.UtcNow.AddDays(-_exportRangeDays) : null; + var sender = string.IsNullOrWhiteSpace(_exportSender) ? null : _exportSender.Trim(); + + _exportDialogOpen = true; + + Plugin.FileDialogManager.SaveFileDialog( + HellionStrings.Export_Dialog_Title, + format.Filter(), + $"hellion-chat-export-{DateTimeOffset.Now:yyyyMMdd-HHmm}", + format.Extension(), + (ok, path) => + { + _exportDialogOpen = false; + if (ok && !string.IsNullOrWhiteSpace(path)) + StartExport(path, format, types, from, sender); + }, + null, + isModal: true + ); + } + + private void StartExport( + string path, + ExportFormat format, + List? types, + DateTimeOffset? from, + string? sender + ) + { + _exportRunning = true; + var filter = new MessageExporter.FilterDescription(types, from, null, sender); + + var worker = new Thread(() => + { + try + { + // Taken inside the thread, the way the retention sweep does it. + // Acquiring before Start would strand the gate for the rest of + // the session if thread creation failed, and the gate also holds + // back the unattended sweep. + // + // Refused rather than queued: by the time a sweep finishes, an + // export the user started minutes ago and forgot about would + // write a file nobody is waiting for any more. + if (!_plugin.DbOperations.TryBegin(DbOperation.Export)) + { + Notify( + string.Format( + HellionStrings.Settings_Database_Busy, + OperationName(_plugin.DbOperations.Current) + ), + NotificationType.Warning + ); + return; + } + + try + { + // Own connection, so the reader can stay open for the length + // of the write without sharing the primary one with + // UpsertMessage. + using var conn = _plugin.MessageManager.Store.OpenSecondaryConnection(); + using var rows = _plugin.MessageManager.Store.StreamForExport( + conn, + types, + from, + null + ); + + var written = MessageExporter.ExportToFile(path, format, rows, filter); + + if (written > 0) + Notify( + string.Format(HellionStrings.Export_Success, written, path), + NotificationType.Success + ); + else + Notify(HellionStrings.Export_Empty, NotificationType.Info); + } + finally + { + _plugin.DbOperations.End(); + } + } + catch (Exception e) + { + _logger.LogError(e, "Export failed"); + Notify(HellionStrings.Export_Error, NotificationType.Error); + } + finally + { + _exportRunning = false; + } + }) + { + IsBackground = true, + Name = "HellionChat Export", + }; + + try + { + worker.Start(); + } + catch (Exception e) + { + // The thread never ran, so nothing will clear the flag for us. + _exportRunning = false; + _logger.LogError(e, "Could not start the export thread"); + WrapperUtil.AddNotification(HellionStrings.Export_Error, NotificationType.Error); + } + } + + // The export thread outlives a plugin teardown -- it is a background thread + // with no cancellation path, and finishing the file the user asked for is + // the right call. Reporting it afterwards is not: the notification would be + // filed against a plugin that is already gone. + private void Notify(string message, NotificationType type) + { + if (_plugin.IsDisposing) + return; + + WrapperUtil.AddNotification(message, type); + } + private void DrawPrivacyPersistChannelsGrid() { // HashSet: iterate Enum.GetValues() for stable diff --git a/HellionChat/Util/DbOperationGate.cs b/HellionChat/Util/DbOperationGate.cs index da720aa..744f354 100644 --- a/HellionChat/Util/DbOperationGate.cs +++ b/HellionChat/Util/DbOperationGate.cs @@ -14,17 +14,17 @@ internal enum DbOperation // frame. Generalises the retention-sweep lock, which already did exactly this // for a single case. // -// The reason it has to cover all of them together, not one each: an export can -// leave a reader open on the primary connection outside _readLock, because -// StreamForExport hands back an enumerator its caller consumes lazily. A VACUUM -// starting while that reader lives fails immediately with "cannot VACUUM - SQL -// statements in progress". +// The reason it has to cover all of them together, not one each: an export holds +// a reader open for as long as it writes, and VACUUM needs the database to +// itself. Since v1.12.0 that reader sits on its own connection, so the clash +// surfaces as SQLITE_BUSY and a five-second timeout rather than the immediate +// SQLITE_ERROR a shared connection produced -- but a VACUUM that gives up after +// five seconds still fails, and it fails after the DELETE has committed. The +// rows are gone and the file is not compacted. PerformMaintenance runs VACUUM, +// REINDEX and ANALYZE as one batch, so the latter two never run either. // -// That is SQLITE_ERROR, not SQLITE_BUSY, so no timeout applies and no retry -// helps -- busy handling only covers contention between different connections. -// And it fails after the DELETE has committed, so the rows are gone and the file -// is not compacted. PerformMaintenance runs VACUUM, REINDEX and ANALYZE as one -// batch, so the latter two never run either. +// Serialising the operations removes the question instead of tuning timeouts +// around it. // // Pure state machine, no ImGui and no database, so the build suite can pin the // transitions without standing up either. From 94af81a9615b67548d691d30b0cf03739c19c14f Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 21:32:02 +0200 Subject: [PATCH 104/129] fix(privacy): let the channel grid actually decide what is stored IsAllowedForStorage applied the unknown-channel failsafe to known channels too. Untick Say in the grid, leave "Save unknown channel types" on, and Say kept being written -- and that failsafe is on by default. So a config that never met the wizard ran with the filter enabled, an empty list and the failsafe on, which stored everything while the filter's own description promised "only messages from allowed channels are written to the database". The grid was inert for exactly the users who had not been walked through the wizard. The rule now reads: on the list, or unknown and the failsafe allows it. A known channel the user did not pick stays out. That correction alone would turn "stores everything" into "stores nothing" for those same configs, so two things move with it: - Config v24 switches the filter off where it was on with nothing picked. Same behaviour as before, stated where the user can see it, and one line in the log saying so. A config that does have picks keeps them and starts honouring them, which is the point of the change. - A fresh config seeds the list from PrivacyFirstWhitelist instead of starting empty. Privacy by Default was already the documented intent; it just relied on the hole to stay usable. The rule lives in its own type now. Configuration implements a Dalamud interface, and the build suite cannot load Dalamud.dll -- the runtime resolves the declaring type before reaching the method body, so even a static call on it fails. Fifteen cases pin the truth table and the migration condition; the self-test checks the running config is not in the state the migration exists to undo. --- HellionChat/Configuration.cs | 29 ++++++++------- HellionChat/Plugin.cs | 30 ++++++++++++++-- HellionChat/Privacy/StorageRule.cs | 32 +++++++++++++++++ ...onV23Step.cs => ConfigMigrationV24Step.cs} | 36 +++++++++++++------ 4 files changed, 103 insertions(+), 24 deletions(-) create mode 100644 HellionChat/Privacy/StorageRule.cs rename HellionChat/SelfTests/{ConfigMigrationV23Step.cs => ConfigMigrationV24Step.cs} (57%) diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index b5b9c78..5720e0b 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -35,7 +35,7 @@ public class ConfigKeyBind [Serializable] public class Configuration : IPluginConfiguration { - internal const int LatestVersion = 23; + internal const int LatestVersion = 24; public int Version { get; set; } = LatestVersion; @@ -59,8 +59,15 @@ public class Configuration : IPluginConfiguration // Privacy by Default master switch. Set false to restore upstream behaviour. public bool PrivacyFilterEnabled = true; - // Empty set means the migration has not run yet — see Plugin.cs v6→v7. - public HashSet PrivacyPersistChannels = []; + // Privacy by Default (DSGVO Art. 25): a config that never met the wizard + // records the player's own conversations and nothing else. Before v1.12.0 + // this started empty, which was harmless only because the failsafe below + // overrode it and stored everything anyway. With the corrected rule an empty + // list means an empty database, so the default has to state the intent. + public HashSet PrivacyPersistChannels = + [ + .. Privacy.PrivacyDefaults.PrivacyFirstWhitelist, + ]; // Failsafe for ChatTypes added by future FFXIV patches. New configs default // to the failsafe via PrivacyDefaults; existing configs keep their saved @@ -84,17 +91,15 @@ public class Configuration : IPluginConfiguration // same set from the draw thread. A HashSet.Contains racing an Add that // resizes buckets can return the wrong answer -- and this answer decides // whether a message is persisted. Lock kept tight, this is a hot path. - bool persists; + bool listed; lock (Plugin.Instance.ConfigMapsLock) - persists = PrivacyPersistChannels.Contains(type); - if (persists) - return true; + listed = PrivacyPersistChannels.Contains(type); + + var known = Enum.IsDefined(typeof(ChatType), type); // F3.2: log first occurrence of a ChatType the running build doesn't - // recognise — i.e. one a future FFXIV patch may have added. Known - // types the user opted out of are routed through the failsafe - // silently, like before. - if (!Enum.IsDefined(typeof(ChatType), type) && _warnedUnknownChannels.Add(type)) + // recognise — i.e. one a future FFXIV patch may have added. + if (!known && !listed && _warnedUnknownChannels.Add(type)) { Plugin.LogProxy.Warning( "PrivacyFilter: unrecognised ChatType {Type} — falling back to PrivacyPersistUnknownChannels={Persist}.", @@ -103,7 +108,7 @@ public class Configuration : IPluginConfiguration ); } - return PrivacyPersistUnknownChannels; + return Privacy.StorageRule.Allows(listed, known, PrivacyPersistUnknownChannels); } // Retention master switch defaults to false — plugin will not delete diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index d77c654..ba894ff 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -283,7 +283,33 @@ public sealed class Plugin : IAsyncDalamudPlugin { Config.MainWindowLayoutMode = MainWindowLayoutMode.TopTabs; } - Config.Version = 23; + + // v24 migration: the privacy filter used to route a known but unticked + // channel through the unknown-type failsafe, so the channel grid was + // inert whenever that failsafe was on. Corrected in v1.12.0. A config + // that never picked a channel was storing everything through that hole, + // and the corrected rule would store nothing at all -- so the intent is + // carried forward as a filter that is honestly switched off. + if ( + Config.Version < 24 + && Privacy.StorageRule.ShouldDisableFilterOnV24( + Config.PrivacyFilterEnabled, + Config.PrivacyPersistUnknownChannels, + Config.PrivacyPersistChannels.Count + ) + ) + { + Config.PrivacyFilterEnabled = false; + // Log, not LogProxy: this runs in Phase-0 and the proxy is only + // resolved from the container further down. + Log.Information( + "Privacy filter switched off during the v24 migration: it was on with no channels " + + "picked, which stored everything through the unknown-channel failsafe. Pick " + + "channels in Settings to switch it back on." + ); + } + + Config.Version = 24; // Unpinned TempTabs are session-only and dropped on every load. Pinned // TempTabs survive reload — Jin's tester feedback (v1.4.7). @@ -444,7 +470,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.SettingsWindowOpenStep(this), new SelfTests.OnOpenMainUiRoutesMainWindowStep(this), new SelfTests.TypingIpcStateStep(this), - new SelfTests.ConfigMigrationV23Step(this), + new SelfTests.ConfigMigrationV24Step(this), new SelfTests.ChannelPopoutBindStep(this), new SelfTests.HoverStateFootprintStep(), new SelfTests.HonorificHeaderRenderStep(this), diff --git a/HellionChat/Privacy/StorageRule.cs b/HellionChat/Privacy/StorageRule.cs new file mode 100644 index 0000000..5fb561f --- /dev/null +++ b/HellionChat/Privacy/StorageRule.cs @@ -0,0 +1,32 @@ +namespace HellionChat.Privacy; + +// The rule that decides whether a message is written to disk, as plain logic. +// +// It lives apart from Configuration because Configuration implements a Dalamud +// interface, and the build suite cannot load Dalamud.dll -- the runtime resolves +// the declaring type before it ever reaches the method body, so even a static +// call on it fails. This is the single most consequential branch in the plugin, +// and it belongs where it can be pinned. +internal static class StorageRule +{ + // v1.12.0 corrected the last term. A known channel the user had unticked + // used to fall through to the unknown-type failsafe, so the channel grid did + // nothing at all whenever that failsafe was on. It is on by default, so the + // filter stored everything while its own description promised "only messages + // from allowed channels are written to the database". + internal static bool Allows(bool listed, bool knownType, bool persistUnknownTypes) => + listed || (!knownType && persistUnknownTypes); + + // Carry-over for configs written before that correction. Where the failsafe + // made the list irrelevant and no channel was ever picked, the old rule + // stored everything; the corrected rule would store nothing. Switching the + // filter off keeps the behaviour and states it where the user can see it. + // + // A config that does have picks keeps them and starts honouring them, which + // is the point of the change. + internal static bool ShouldDisableFilterOnV24( + bool filterEnabled, + bool persistUnknownTypes, + int listedCount + ) => filterEnabled && persistUnknownTypes && listedCount == 0; +} diff --git a/HellionChat/SelfTests/ConfigMigrationV23Step.cs b/HellionChat/SelfTests/ConfigMigrationV24Step.cs similarity index 57% rename from HellionChat/SelfTests/ConfigMigrationV23Step.cs rename to HellionChat/SelfTests/ConfigMigrationV24Step.cs index 48a0f62..1d44dee 100644 --- a/HellionChat/SelfTests/ConfigMigrationV23Step.cs +++ b/HellionChat/SelfTests/ConfigMigrationV24Step.cs @@ -3,25 +3,41 @@ using Dalamud.Plugin.SelfTest; namespace HellionChat.SelfTests; -// Pins the post-migration shape of the v23 config. By /xlperf time the schema -// gate has already stamped Config.Version = 23 and run the SidebarTabView→ -// TopTabs migration, so MainWindowLayoutMode must carry a valid value here. -// This probe never rewrites config; the actual migration (false → TopTabs) is -// load-time and verified by the prepared-config smoke in the plan. -internal sealed class ConfigMigrationV23Step : ISelfTestStep +// Pins the post-migration shape of the config. By /xlperf time the schema gate +// has already stamped Config.Version and run both migrations, so the fields +// below must carry valid values here. This probe never rewrites config; the +// migrations themselves are load-time and verified by the prepared-config smoke +// in the plan. +internal sealed class ConfigMigrationV24Step : ISelfTestStep { - public ConfigMigrationV23Step(Plugin plugin) + public ConfigMigrationV24Step(Plugin plugin) { _ = plugin; } - public string Name => "Hellion Chat - Config v23 migration"; + public string Name => "Hellion Chat - Config v24 migration"; public SelfTestStepResult RunStep() { - if (Plugin.Config.Version != 23) + if (Plugin.Config.Version != 24) { - ImGui.Text($"Config.Version is {Plugin.Config.Version}, expected 23"); + ImGui.Text($"Config.Version is {Plugin.Config.Version}, expected 24"); + return SelfTestStepResult.Fail; + } + + // The state the v24 migration exists to prevent: filter on, failsafe on, + // nothing picked. Under the corrected rule that combination stores no + // messages at all, so reaching /xlperf in it means the migration did not + // run. + if ( + Privacy.StorageRule.ShouldDisableFilterOnV24( + Plugin.Config.PrivacyFilterEnabled, + Plugin.Config.PrivacyPersistUnknownChannels, + Plugin.Config.PrivacyPersistChannels.Count + ) + ) + { + ImGui.Text("Privacy filter is on with no channels picked — v24 migration did not run"); return SelfTestStepResult.Fail; } From 1ab7ba83771850a32557689c4f8084ebab4d1b55 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 21:38:34 +0200 Subject: [PATCH 105/129] feat(privacy): reconnect the retroactive cleanup The privacy filter only decides what gets written from now on. Whatever was stored before the user narrowed their channels stays there until something removes it, and that something has had no button since May. Two rules shape the section, both because this deletes history and cannot be undone: - Without a preview the apply button does not exist. Not greyed out, absent. A disabled button is something a user waits for; a missing one is something they have to go and earn. - A preview that no longer matches the settings counts as no preview. The old version only recoloured the number and left the button live, so a changed whitelist could be applied against counts computed for the previous one. The mapping from the live rule to CleanupRetainOnly is the part worth reading twice. CleanupRetainOnly takes one set and deletes everything else, so it can only stand in for the live rule where that rule narrows something: filter off means nothing is filtered, and an empty list means a full wipe, which has its own button and its own confirmation. Both cases now say so instead of offering a destructive action that does not mean what it looks like. Inside that, the allowlist is the whitelist itself plus any stored channel this build does not recognise while the unknown-channel failsafe is on. Deriving it from the counts instead would delete messages that arrive on a whitelisted but currently empty channel between the preview and the apply, and dropping the unrecognised ones would defeat the failsafe, which exists to hold on to a new patch's channel until the user has decided about it. The preview runs on a worker over its own connection. It is a GROUP BY across every stored row, the old version ran it inline on the draw thread, and holding the read lock for it would stall UpsertMessage on the framework thread for the length of the scan. Cleanup_Help_SavedNote stays unused: it tells the reader to press Save first, and the window it was written for had a Save button. --- HellionChat/MessageStore.cs | 31 +- HellionChat/Privacy/StorageRule.cs | 19 + .../Resources/HellionStrings.Designer.cs | 2 + HellionChat/Resources/HellionStrings.ca.resx | 6 + HellionChat/Resources/HellionStrings.cs.resx | 6 + HellionChat/Resources/HellionStrings.da.resx | 6 + HellionChat/Resources/HellionStrings.de.resx | 6 + HellionChat/Resources/HellionStrings.el.resx | 6 + HellionChat/Resources/HellionStrings.es.resx | 6 + HellionChat/Resources/HellionStrings.fi.resx | 6 + HellionChat/Resources/HellionStrings.fr.resx | 6 + HellionChat/Resources/HellionStrings.hu.resx | 6 + HellionChat/Resources/HellionStrings.it.resx | 6 + HellionChat/Resources/HellionStrings.ja.resx | 6 + HellionChat/Resources/HellionStrings.ko.resx | 6 + HellionChat/Resources/HellionStrings.nb.resx | 6 + HellionChat/Resources/HellionStrings.nl.resx | 6 + HellionChat/Resources/HellionStrings.pl.resx | 6 + .../Resources/HellionStrings.pt-BR.resx | 6 + .../Resources/HellionStrings.pt-PT.resx | 6 + HellionChat/Resources/HellionStrings.resx | 6 + HellionChat/Resources/HellionStrings.ro.resx | 6 + HellionChat/Resources/HellionStrings.ru.resx | 6 + HellionChat/Resources/HellionStrings.sv.resx | 6 + HellionChat/Resources/HellionStrings.tr.resx | 6 + HellionChat/Resources/HellionStrings.uk.resx | 6 + .../Resources/HellionStrings.zh-Hans.resx | 6 + .../Resources/HellionStrings.zh-Hant.resx | 6 + .../Settings/Tabs/DataPrivacyTab.cs | 337 ++++++++++++++++++ 29 files changed, 524 insertions(+), 15 deletions(-) diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index 21c3bd0..2afee3b 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -499,24 +499,25 @@ internal class MessageStore : IDisposable // Returns a (ChatType, count) snapshot over non-deleted messages. // Used by the Privacy tab to preview retroactive cleanup impact. - internal Dictionary GetMessageCountsByChatType() + // + // Caller-owned connection, same reasoning as StreamForExport: this is a + // GROUP BY over every row, and holding _readLock for it would stall + // UpsertMessage on the framework thread for as long as the scan takes. + internal Dictionary GetMessageCountsByChatType(SqliteConnection conn) { - lock (_readLock) + var result = new Dictionary(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = + "SELECT ChatType, COUNT(*) FROM messages WHERE deleted = false GROUP BY ChatType;"; + cmd.CommandTimeout = 120; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) { - var result = new Dictionary(); - using var cmd = Connection.CreateCommand(); - cmd.CommandText = - "SELECT ChatType, COUNT(*) FROM messages WHERE deleted = false GROUP BY ChatType;"; - cmd.CommandTimeout = 120; - using var reader = cmd.ExecuteReader(); - while (reader.Read()) - { - var chatType = reader.GetInt32(0); - var count = reader.GetInt64(1); - result[chatType] = count; - } - return result; + var chatType = reader.GetInt32(0); + var count = reader.GetInt64(1); + result[chatType] = count; } + return result; } // Deletes messages older than the per-channel retention window, with a global diff --git a/HellionChat/Privacy/StorageRule.cs b/HellionChat/Privacy/StorageRule.cs index 5fb561f..8615364 100644 --- a/HellionChat/Privacy/StorageRule.cs +++ b/HellionChat/Privacy/StorageRule.cs @@ -29,4 +29,23 @@ internal static class StorageRule bool persistUnknownTypes, int listedCount ) => filterEnabled && persistUnknownTypes && listedCount == 0; + + // Why a retroactive cleanup cannot be offered, or that it can. + internal enum CleanupAvailability + { + Available, + + // Nothing is filtered, so nothing in the database contradicts the rule. + FilterDisabled, + + // The rule keeps no channel at all. CleanupRetainOnly refuses an empty + // allowlist on purpose -- that request is a full wipe, and a full wipe + // has its own button with its own confirmation. + NothingListed, + } + + internal static CleanupAvailability CleanupState(bool filterEnabled, int listedCount) => + !filterEnabled ? CleanupAvailability.FilterDisabled + : listedCount == 0 ? CleanupAvailability.NothingListed + : CleanupAvailability.Available; } diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index c57dec5..2ca6de7 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -60,6 +60,8 @@ internal class HellionStrings internal static string Privacy_PersistUnknown_Description => Get(nameof(Privacy_PersistUnknown_Description)); internal static string Cleanup_Heading => Get(nameof(Cleanup_Heading)); + internal static string Cleanup_Unavailable_FilterOff => Get(nameof(Cleanup_Unavailable_FilterOff)); + internal static string Cleanup_Unavailable_NothingListed => Get(nameof(Cleanup_Unavailable_NothingListed)); internal static string Cleanup_Help_Intro => Get(nameof(Cleanup_Help_Intro)); internal static string Cleanup_Help_SavedNote => Get(nameof(Cleanup_Help_SavedNote)); internal static string Cleanup_Preview_Stale => Get(nameof(Cleanup_Preview_Stale)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index 73d9adb..2095e6f 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1172,4 +1172,10 @@ esborrat de l'historial + + El filtre de privadesa està desactivat, així que es desa cada canal i res de la base de dades no contradiu la teva configuració. Activa el filtre i tria els canals primer. + + + No hi ha cap canal seleccionat, així que una neteja esborraria tot l'historial. Tria els canals que vols conservar, o fes servir el botó d'esborrar si de debò ho vols eliminar tot. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index 08fa793..42a7fdf 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1171,4 +1171,10 @@ mazání historie + + Filtr soukromí je vypnutý, takže se ukládá každý kanál a nic v databázi neodporuje vašemu nastavení. Nejprve filtr zapněte a vyberte kanály. + + + Není vybrán žádný kanál, takže úklid by smazal celou historii. Vyberte kanály, které chcete zachovat, nebo použijte tlačítko pro vymazání, pokud opravdu chcete smazat vše. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index 9f028c9..f4bd284 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1171,4 +1171,10 @@ sletning af historikken + + Privatlivsfilteret er slået fra, så alle kanaler gemmes, og intet i databasen strider mod dine indstillinger. Slå filteret til, og vælg kanaler først. + + + Ingen kanal er valgt, så en oprydning ville slette hele historikken. Vælg de kanaler, du vil beholde, eller brug sletteknappen, hvis du virkelig vil af med alt. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index 59b1471..54c4595 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1166,4 +1166,10 @@ Löschen des Verlaufs + + Der Datenschutzfilter ist aus, deshalb wird jeder Kanal gespeichert und nichts in der Datenbank widerspricht deinen Einstellungen. Schalte den Filter zuerst ein und wähle Kanäle aus. + + + Es ist kein Kanal ausgewählt, eine Bereinigung würde also den gesamten Verlauf löschen. Wähle die Kanäle aus, die du behalten willst, oder nimm den Löschen-Knopf, wenn wirklich alles weg soll. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index 35e5dfb..af91bc3 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1171,4 +1171,10 @@ διαγραφή ιστορικού + + Το φίλτρο απορρήτου είναι απενεργοποιημένο, άρα αποθηκεύεται κάθε κανάλι και τίποτα στη βάση δεδομένων δεν έρχεται σε αντίθεση με τις ρυθμίσεις σας. Ενεργοποιήστε πρώτα το φίλτρο και επιλέξτε κανάλια. + + + Δεν έχει επιλεγεί κανένα κανάλι, οπότε μια εκκαθάριση θα διέγραφε όλο το ιστορικό. Επιλέξτε τα κανάλια που θέλετε να κρατήσετε ή χρησιμοποιήστε το κουμπί διαγραφής αν θέλετε πραγματικά να φύγουν όλα. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index ed433c9..1ff51f0 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1172,4 +1172,10 @@ borrado del historial + + El filtro de privacidad está desactivado, así que se guarda cada canal y nada en la base de datos contradice tu configuración. Activa el filtro y elige canales primero. + + + No hay ningún canal seleccionado, así que una limpieza borraría todo el historial. Elige los canales que quieras conservar, o usa el botón de borrar si de verdad quieres eliminarlo todo. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index f28a7ee..b9a4d51 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1171,4 +1171,10 @@ historian tyhjennys + + Yksityisyyssuodatin on pois päältä, joten jokainen kanava tallennetaan eikä mikään tietokannassa ole ristiriidassa asetustesi kanssa. Kytke suodatin päälle ja valitse kanavat ensin. + + + Yhtään kanavaa ei ole valittu, joten siivous poistaisi koko historian. Valitse säilytettävät kanavat tai käytä tyhjennyspainiketta, jos haluat todella poistaa kaiken. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index f6a634f..0442588 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1172,4 +1172,10 @@ effacement de l'historique + + Le filtre de confidentialité est désactivé, donc chaque canal est enregistré et rien dans la base de données ne contredit vos réglages. Activez d'abord le filtre et choisissez des canaux. + + + Aucun canal n'est sélectionné, un nettoyage supprimerait donc tout l'historique. Choisissez les canaux à conserver, ou utilisez le bouton d'effacement si vous voulez vraiment tout supprimer. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index 2583443..083c79b 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1171,4 +1171,10 @@ az előzmények törlése + + Az adatvédelmi szűrő ki van kapcsolva, így minden csatorna mentésre kerül, és semmi sem mond ellent a beállításaidnak az adatbázisban. Előbb kapcsold be a szűrőt, és válassz csatornákat. + + + Nincs kiválasztva csatorna, így a takarítás a teljes előzményt törölné. Válaszd ki a megtartandó csatornákat, vagy használd a törlés gombot, ha tényleg mindent el akarsz távolítani. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index a55e756..89b963a 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1172,4 +1172,10 @@ cancellazione della cronologia + + Il filtro privacy è disattivato, quindi ogni canale viene salvato e nulla nel database contraddice le tue impostazioni. Attiva prima il filtro e scegli i canali. + + + Nessun canale è selezionato, quindi una pulizia cancellerebbe l'intera cronologia. Scegli i canali da conservare, oppure usa il pulsante di cancellazione se vuoi davvero eliminare tutto. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index 4fda299..e1329e1 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1172,4 +1172,10 @@ 履歴の削除 + + プライバシーフィルターがオフのため、すべてのチャンネルが保存されており、設定と矛盾するデータはありません。まずフィルターをオンにしてチャンネルを選んでください。 + + + チャンネルが一つも選ばれていないため、クリーンアップは履歴をすべて削除します。残したいチャンネルを選ぶか、本当にすべて消したい場合は削除ボタンを使ってください。 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index ce3f4cf..3344e1f 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1172,4 +1172,10 @@ 기록 삭제 + + 개인정보 필터가 꺼져 있어 모든 채널이 저장되며, 데이터베이스에 설정과 어긋나는 내용이 없습니다. 먼저 필터를 켜고 채널을 선택하세요. + + + 선택된 채널이 없어 정리를 실행하면 기록 전체가 삭제됩니다. 남길 채널을 선택하거나, 정말 모두 지우려면 삭제 버튼을 사용하세요. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index b4e659c..4d7cb89 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1171,4 +1171,10 @@ sletting av historikken + + Personvernfilteret er av, så alle kanaler lagres, og ingenting i databasen strider mot innstillingene dine. Slå på filteret og velg kanaler først. + + + Ingen kanal er valgt, så en opprydding ville slette hele historikken. Velg kanalene du vil beholde, eller bruk sletteknappen hvis du virkelig vil fjerne alt. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 13ec0ac..64c7335 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1172,4 +1172,10 @@ wissen van de geschiedenis + + Het privacyfilter staat uit, dus elk kanaal wordt opgeslagen en niets in de database spreekt je instellingen tegen. Zet het filter eerst aan en kies kanalen. + + + Er is geen kanaal geselecteerd, dus een opschoning zou de hele geschiedenis wissen. Kies de kanalen die je wilt bewaren, of gebruik de wisknop als je echt alles kwijt wilt. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index 783f642..ea72b2c 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1171,4 +1171,10 @@ usuwanie historii + + Filtr prywatności jest wyłączony, więc zapisywany jest każdy kanał i nic w bazie danych nie kłóci się z twoimi ustawieniami. Najpierw włącz filtr i wybierz kanały. + + + Nie wybrano żadnego kanału, więc porządkowanie usunęłoby całą historię. Wybierz kanały, które chcesz zachować, albo użyj przycisku czyszczenia, jeśli naprawdę chcesz usunąć wszystko. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index 4f23d63..dd698da 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1172,4 +1172,10 @@ exclusão do histórico + + O filtro de privacidade está desligado, então todos os canais são salvos e nada no banco de dados contradiz suas configurações. Ligue o filtro e escolha os canais primeiro. + + + Nenhum canal está selecionado, então uma limpeza apagaria todo o histórico. Escolha os canais que quer manter, ou use o botão de apagar se realmente quiser remover tudo. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 07a382b..10b880a 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1171,4 +1171,10 @@ eliminação do histórico + + O filtro de privacidade está desligado, por isso todos os canais são guardados e nada na base de dados contradiz as suas definições. Ligue primeiro o filtro e escolha os canais. + + + Não está selecionado nenhum canal, por isso uma limpeza apagaria todo o histórico. Escolha os canais que quer manter, ou use o botão de apagar se quiser mesmo remover tudo. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index fc01aec..aa47fe1 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1192,4 +1192,10 @@ clearing the history + + The privacy filter is off, so every channel is stored and nothing in the database contradicts your settings. Switch the filter on and pick channels first. + + + No channel is selected, so a cleanup would delete the entire history. Pick the channels you want to keep, or use the clear button if you really want everything gone. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index fc18940..6fc0885 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1172,4 +1172,10 @@ ștergerea istoricului + + Filtrul de confidențialitate este oprit, deci fiecare canal este salvat și nimic din baza de date nu contrazice setările tale. Pornește mai întâi filtrul și alege canalele. + + + Niciun canal nu este selectat, deci o curățare ar șterge tot istoricul. Alege canalele pe care vrei să le păstrezi sau folosește butonul de ștergere dacă chiar vrei să dispară tot. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index 2ea927c..597df69 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1172,4 +1172,10 @@ удаление истории + + Фильтр конфиденциальности выключен, поэтому сохраняются все каналы и ничто в базе данных не противоречит вашим настройкам. Сначала включите фильтр и выберите каналы. + + + Не выбран ни один канал, поэтому очистка удалит всю историю. Выберите каналы, которые хотите сохранить, или воспользуйтесь кнопкой удаления, если действительно хотите стереть всё. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index 3c3e042..ccd76bc 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1172,4 +1172,10 @@ radering av historiken + + Integritetsfiltret är avstängt, så alla kanaler sparas och inget i databasen strider mot dina inställningar. Slå på filtret och välj kanaler först. + + + Ingen kanal är vald, så en rensning skulle radera hela historiken. Välj de kanaler du vill behålla, eller använd raderingsknappen om du verkligen vill ta bort allt. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index 587bd23..9ad83af 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1171,4 +1171,10 @@ geçmişin silinmesi + + Gizlilik filtresi kapalı, bu yüzden her kanal saklanıyor ve veritabanında ayarlarınızla çelişen bir şey yok. Önce filtreyi açın ve kanalları seçin. + + + Hiçbir kanal seçili değil, bu yüzden temizlik tüm geçmişi siler. Saklamak istediğiniz kanalları seçin veya gerçekten her şeyin gitmesini istiyorsanız silme düğmesini kullanın. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 06c1328..2c74872 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1171,4 +1171,10 @@ видалення історії + + Фільтр приватності вимкнено, тому зберігаються всі канали і ніщо в базі даних не суперечить вашим налаштуванням. Спершу увімкніть фільтр і виберіть канали. + + + Не вибрано жодного каналу, тому очищення видалить усю історію. Виберіть канали, які хочете зберегти, або скористайтеся кнопкою видалення, якщо справді хочете стерти все. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index c74a6dc..9d5a775 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1172,4 +1172,10 @@ 清空历史记录 + + 隐私过滤器已关闭,因此所有频道都会保存,数据库中没有与设置冲突的内容。请先开启过滤器并选择频道。 + + + 未选择任何频道,因此清理会删除全部历史记录。请选择要保留的频道,若确实想全部删除,请使用清空按钮。 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index ee01999..64a1eb1 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1172,4 +1172,10 @@ 清除歷史紀錄 + + 隱私篩選器已關閉,因此所有頻道都會保存,資料庫中沒有與設定衝突的內容。請先開啟篩選器並選擇頻道。 + + + 未選擇任何頻道,因此清理會刪除全部歷史紀錄。請選擇要保留的頻道,若確實想全部刪除,請使用清除按鈕。 + \ No newline at end of file diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs index 25578fd..c1f1988 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -32,6 +32,31 @@ internal sealed class DataPrivacyTab // Written by the export thread, read by the draw thread every frame. private volatile bool _exportRunning; + // One immutable object, published in a single write. The preview is built on + // a worker and read by the draw thread, and a half-filled set of counters is + // exactly the kind of thing a user would act on. + private volatile CleanupPreview? _cleanupPreview; + private volatile bool _cleanupPreviewRunning; + private volatile bool _cleanupRunning; + + // What the preview was computed against. All three inputs decide the outcome, + // so all three decide whether it is still valid. + private sealed record CleanupPreview( + IReadOnlyList<(ChatType Type, long Count, bool Keep)> Rows, + long KeepCount, + long DeleteCount, + IReadOnlyCollection AllowedTypes, + HashSet Listed, + bool FilterEnabled, + bool PersistUnknown + ) + { + internal bool MatchesConfig() => + FilterEnabled == Plugin.Config.PrivacyFilterEnabled + && PersistUnknown == Plugin.Config.PrivacyPersistUnknownChannels + && Listed.SetEquals(Plugin.Config.PrivacyPersistChannels); + } + private static readonly ExportFormat[] FormatValues = EnumValues.All; // Five years. The old form had no upper bound at all, and retention caps at @@ -114,6 +139,17 @@ internal sealed class DataPrivacyTab DrawPrivacyPersistChannelsGrid(); } + if ( + _w.Section( + ImGui.GetID("privacy.cleanup"u8), + HellionStrings.Settings_Section_Cleanup, + open: false + ) + ) + { + DrawCleanupSection(); + } + if ( _w.Section( ImGui.GetID("privacy.export"u8), @@ -180,6 +216,307 @@ internal sealed class DataPrivacyTab } } + // The privacy filter only decides what gets written from now on. Everything + // stored before the user narrowed their channels stays until something goes + // and removes it, and that is what this does. + // + // Two rules shape the layout, both because this deletes history and cannot + // be undone: + // + // 1. Without a preview the apply button does not exist. Not greyed out -- + // absent. A disabled button is something a user waits for; a missing + // one is something they have to go and earn. + // 2. A preview that no longer matches the settings is the same as no + // preview. The old version only recoloured the number and left the + // button live, so a changed whitelist could be applied against counts + // computed for the previous one. + private void DrawCleanupSection() + { + ImGuiUtil.HelpText(HellionStrings.Cleanup_Help_Intro); + + var availability = StorageRule.CleanupState( + Plugin.Config.PrivacyFilterEnabled, + Plugin.Config.PrivacyPersistChannels.Count + ); + + if (availability != StorageRule.CleanupAvailability.Available) + { + ImGui.Spacing(); + ImGuiUtil.HelpText( + availability == StorageRule.CleanupAvailability.FilterDisabled + ? HellionStrings.Cleanup_Unavailable_FilterOff + : HellionStrings.Cleanup_Unavailable_NothingListed + ); + return; + } + + var current = _plugin.DbOperations.Current; + var busy = _cleanupPreviewRunning || _cleanupRunning || current != DbOperation.None; + + ImGui.Spacing(); + using (ImRaii.Disabled(busy)) + { + if (ImGui.Button(HellionStrings.Cleanup_RefreshPreview)) + StartCleanupPreview(); + } + + var preview = _cleanupPreview; + if (preview is null) + { + ImGuiUtil.HelpText(HellionStrings.Cleanup_NoPreview); + } + else if (!preview.MatchesConfig()) + { + ImGuiUtil.HelpText(HellionStrings.Cleanup_Preview_Stale); + } + else + { + DrawCleanupNumbers(preview); + + // Only reachable with a preview that still describes the current + // settings. Everything above returns before this point. + if (preview.DeleteCount > 0) + { + ImGui.Spacing(); + using (ImRaii.Disabled(busy)) + { + if ( + ImGuiUtil.CtrlShiftButton( + HellionStrings.Cleanup_Apply_Label, + string.Format(HellionStrings.Cleanup_Apply_Tooltip, preview.DeleteCount) + ) + ) + StartCleanup(preview); + } + } + } + + if (_cleanupRunning) + ImGuiUtil.HelpText(HellionStrings.Cleanup_Running); + else if (current != DbOperation.None) + ImGuiUtil.HelpText( + string.Format(HellionStrings.Settings_Database_Busy, OperationName(current)) + ); + } + + private void DrawCleanupNumbers(CleanupPreview preview) + { + ImGui.Spacing(); + ImGui.TextUnformatted( + string.Format( + HellionStrings.Cleanup_TotalStored, + preview.KeepCount + preview.DeleteCount + ) + ); + ImGui.TextUnformatted(string.Format(HellionStrings.Cleanup_WillKeep, preview.KeepCount)); + ImGui.TextUnformatted( + string.Format(HellionStrings.Cleanup_WillDelete, preview.DeleteCount) + ); + + using var tree = ImRaii.TreeNode(HellionStrings.Cleanup_Breakdown); + if (!tree.Success) + return; + + using var indent = ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false); + foreach (var (type, count, keep) in preview.Rows) + { + var marker = keep + ? HellionStrings.Cleanup_Marker_Keep + : HellionStrings.Cleanup_Marker_Delete; + ImGui.TextDisabled($"{marker} {type.Name()}: {count:N0}"); + } + } + + // On a worker: the count is a GROUP BY over every stored row, and the old + // version ran it inline on the draw thread. + private void StartCleanupPreview() + { + if (_cleanupPreviewRunning) + return; + + _cleanupPreviewRunning = true; + + // Snapshotted here, on the draw thread, so the worker cannot read the + // config while the settings UI is writing it. + var listed = new HashSet(Plugin.Config.PrivacyPersistChannels); + var filterEnabled = Plugin.Config.PrivacyFilterEnabled; + var persistUnknown = Plugin.Config.PrivacyPersistUnknownChannels; + + var worker = new Thread(() => + { + try + { + using var conn = _plugin.MessageManager.Store.OpenSecondaryConnection(); + var counts = _plugin.MessageManager.Store.GetMessageCountsByChatType(conn); + + var rows = new List<(ChatType, long, bool)>(counts.Count); + long keepCount = 0; + long deleteCount = 0; + + // The allowlist starts from the whitelist itself, not from what + // happens to be stored right now. A channel with zero messages + // at preview time can receive one before the apply lands, and + // deriving the list from the counts would delete it. + var allowed = new HashSet(listed.Select(t => (int)(ushort)t)); + + foreach (var (raw, count) in counts) + { + var type = (ChatType)(ushort)raw; + var known = Enum.IsDefined(type); + var keep = StorageRule.Allows(listed.Contains(type), known, persistUnknown); + + // A stored channel this build does not recognise survives if + // the failsafe says so. That failsafe exists to hold on to a + // new patch's channel until the user decides, and a cleanup + // that deleted it anyway would defeat exactly that. + if (keep) + { + keepCount += count; + allowed.Add(raw); + } + else + { + deleteCount += count; + } + + rows.Add((type, count, keep)); + } + + rows.Sort((a, b) => b.Item2.CompareTo(a.Item2)); + + _cleanupPreview = new CleanupPreview( + rows, + keepCount, + deleteCount, + allowed, + listed, + filterEnabled, + persistUnknown + ); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to compute cleanup preview"); + Notify(HellionStrings.Cleanup_PreviewError, NotificationType.Error); + } + finally + { + _cleanupPreviewRunning = false; + } + }) + { + IsBackground = true, + Name = "HellionChat Cleanup Preview", + }; + + try + { + worker.Start(); + } + catch (Exception e) + { + _cleanupPreviewRunning = false; + _logger.LogError(e, "Could not start the cleanup preview thread"); + WrapperUtil.AddNotification( + HellionStrings.Cleanup_PreviewError, + NotificationType.Error + ); + } + } + + private void StartCleanup(CleanupPreview preview) + { + if (_cleanupRunning) + return; + + // Carried from the preview the user actually read, not recomputed from + // the config. Between the frame that drew the number and the frame that + // took the click, nothing can have changed -- but the next revision of + // this method should not have to prove that again. + var allowed = preview.AllowedTypes; + if (allowed.Count == 0) + return; + + _cleanupRunning = true; + + var worker = new Thread(() => + { + try + { + if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup)) + { + Notify( + string.Format( + HellionStrings.Settings_Database_Busy, + OperationName(_plugin.DbOperations.Current) + ), + NotificationType.Warning + ); + return; + } + + try + { + var deleted = _plugin.MessageManager.Store.CleanupRetainOnly(allowed); + _logger.LogInformation($"Privacy cleanup: deleted {deleted} messages"); + + // The tabs still hold the rows that just left the database. + if ( + !Plugin + .Framework.Run(() => + { + _plugin.MessageManager.ClearAllTabs(); + _plugin.MessageManager.FilterAllTabs(); + }) + .Wait(TimeSpan.FromSeconds(5)) + ) + { + _logger.LogWarning( + "Privacy cleanup: framework refresh timed out after 5s." + ); + } + + Notify( + string.Format(HellionStrings.Cleanup_Success, deleted), + NotificationType.Success + ); + } + finally + { + _plugin.DbOperations.End(); + } + } + catch (Exception e) + { + _logger.LogError(e, "Privacy cleanup failed"); + Notify(HellionStrings.Cleanup_Error, NotificationType.Error); + } + finally + { + // Dropped either way: after a successful run the numbers describe + // a database that no longer exists, and after a failure they + // describe one nobody should act on. + _cleanupPreview = null; + _cleanupRunning = false; + } + }) + { + IsBackground = true, + Name = "HellionChat Cleanup", + }; + + try + { + worker.Start(); + } + catch (Exception e) + { + _cleanupRunning = false; + _logger.LogError(e, "Could not start the cleanup thread"); + WrapperUtil.AddNotification(HellionStrings.Cleanup_Error, NotificationType.Error); + } + } + // GDPR Art. 15. The backend has been able to do this since v1.4.8; the form // that drives it was removed with the old settings window in May, which left // the promise in PRIVACY.md without a way to keep it. From e24ea793027c22f0681178bd803eb6391f237197 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 21:47:50 +0200 Subject: [PATCH 106/129] feat(privacy): reconnect database maintenance and the manual retention run Two sections that had backends and no buttons. Database: path, size, WAL size, message count, and a clear button. The numbers refresh at most every five seconds and not at all while a long operation owns the store -- MessageCount takes the read lock, and asking for it during a VACUUM means waiting for the whole file to be rewritten, on the draw thread. The old version called ClearMessages straight from the draw thread, VACUUM included; it runs on a worker now. One line beyond the old layout sits above the clear button: how many messages are stored, and that exporting keeps a copy. Whoever is about to throw the history away should be told there is a way not to. The legacy Chat 2 files only get a block when they are actually on disk, and the advanced tools only appear when the section is expanded with Shift held. The message injector is not back: it was deleted with the tab and writing 10,000 fake messages into a user's real database is not something to rebuild on the way past. Retention: an "apply now" button, the running hint, and the last-run line, which v1.11.0 shipped as an English literal while both strings sat translated in all 25 languages. Plus reset-to-spec next to the existing clear-overrides, since the two answer different questions and both were already translated. Retention_Apply_Tooltip stays unused and gets a replacement. It ends with "Save your changes first", and the window it was written for had a Save button. Also here, found while wiring the manual trigger: DbOperationGate.End now takes the operation it releases. It used to reset blindly, on the reasoning that a worker must be able to release from a finally without knowing whether it acquired. That is backwards: a worker whose TryBegin was refused also runs its finally, and a blind reset there hands away the lock of whichever operation actually holds it. Worse than no gate, because the refused worker walks off believing it did nothing while a VACUUM starts under somebody's open reader. --- HellionChat/Plugin.cs | 126 ++++-- .../Resources/HellionStrings.Designer.cs | 2 + HellionChat/Resources/HellionStrings.ca.resx | 6 + HellionChat/Resources/HellionStrings.cs.resx | 6 + HellionChat/Resources/HellionStrings.da.resx | 6 + HellionChat/Resources/HellionStrings.de.resx | 6 + HellionChat/Resources/HellionStrings.el.resx | 6 + HellionChat/Resources/HellionStrings.es.resx | 6 + HellionChat/Resources/HellionStrings.fi.resx | 6 + HellionChat/Resources/HellionStrings.fr.resx | 6 + HellionChat/Resources/HellionStrings.hu.resx | 6 + HellionChat/Resources/HellionStrings.it.resx | 6 + HellionChat/Resources/HellionStrings.ja.resx | 6 + HellionChat/Resources/HellionStrings.ko.resx | 6 + HellionChat/Resources/HellionStrings.nb.resx | 6 + HellionChat/Resources/HellionStrings.nl.resx | 6 + HellionChat/Resources/HellionStrings.pl.resx | 6 + .../Resources/HellionStrings.pt-BR.resx | 6 + .../Resources/HellionStrings.pt-PT.resx | 6 + HellionChat/Resources/HellionStrings.resx | 6 + HellionChat/Resources/HellionStrings.ro.resx | 6 + HellionChat/Resources/HellionStrings.ru.resx | 6 + HellionChat/Resources/HellionStrings.sv.resx | 6 + HellionChat/Resources/HellionStrings.tr.resx | 6 + HellionChat/Resources/HellionStrings.uk.resx | 6 + .../Resources/HellionStrings.zh-Hans.resx | 6 + .../Resources/HellionStrings.zh-Hant.resx | 6 + .../Settings/Tabs/DataPrivacyTab.cs | 402 +++++++++++++++++- HellionChat/Util/DbOperationGate.cs | 14 +- 29 files changed, 646 insertions(+), 48 deletions(-) diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index ba894ff..aa4bfaa 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -1010,6 +1010,22 @@ public sealed class Plugin : IAsyncDalamudPlugin if (DateTimeOffset.UtcNow - Config.RetentionLastRunAt < TimeSpan.FromHours(24)) return; + StartRetentionSweep(notify: false); + } + + // Shared by the daily check above and the manual button in settings. + // + // notify: the unattended sweep stays quiet, because a notification for + // something the user did not ask for at a moment they did not choose is + // noise. A run they pressed a button for reports back. + // + // Returns false when the store is already busy, so the caller can say so + // instead of leaving the user waiting for a run that never started. + internal bool StartRetentionSweep(bool notify) + { + if (DbOperations.IsBusy) + return false; + // Snapshot the policy so the user can edit settings while the sweep runs. var policy = new Dictionary(); foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays) @@ -1023,67 +1039,107 @@ public sealed class Plugin : IAsyncDalamudPlugin } var defaultDays = Config.RetentionDefaultDays; + _retentionSweepRunning = true; + // IsBackground = true so a stuck sweep never blocks plugin unload. - new Thread(() => + var worker = new Thread(() => { // Bails when anything else already owns the store, not only another // sweep: a user-triggered export or cleanup counts too. - if (!DbOperations.TryBegin(Util.DbOperation.RetentionSweep)) - return; - try { - var deleted = MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays); - Config.RetentionLastRunAt = DateTimeOffset.UtcNow; - SaveConfig(); + if (!DbOperations.TryBegin(Util.DbOperation.RetentionSweep)) + return; - if (deleted > 0) + try { - Log.Information($"Retention sweep deleted {deleted} expired messages."); - // Schedule on the next framework tick to avoid the ~194ms - // hitch from blocking with .Wait() while the frame finishes. - // The Config.Tabs enumeration in ClearAllTabs/FilterAllTabs is - // now guarded by the shared Plugin.TabsListLock (B3), so this - // tick scheduling is purely hitch-avoidance, not safety. - // Pattern reference: SimpleTweaks - // Tweaks/Chat/CaseInsensitiveCommands.cs:45. - Framework.RunOnTick(() => + var deleted = MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays); + Config.RetentionLastRunAt = DateTimeOffset.UtcNow; + SaveConfig(); + + if (notify) + Util.WrapperUtil.AddNotification( + string.Format(Resources.HellionStrings.Retention_Success, deleted), + Dalamud.Interface.ImGuiNotification.NotificationType.Success + ); + + if (deleted > 0) { - // The retention thread is IsBackground=true so plugin - // unload can fire while a scheduled tick is still - // pending; bail before touching anything torn down. - if (_isDisposing) - return; - try + Log.Information($"Retention sweep deleted {deleted} expired messages."); + // Schedule on the next framework tick to avoid the ~194ms + // hitch from blocking with .Wait() while the frame finishes. + // The Config.Tabs enumeration in ClearAllTabs/FilterAllTabs is + // now guarded by the shared Plugin.TabsListLock (B3), so this + // tick scheduling is purely hitch-avoidance, not safety. + // Pattern reference: SimpleTweaks + // Tweaks/Chat/CaseInsensitiveCommands.cs:45. + Framework.RunOnTick(() => { - MessageManager.ClearAllTabs(); - MessageManager.FilterAllTabs(); - } - catch (Exception ex) - { - Log.Error(ex, "Retention sweep clear+refilter failed"); - } - }); + // The retention thread is IsBackground=true so plugin + // unload can fire while a scheduled tick is still + // pending; bail before touching anything torn down. + if (_isDisposing) + return; + try + { + MessageManager.ClearAllTabs(); + MessageManager.FilterAllTabs(); + } + catch (Exception ex) + { + Log.Error(ex, "Retention sweep clear+refilter failed"); + } + }); + } + else + { + Log.Information("Retention sweep ran, nothing expired."); + } } - else + finally { - Log.Information("Retention sweep ran, nothing expired."); + DbOperations.End(Util.DbOperation.RetentionSweep); } } catch (Exception e) { Log.Error(e, "Retention sweep failed"); + if (notify) + Util.WrapperUtil.AddNotification( + Resources.HellionStrings.Retention_Error, + Dalamud.Interface.ImGuiNotification.NotificationType.Error + ); } finally { - DbOperations.End(); + _retentionSweepRunning = false; } }) { IsBackground = true, - }.Start(); + }; + + try + { + worker.Start(); + return true; + } + catch (Exception e) + { + // The thread never ran, so nothing will clear the flag for us. + _retentionSweepRunning = false; + Log.Error(e, "Could not start the retention sweep thread"); + return false; + } } + // Read by the settings tab every frame so the manual button can say a run is + // in progress. The gate itself cannot answer that: it goes busy only once + // the worker reaches TryBegin, which is after Start returns. + private volatile bool _retentionSweepRunning; + + internal bool RetentionSweepRunning => _retentionSweepRunning; + private void Draw() { // v1.9.0 B5: time the whole handler (style + font prologue included). diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index 2ca6de7..ca7653a 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -96,6 +96,7 @@ internal class HellionStrings internal static string Retention_Apply_Label => Get(nameof(Retention_Apply_Label)); internal static string Retention_Apply_Tooltip => Get(nameof(Retention_Apply_Tooltip)); internal static string Retention_Running => Get(nameof(Retention_Running)); + internal static string Retention_RunNow_Tooltip => Get(nameof(Retention_RunNow_Tooltip)); internal static string Retention_LastRun_Never => Get(nameof(Retention_LastRun_Never)); internal static string Retention_LastRun_At => Get(nameof(Retention_LastRun_At)); internal static string Retention_Success => Get(nameof(Retention_Success)); @@ -296,6 +297,7 @@ internal class HellionStrings internal static string Settings_Database_Viewer_Heading => Get(nameof(Settings_Database_Viewer_Heading)); internal static string Settings_Database_Stats_Heading => Get(nameof(Settings_Database_Stats_Heading)); internal static string Settings_Database_Busy => Get(nameof(Settings_Database_Busy)); + internal static string Settings_Database_ClearHint => Get(nameof(Settings_Database_ClearHint)); internal static string Settings_Database_Op_RetentionSweep => Get(nameof(Settings_Database_Op_RetentionSweep)); internal static string Settings_Database_Op_Export => Get(nameof(Settings_Database_Op_Export)); internal static string Settings_Database_Op_Cleanup => Get(nameof(Settings_Database_Op_Cleanup)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index 2095e6f..70f5adc 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1178,4 +1178,10 @@ No hi ha cap canal seleccionat, així que una neteja esborraria tot l'historial. Tria els canals que vols conservar, o fes servir el botó d'esborrar si de debò ho vols eliminar tot. + + Hi ha {0:N0} missatges desats. Si vols conservar-ne una còpia, exporta'ls abans d'esborrar. + + + Ctrl+Maj: executa la neteja de retenció ara mateix en comptes d'esperar el pas diari. Esborra els missatges més antics que els límits de dalt. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index 42a7fdf..58c1546 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1177,4 +1177,10 @@ Není vybrán žádný kanál, takže úklid by smazal celou historii. Vyberte kanály, které chcete zachovat, nebo použijte tlačítko pro vymazání, pokud opravdu chcete smazat vše. + + Uloženo je {0:N0} zpráv. Pokud si chcete ponechat kopii, před vymazáním je exportujte. + + + Ctrl+Shift: spustí úklid podle doby uchování hned, místo čekání na denní běh. Smaže zprávy starší než limity výše. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index f4bd284..777e968 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1177,4 +1177,10 @@ Ingen kanal er valgt, så en oprydning ville slette hele historikken. Vælg de kanaler, du vil beholde, eller brug sletteknappen, hvis du virkelig vil af med alt. + + Der er gemt {0:N0} beskeder. Vil du beholde en kopi, så eksportér dem før du sletter. + + + Ctrl+Shift: kører oprydningen med det samme i stedet for at vente på det daglige gennemløb. Sletter beskeder ældre end grænserne ovenfor. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index 54c4595..ece9b51 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1172,4 +1172,10 @@ Es ist kein Kanal ausgewählt, eine Bereinigung würde also den gesamten Verlauf löschen. Wähle die Kanäle aus, die du behalten willst, oder nimm den Löschen-Knopf, wenn wirklich alles weg soll. + + Es sind {0:N0} Nachrichten gespeichert. Wenn du eine Kopie behalten willst, exportiere sie vor dem Löschen. + + + Strg+Umschalt: führt den Aufbewahrungslauf sofort aus, statt auf den täglichen Durchlauf zu warten. Löscht Nachrichten, die älter sind als die Grenzen oben. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index af91bc3..51a6550 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1177,4 +1177,10 @@ Δεν έχει επιλεγεί κανένα κανάλι, οπότε μια εκκαθάριση θα διέγραφε όλο το ιστορικό. Επιλέξτε τα κανάλια που θέλετε να κρατήσετε ή χρησιμοποιήστε το κουμπί διαγραφής αν θέλετε πραγματικά να φύγουν όλα. + + Έχουν αποθηκευτεί {0:N0} μηνύματα. Αν θέλετε να κρατήσετε αντίγραφο, εξαγάγετέ τα πριν τη διαγραφή. + + + Ctrl+Shift: εκτελεί την εκκαθάριση διατήρησης αμέσως, χωρίς να περιμένει το ημερήσιο πέρασμα. Διαγράφει μηνύματα παλαιότερα από τα παραπάνω όρια. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index 1ff51f0..89660e9 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1178,4 +1178,10 @@ No hay ningún canal seleccionado, así que una limpieza borraría todo el historial. Elige los canales que quieras conservar, o usa el botón de borrar si de verdad quieres eliminarlo todo. + + Hay {0:N0} mensajes guardados. Si quieres conservar una copia, expórtalos antes de borrar. + + + Ctrl+Mayús: ejecuta la limpieza de retención ahora mismo en lugar de esperar al barrido diario. Borra los mensajes más antiguos que los límites de arriba. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index b9a4d51..743a5ac 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1177,4 +1177,10 @@ Yhtään kanavaa ei ole valittu, joten siivous poistaisi koko historian. Valitse säilytettävät kanavat tai käytä tyhjennyspainiketta, jos haluat todella poistaa kaiken. + + Tallennettuna on {0:N0} viestiä. Jos haluat säilyttää kopion, vie ne ennen tyhjennystä. + + + Ctrl+Vaihto: suorittaa säilytysajon heti sen sijaan, että odottaisi päivittäistä ajoa. Poistaa yllä olevia rajoja vanhemmat viestit. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index 0442588..e95fa64 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1178,4 +1178,10 @@ Aucun canal n'est sélectionné, un nettoyage supprimerait donc tout l'historique. Choisissez les canaux à conserver, ou utilisez le bouton d'effacement si vous voulez vraiment tout supprimer. + + {0:N0} messages sont enregistrés. Si vous voulez en garder une copie, exportez-les avant d'effacer. + + + Ctrl+Maj : lance le nettoyage de rétention immédiatement au lieu d'attendre le passage quotidien. Supprime les messages plus anciens que les limites ci-dessus. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index 083c79b..289949b 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1177,4 +1177,10 @@ Nincs kiválasztva csatorna, így a takarítás a teljes előzményt törölné. Válaszd ki a megtartandó csatornákat, vagy használd a törlés gombot, ha tényleg mindent el akarsz távolítani. + + {0:N0} üzenet van elmentve. Ha meg akarsz tartani egy másolatot, törlés előtt exportáld őket. + + + Ctrl+Shift: azonnal lefuttatja a megőrzési takarítást, nem várja meg a napi futást. Törli a fenti korlátoknál régebbi üzeneteket. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index 89b963a..254ee3f 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1178,4 +1178,10 @@ Nessun canale è selezionato, quindi una pulizia cancellerebbe l'intera cronologia. Scegli i canali da conservare, oppure usa il pulsante di cancellazione se vuoi davvero eliminare tutto. + + Ci sono {0:N0} messaggi salvati. Se vuoi conservarne una copia, esportali prima di cancellare. + + + Ctrl+Maiusc: esegue subito la pulizia di conservazione invece di aspettare il passaggio giornaliero. Cancella i messaggi più vecchi dei limiti sopra. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index e1329e1..ed0858a 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1178,4 +1178,10 @@ チャンネルが一つも選ばれていないため、クリーンアップは履歴をすべて削除します。残したいチャンネルを選ぶか、本当にすべて消したい場合は削除ボタンを使ってください。 + + {0:N0} 件のメッセージが保存されています。控えを残したい場合は、削除する前にエクスポートしてください。 + + + Ctrl+Shift: 毎日の処理を待たずに保存期間の整理をすぐ実行します。上の期限より古いメッセージを削除します。 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 3344e1f..52c99fe 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1178,4 +1178,10 @@ 선택된 채널이 없어 정리를 실행하면 기록 전체가 삭제됩니다. 남길 채널을 선택하거나, 정말 모두 지우려면 삭제 버튼을 사용하세요. + + {0:N0}개의 메시지가 저장되어 있습니다. 사본을 남기려면 삭제하기 전에 내보내세요. + + + Ctrl+Shift: 매일 실행을 기다리지 않고 보존 기간 정리를 지금 실행합니다. 위 기한보다 오래된 메시지를 삭제합니다. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index 4d7cb89..27f37ac 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1177,4 +1177,10 @@ Ingen kanal er valgt, så en opprydding ville slette hele historikken. Velg kanalene du vil beholde, eller bruk sletteknappen hvis du virkelig vil fjerne alt. + + {0:N0} meldinger er lagret. Vil du beholde en kopi, eksporter dem før du sletter. + + + Ctrl+Shift: kjører oppryddingen med en gang i stedet for å vente på det daglige gjennomløpet. Sletter meldinger eldre enn grensene over. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 64c7335..763cb82 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1178,4 +1178,10 @@ Er is geen kanaal geselecteerd, dus een opschoning zou de hele geschiedenis wissen. Kies de kanalen die je wilt bewaren, of gebruik de wisknop als je echt alles kwijt wilt. + + Er zijn {0:N0} berichten opgeslagen. Wil je een kopie houden, exporteer ze dan voordat je wist. + + + Ctrl+Shift: voert de opschoning nu meteen uit in plaats van te wachten op de dagelijkse ronde. Wist berichten ouder dan de limieten hierboven. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index ea72b2c..d719b9a 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1177,4 +1177,10 @@ Nie wybrano żadnego kanału, więc porządkowanie usunęłoby całą historię. Wybierz kanały, które chcesz zachować, albo użyj przycisku czyszczenia, jeśli naprawdę chcesz usunąć wszystko. + + Zapisanych jest {0:N0} wiadomości. Jeśli chcesz zachować kopię, wyeksportuj je przed wyczyszczeniem. + + + Ctrl+Shift: uruchamia porządkowanie od razu, zamiast czekać na codzienny przebieg. Usuwa wiadomości starsze niż limity powyżej. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index dd698da..d2e5b25 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1178,4 +1178,10 @@ Nenhum canal está selecionado, então uma limpeza apagaria todo o histórico. Escolha os canais que quer manter, ou use o botão de apagar se realmente quiser remover tudo. + + Há {0:N0} mensagens salvas. Se quiser manter uma cópia, exporte-as antes de apagar. + + + Ctrl+Shift: executa a limpeza de retenção agora em vez de esperar a varredura diária. Apaga mensagens mais antigas que os limites acima. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 10b880a..34b4aae 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1177,4 +1177,10 @@ Não está selecionado nenhum canal, por isso uma limpeza apagaria todo o histórico. Escolha os canais que quer manter, ou use o botão de apagar se quiser mesmo remover tudo. + + Estão guardadas {0:N0} mensagens. Se quiser manter uma cópia, exporte-as antes de apagar. + + + Ctrl+Shift: executa a limpeza de retenção agora em vez de esperar a varredura diária. Apaga mensagens mais antigas do que os limites acima. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index aa47fe1..9a9583d 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1198,4 +1198,10 @@ No channel is selected, so a cleanup would delete the entire history. Pick the channels you want to keep, or use the clear button if you really want everything gone. + + {0:N0} messages are stored. If you want to keep a copy, export them before clearing. + + + Ctrl+Shift: runs the retention cleanup right now instead of waiting for the daily sweep. Deletes messages older than the limits above. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index 6fc0885..255a0d8 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1178,4 +1178,10 @@ Niciun canal nu este selectat, deci o curățare ar șterge tot istoricul. Alege canalele pe care vrei să le păstrezi sau folosește butonul de ștergere dacă chiar vrei să dispară tot. + + Sunt salvate {0:N0} mesaje. Dacă vrei să păstrezi o copie, exportă-le înainte de ștergere. + + + Ctrl+Shift: rulează curățarea acum, în loc să aștepte trecerea zilnică. Șterge mesajele mai vechi decât limitele de mai sus. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index 597df69..5fd3563 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1178,4 +1178,10 @@ Не выбран ни один канал, поэтому очистка удалит всю историю. Выберите каналы, которые хотите сохранить, или воспользуйтесь кнопкой удаления, если действительно хотите стереть всё. + + Сохранено {0:N0} сообщений. Если хотите оставить копию, экспортируйте их перед удалением. + + + Ctrl+Shift: выполняет очистку по сроку хранения сразу, не дожидаясь ежедневного прохода. Удаляет сообщения старше указанных выше пределов. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index ccd76bc..4c3e621 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1178,4 +1178,10 @@ Ingen kanal är vald, så en rensning skulle radera hela historiken. Välj de kanaler du vill behålla, eller använd raderingsknappen om du verkligen vill ta bort allt. + + {0:N0} meddelanden är sparade. Vill du behålla en kopia, exportera dem innan du raderar. + + + Ctrl+Skift: kör gallringen direkt i stället för att vänta på den dagliga körningen. Raderar meddelanden äldre än gränserna ovan. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index 9ad83af..15de60d 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1177,4 +1177,10 @@ Hiçbir kanal seçili değil, bu yüzden temizlik tüm geçmişi siler. Saklamak istediğiniz kanalları seçin veya gerçekten her şeyin gitmesini istiyorsanız silme düğmesini kullanın. + + {0:N0} ileti saklanıyor. Bir kopya tutmak istiyorsanız, silmeden önce dışa aktarın. + + + Ctrl+Shift: günlük taramayı beklemeden saklama temizliğini hemen çalıştırır. Yukarıdaki sınırlardan eski iletileri siler. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 2c74872..6c42f66 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1177,4 +1177,10 @@ Не вибрано жодного каналу, тому очищення видалить усю історію. Виберіть канали, які хочете зберегти, або скористайтеся кнопкою видалення, якщо справді хочете стерти все. + + Збережено {0:N0} повідомлень. Якщо хочете залишити копію, експортуйте їх перед видаленням. + + + Ctrl+Shift: виконує очищення за строком зберігання одразу, не чекаючи щоденного проходу. Видаляє повідомлення, старші за вказані вище межі. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index 9d5a775..8681759 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1178,4 +1178,10 @@ 未选择任何频道,因此清理会删除全部历史记录。请选择要保留的频道,若确实想全部删除,请使用清空按钮。 + + 已保存 {0:N0} 条消息。若想留一份副本,请在清空前先导出。 + + + Ctrl+Shift:立即执行保留期清理,无需等待每日运行。删除早于上方期限的消息。 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index 64a1eb1..2091c3d 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1178,4 +1178,10 @@ 未選擇任何頻道,因此清理會刪除全部歷史紀錄。請選擇要保留的頻道,若確實想全部刪除,請使用清除按鈕。 + + 已保存 {0:N0} 則訊息。若想留一份副本,請在清除前先匯出。 + + + Ctrl+Shift:立即執行保留期清理,無需等待每日執行。刪除早於上方期限的訊息。 + \ No newline at end of file diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs index c1f1988..8b459f1 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -39,6 +39,21 @@ internal sealed class DataPrivacyTab private volatile bool _cleanupPreviewRunning; private volatile bool _cleanupRunning; + // Database metadata, refreshed at most every five seconds. MessageCount and + // the file sizes are cheap on their own, but MessageCount takes the read + // lock, and a VACUUM holds that for the length of a full file rewrite. Once + // per frame would not be a stutter, it would be a still image. + private long _dbRefreshedAt; + private long _dbSize; + private long _dbLogSize; + private int _dbMessageCount; + private volatile bool _clearRunning; + + // Shift held while expanding the section. Keeps the developer tools out of + // the way without a permanent switch, the way upstream did it. + private bool _dbSectionWasOpen; + private bool _dbShowAdvanced; + // What the preview was computed against. All three inputs decide the outcome, // so all three decide whether it is still valid. private sealed record CleanupPreview( @@ -102,17 +117,24 @@ internal sealed class DataPrivacyTab ); // RetentionLastRunAt defaults to MinValue on a fresh install, which - // would render as "0001-01-01 00:00" and look like a bug; the "Never" + // would render as "0001-01-01 00:00" and look like a bug; the "never" // sentinel handles that. Disabling the sweep does NOT reset the // timestamp — the historical last-run value is kept as informational // carry-over until the next sweep updates it. - var lastRun = + // + // Both strings were already translated when v1.11.0 shipped this row + // with an English literal. + ImGui.TextDisabled( Plugin.Config.RetentionLastRunAt == DateTimeOffset.MinValue - ? "Never" - : Plugin.Config.RetentionLastRunAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"); - ImGui.TextDisabled($"Last run: {lastRun}"); + ? HellionStrings.Retention_LastRun_Never + : string.Format( + HellionStrings.Retention_LastRun_At, + Plugin.Config.RetentionLastRunAt.ToLocalTime() + ) + ); DrawRetentionOverrides(); + DrawManualRetentionRun(); } if ( @@ -161,6 +183,17 @@ internal sealed class DataPrivacyTab DrawExportSection(); } + var dbOpen = _w.Section( + ImGui.GetID("privacy.database"u8), + HellionStrings.Settings_Section_Database, + open: false + ); + if (dbOpen && !_dbSectionWasOpen) + _dbShowAdvanced = ImGui.GetIO().KeyShift; + _dbSectionWasOpen = dbOpen; + if (dbOpen) + DrawDatabaseSection(); + if (_w.Section(ImGui.GetID("privacy.telemetry"u8), "Telemetry", open: false)) { // Read-only placeholder; no telemetry is wired in v1.7.0. Do not @@ -204,7 +237,8 @@ internal sealed class DataPrivacyTab return; } - if (overrides.Count > 0 && ImGui.Button(HellionStrings.Retention_Clear_Overrides)) + var hasOverrides = overrides.Count > 0; + if (hasOverrides && ImGui.Button(HellionStrings.Retention_Clear_Overrides)) { // Same lock the sweep takes when it snapshots the policy, so a clear // cannot cut its enumeration short. @@ -214,6 +248,358 @@ internal sealed class DataPrivacyTab } _plugin.SaveConfig(); } + + // SameLine only when there is a button to sit beside; otherwise it would + // attach to the last channel row above. + if (hasOverrides) + ImGui.SameLine(); + + // Clearing drops every override and falls back to the global default; + // this puts the per-channel spec values back instead. Two different + // answers to "I have made a mess of this", and both were translated + // before either had a button. + if (ImGui.Button(HellionStrings.Retention_Reset_Button)) + { + lock (_plugin.ConfigMapsLock) + { + overrides.Clear(); + foreach (var (type, days) in PrivacyDefaults.DefaultRetentionDays) + overrides[type] = days; + } + _plugin.SaveConfig(); + } + + if (ImGui.IsItemHovered()) + ImGuiUtil.Tooltip(HellionStrings.Retention_Reset_Spec); + } + + // The daily sweep has run unattended since v1.4.8, but only ever on its own + // schedule: change a limit and the effect lands up to 24 hours later, with + // nothing on screen to say so. The lock this needed was already there, + // waiting for a caller. + private void DrawManualRetentionRun() + { + if (!Plugin.Config.RetentionEnabled) + return; + + ImGui.Spacing(); + + var running = _plugin.RetentionSweepRunning; + var current = _plugin.DbOperations.Current; + + using (ImRaii.Disabled(running || current != DbOperation.None)) + { + if ( + ImGuiUtil.CtrlShiftButton( + HellionStrings.Retention_Apply_Label, + HellionStrings.Retention_RunNow_Tooltip + ) + ) + { + // Refusal is reported here rather than from the worker: the + // sweep bails silently by design, and a button that does nothing + // without saying why is the thing this cycle exists to remove. + if (!_plugin.StartRetentionSweep(notify: true)) + WrapperUtil.AddNotification( + string.Format( + HellionStrings.Settings_Database_Busy, + OperationName(_plugin.DbOperations.Current) + ), + NotificationType.Warning + ); + } + } + + if (running) + ImGuiUtil.HelpText(HellionStrings.Retention_Running); + } + + private void DrawDatabaseSection() + { + RefreshDatabaseMetadata(); + + ImGuiUtil.HelpText( + string.Format(Language.Options_Database_Metadata_Path, MessageManager.DatabasePath()) + ); + if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) + { + ImGui.SetClipboardText(Path.GetDirectoryName(MessageManager.DatabasePath())); + WrapperUtil.AddNotification( + Language.Options_Database_Metadata_CopyConfigPathNotification, + NotificationType.Info + ); + } + + if (ImGui.IsItemHovered()) + { + ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); + ImGuiUtil.Tooltip(Language.Options_Database_Metadata_CopyConfigPath); + } + + ImGuiUtil.HelpText( + string.Format( + Language.Options_Database_Metadata_Size, + StringUtil.BytesToString(_dbSize) + ) + ); + ImGuiUtil.HelpText( + string.Format( + Language.Options_Database_Metadata_LogSize, + StringUtil.BytesToString(_dbLogSize) + ) + ); + ImGuiUtil.HelpText( + string.Format(Language.Options_Database_Metadata_MessageCount, _dbMessageCount) + ); + + ImGui.Spacing(); + + // Beyond the old layout: whoever is about to throw the history away + // should see how much of it there is and be told, in the same breath, + // that there is a way to keep a copy. + ImGuiUtil.HelpText( + string.Format(HellionStrings.Settings_Database_ClearHint, _dbMessageCount) + ); + + var current = _plugin.DbOperations.Current; + var busy = _clearRunning || current != DbOperation.None; + + using (ImRaii.Disabled(busy)) + { + if ( + ImGuiUtil.CtrlShiftButton( + Language.Options_ClearDatabase_Button, + Language.Options_ClearDatabase_Tooltip + ) + ) + StartClear(); + } + + if (!_clearRunning && current != DbOperation.None) + ImGuiUtil.HelpText( + string.Format(HellionStrings.Settings_Database_Busy, OperationName(current)) + ); + + DrawLegacyDatabaseBlock(); + + if (_dbShowAdvanced) + DrawAdvancedDatabaseBlock(busy); + } + + // Suspended while a long-running operation owns the store: MessageCount + // takes the read lock, and asking for it during a VACUUM means waiting for + // the whole file to be rewritten -- on the draw thread. + private void RefreshDatabaseMetadata() + { + if (_plugin.DbOperations.IsBusy || _clearRunning) + return; + + if (_dbRefreshedAt + 5_000 > Environment.TickCount64) + return; + + _dbSize = _plugin.MessageManager.Store.DatabaseSize(); + _dbLogSize = _plugin.MessageManager.Store.DatabaseLogSize(); + _dbMessageCount = _plugin.MessageManager.Store.MessageCount(); + _dbRefreshedAt = Environment.TickCount64; + } + + // The old version called ClearMessages straight from the draw thread, VACUUM + // and all. On a large database that is a frozen game for as long as SQLite + // needs to rewrite the file. + private void StartClear() + { + if (_clearRunning) + return; + + _clearRunning = true; + + var worker = new Thread(() => + { + try + { + if (!_plugin.DbOperations.TryBegin(DbOperation.Clear)) + { + Notify( + string.Format( + HellionStrings.Settings_Database_Busy, + OperationName(_plugin.DbOperations.Current) + ), + NotificationType.Warning + ); + return; + } + + try + { + _logger.LogWarning("Clearing messages from database"); + _plugin.MessageManager.Store.ClearMessages(); + + if ( + !Plugin + .Framework.Run(() => _plugin.MessageManager.ClearAllTabs()) + .Wait(TimeSpan.FromSeconds(5)) + ) + { + _logger.LogWarning("Clear: framework refresh timed out after 5s."); + } + + Notify(Language.Options_ClearDatabase_Success, NotificationType.Info); + } + finally + { + _plugin.DbOperations.End(DbOperation.Clear); + } + } + catch (Exception e) + { + _logger.LogError(e, "Clearing the database failed"); + Notify(Language.Options_ClearDatabase_Success, NotificationType.Error); + } + finally + { + // Both the counters and any cleanup preview describe a database + // that no longer exists. + _dbRefreshedAt = 0; + _cleanupPreview = null; + _clearRunning = false; + } + }) + { + IsBackground = true, + Name = "HellionChat Clear", + }; + + try + { + worker.Start(); + } + catch (Exception e) + { + _clearRunning = false; + _logger.LogError(e, "Could not start the clear thread"); + } + } + + // Chat 2 left these behind on migration. Drawn only when they are actually + // on disk, so the block does not sit there permanently telling most users + // about a file they have never had. + private void DrawLegacyDatabaseBlock() + { + var dir = Plugin.Interface.ConfigDirectory.FullName; + var old = new FileInfo(Path.Join(dir, "chat.db")); + var migrated = new FileInfo(Path.Join(dir, "chat-litedb.db")); + if (!old.Exists && !migrated.Exists) + return; + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + ImGui.TextUnformatted(Language.Options_Database_Old_Heading); + + if ( + !ImGuiUtil.CtrlShiftButton( + Language.Options_Database_Old_Delete, + Language.Options_Database_Old_Delete_Tooltip + ) + ) + return; + + try + { + if (old.Exists) + old.Delete(); + if (migrated.Exists) + migrated.Delete(); + WrapperUtil.AddNotification( + Language.Options_Database_Old_Delete_Success, + NotificationType.Success + ); + } + catch (Exception e) + { + _logger.LogError(e, "Unable to delete old database"); + WrapperUtil.AddNotification( + Language.Options_Database_Old_Delete_Error, + NotificationType.Error + ); + } + } + + // Untranslated on purpose: these are developer tools, and the labels name + // the methods they call. + private void DrawAdvancedDatabaseBlock(bool busy) + { + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + ImGui.TextUnformatted(Language.Options_Database_Advanced); + + using var wrap = ImRaii.TextWrapPos(0.0f); + ImGuiUtil.WarningText(Language.Options_Database_Advanced_Warning); + + using (ImRaii.Disabled(busy)) + { + if ( + ImGuiUtil.CtrlShiftButton( + "Perform maintenance", + "Ctrl+Shift: VACUUM, REINDEX and ANALYZE. Runs in the background." + ) + ) + StartMaintenance(); + } + + if ( + ImGuiUtil.CtrlShiftButton( + "Reload messages from database", + "Ctrl+Shift: MessageManager.FilterAllTabsAsync()" + ) + ) + { + _plugin.MessageManager.ClearAllTabs(); + _plugin.MessageManager.FilterAllTabsAsync(); + } + } + + private void StartMaintenance() + { + var worker = new Thread(() => + { + try + { + if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup)) + return; + + try + { + _plugin.MessageManager.Store.PerformMaintenance(); + } + finally + { + _plugin.DbOperations.End(DbOperation.Cleanup); + } + } + catch (Exception e) + { + _logger.LogError(e, "Manual maintenance failed"); + } + finally + { + _dbRefreshedAt = 0; + } + }) + { + IsBackground = true, + Name = "HellionChat Maintenance", + }; + + try + { + worker.Start(); + } + catch (Exception e) + { + _logger.LogError(e, "Could not start the maintenance thread"); + } } // The privacy filter only decides what gets written from now on. Everything @@ -483,7 +869,7 @@ internal sealed class DataPrivacyTab } finally { - _plugin.DbOperations.End(); + _plugin.DbOperations.End(DbOperation.Cleanup); } } catch (Exception e) @@ -742,7 +1128,7 @@ internal sealed class DataPrivacyTab } finally { - _plugin.DbOperations.End(); + _plugin.DbOperations.End(DbOperation.Export); } } catch (Exception e) diff --git a/HellionChat/Util/DbOperationGate.cs b/HellionChat/Util/DbOperationGate.cs index 744f354..3f6cab6 100644 --- a/HellionChat/Util/DbOperationGate.cs +++ b/HellionChat/Util/DbOperationGate.cs @@ -62,14 +62,18 @@ internal sealed class DbOperationGate } } - // Idempotent, and deliberately not checking which operation ends. A worker - // that throws must still be able to release from a finally block without - // knowing whether it ever acquired. - internal void End() + // Releases only what the caller acquired. Idempotent for that caller, and a + // no-op for anyone else -- a worker whose TryBegin was refused still runs its + // finally, and a blind reset there would hand away the lock of whichever + // operation actually holds it. That is worse than no gate: the refused + // worker walks away believing it did nothing while a VACUUM starts under + // somebody's open reader. + internal void End(DbOperation operation) { lock (_lock) { - _current = DbOperation.None; + if (_current == operation) + _current = DbOperation.None; } } } From 1987d745d8a1a6a21dad638b601ea22a52a8b739 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 21:51:15 +0200 Subject: [PATCH 107/129] docs: hold the documentation against the code Every claim in the user-facing docs, checked against what the plugin actually does after A2 to A5. The two GDPR sections in PRIVACY.md describe features that only exist again as of this cycle, so they now name the screens they live on and say what the cleanup does with a channel this build does not recognise. The export sentence promised a date range; the form offers an age in days. PRIVACY.md also named "Show emotes" in Settings as the way to stop the one outbound call the plugin makes. That switch has had no control since May, and a documented opt-out that only exists in the JSON is not an opt-out, so the toggle is back in the chat tab. Both its strings were already translated. Dead paths: the README source tree still listed a file deleted in May, docs/IPC.md cited a window that no longer exists, the first-run wizard pointed at the same removed file, and the theme authoring guide sent readers to a Themes tab that is called Appearance. The changelog lost two double blank lines that were failing markdownlint and holding preflight red. Preflight is green again. Historical documents keep their old paths on purpose: the changelog and the AI disclosure describe what was true when they were written. --- .../Ui/Components/Settings/Tabs/ChatTab.cs | 11 ++++++++++ HellionChat/Ui/FirstRunWizard.cs | 4 +++- PRIVACY.md | 22 ++++++++++++++----- README.md | 7 +++--- docs/CHANGELOG.md | 2 -- docs/IPC.md | 4 ++-- docs/THEME-AUTHORING.md | 10 ++++----- 7 files changed, 41 insertions(+), 19 deletions(-) diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs index 36f1fd6..eab173a 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs @@ -34,6 +34,17 @@ internal sealed class ChatTab v => Plugin.Config.Use24HourClock = v ); + // PRIVACY.md names this switch as the way to stop the one outbound + // call the plugin makes, and it has had no control since May. A + // documented opt-out that only exists in the JSON is not an opt-out. + _w.ToggleRow( + ImGui.GetID("chat.display.emotes"u8), + Language.Options_ShowEmotes_Name, + Language.Options_ShowEmotes_Desc, + () => Plugin.Config.ShowEmotes, + v => Plugin.Config.ShowEmotes = v + ); + // Descriptions move out of the help markers and onto the row. They // were written to be read; a (?) the user has to hover is where an // explanation goes to be ignored. diff --git a/HellionChat/Ui/FirstRunWizard.cs b/HellionChat/Ui/FirstRunWizard.cs index 91a5510..da28c30 100644 --- a/HellionChat/Ui/FirstRunWizard.cs +++ b/HellionChat/Ui/FirstRunWizard.cs @@ -368,7 +368,9 @@ public sealed class FirstRunWizard : Window // Mirror the DataAndPrivacy coupling: turning load-previous on // also turns filter-include on (otherwise old messages bypass // the filter chain), and turning filter-include off forces - // load-previous off. Same idiom as Ui/SettingsTabs/DataAndPrivacy.cs. + // load-previous off. The settings tab that used to hold the same + // pairing was removed in May; the coupling lives here alone until + // the two checkboxes are reachable again. if (loadPrev) _state.PendingFilterIncludePreviousSessions = true; } diff --git a/PRIVACY.md b/PRIVACY.md index 214186f..6ee5570 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -95,7 +95,7 @@ does on your behalf. incoming chat message contains a token matching one of the cached IDs. These are cached locally (`emoteCache/`) and reused across sessions. - **Cached:** Yes, in `emoteCache/`. A given emote is downloaded once per machine and reused. -- **How to opt out:** Turn off the **Show emotes** option in Settings → Chat. With it disabled, the +- **How to opt out:** Turn off the **Show emotes** option in Settings → Chat → Display modes. With it disabled, the emote cache does not load and no requests to BetterTTV are made for the rest of the session. - **BetterTTV's privacy policy:** @@ -148,17 +148,27 @@ locally, those rights translate directly into plugin features: ### Right to access (Art. 15) -Use the export feature in the plugin settings. You can export to **Markdown**, **JSON** or **CSV**, -filtered by channel, date range or sender substring. The export goes through a Dalamud file dialog -and writes wherever you point it, on your machine. +Settings → Data & Privacy → Export. You can export to **Markdown**, **JSON** or **CSV**, narrowed +by channel group, by age in days, or by a substring of the sender's name. The export goes through a +Dalamud file dialog and writes wherever you point it, on your machine. It reads the database on its +own connection and writes to a temporary file first, so a run that is interrupted leaves the +previous export in place rather than a file that looks complete and is not. ### Right to erasure (Art. 17) Two options: -1. **Targeted deletion.** The "retroactive cleanup" feature lets you apply your current whitelist to - the existing database. It shows a preview of what will be removed before you confirm with +1. **Targeted deletion.** Settings → Data & Privacy → Cleanup applies your current channel list to + the messages already stored. It shows a preview of what will be removed before you confirm with Ctrl+Shift, runs in the background, and calls `VACUUM` afterwards to actually shrink the file. + Channels this build does not recognise -- ones a game patch added after the plugin was released + -- survive the cleanup while "save unknown channel types" is on, for the same reason they are + stored in the first place: so the decision about them stays yours. + + The cleanup is only offered when it can mean something. With the privacy filter off, every + channel is stored and nothing contradicts your settings; with no channel selected, a cleanup + would delete everything, and that is what the clear button is for. Both cases say so instead of + offering a button that does not do what it looks like. 2. **Full deletion.** Close the game and delete the `pluginConfigs/HellionChat/` directory. The next plugin start will produce a fresh, empty configuration. diff --git a/README.md b/README.md index 51dce2a..6022dce 100644 --- a/README.md +++ b/README.md @@ -169,9 +169,10 @@ HellionChat/ │ └── Language*.resx # Upstream localization (Crowdin) ├── Ui/ │ ├── FirstRunWizard.cs # Three-profile onboarding -│ ├── HellionStyle.cs # ImGui theme push (local and global) -│ └── SettingsTabs/ -│ └── DataAndPrivacy.cs # Data & Privacy tab (filters, retention, cleanup, export) +│ ├── StyleEngine/ # Tokens, widgets and the theme push +│ ├── Windows/ # Main, settings, popouts, DB viewer +│ └── Components/Settings/Tabs/ +│ └── DataPrivacyTab.cs # Data & Privacy tab (filters, retention, cleanup, export) ├── Ipc/ # IPC channels, migrated to HellionChat.* in v1.0.0 ├── ChatTwoConflictDetector.cs # Blocks plugin load if upstream Chat 2 is active ├── images/ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 00d2802..cbda130 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -130,7 +130,6 @@ release stays at v1.5.6. - Test suite 829 → 892, and `StatusBarCacheTests` is back in the build after sitting in the exclusion block. - ## [1.9.0] — unreleased (local only) Polish, tester-beta preparation and a concurrency hardening pass. **Not published** — the public @@ -164,7 +163,6 @@ the assembly version reads 1.9.0. - Removed `DeferredSaveFrames`: the debounce was fully wired but never armed. - Test suite 812 → 829, including migration and query-plan coverage. - ## Hellion Chat 1.5.6 — Settings Overhaul + Filter & Notification Polish (2026-05-23) - Settings window reorganised: ten tabs down to seven (General, Appearance, Chat, Window, Channels, Data & Privacy, About). Each tab now uses collapsible sections grouped by control type. Sections start collapsed every time you open a tab — less noise, easier to find what you need. diff --git a/docs/IPC.md b/docs/IPC.md index c851b8f..f0a5de9 100755 --- a/docs/IPC.md +++ b/docs/IPC.md @@ -169,8 +169,8 @@ the same tuple: `ChannelType` is the `HellionChat.Code.ChatType` enum value representing the target channel for the current submission. It is sourced from the active tab's `UsedChannel` (`HellionChat/Configuration.cs`), which the plugin keeps in sync by hooking the in-game shell -(`HellionChat/GameFunctions/Chat.cs`) and by resolving temporary overrides inside the chat UI -(`HellionChat/Ui/ChatLogWindow.cs:597`). `InputChannel` values are converted into the exported +(`HellionChat/GameFunctions/Chat.cs`) and by resolving temporary overrides in the input bar +(`HellionChat/Ui/Components/InputBar.cs`). `InputChannel` values are converted into the exported `ChatType` via `HellionChat/Code/InputChannelExt.ToChatType`. ### Behavior diff --git a/docs/THEME-AUTHORING.md b/docs/THEME-AUTHORING.md index e6da235..6556268 100644 --- a/docs/THEME-AUTHORING.md +++ b/docs/THEME-AUTHORING.md @@ -10,11 +10,11 @@ ## TL;DR -1. Open Settings → Themes → **Open themes folder** +1. Open Settings → Appearance → **Open themes folder** 2. Copy `example-theme.json` to `.json` in the same folder 3. Edit the file with any text editor 4. Reload the plugin (toggle off/on in `/xlplugins`) -5. Your theme appears in the Custom-Themes section in Settings → Themes +5. Your theme appears in the Custom-Themes section in Settings → Appearance That's the whole loop. The rest of this document is reference. @@ -24,7 +24,7 @@ That's the whole loop. The rest of this document is reference. %APPDATA%\XIVLauncher\pluginConfigs\HellionChat\themes\ ``` -(or the equivalent path on Linux/macOS — Settings → Themes → "Open themes folder" opens it +(or the equivalent path on Linux/macOS — Settings → Appearance → "Open themes folder" opens it directly). Each `*.json` file in this folder is loaded as one theme. The `example-theme.json` that HellionChat @@ -179,11 +179,11 @@ Check `/xllog` after a plugin reload to see what loaded and what didn't. 1. Edit the JSON, save the file. 2. Reload the plugin: `/xlplugins` → toggle HellionChat off, then on. -3. Settings → Themes → click your theme card. +3. Settings → Appearance → click your theme card. 4. Watch every plugin window (chat, settings, pop-out) and pick something to fix. 5. Tweak. Reload. Repeat. -Tip: the **Settings → Themes** picker shows a mini-mockup per theme — your colors are visible before +Tip: the **Settings → Appearance** picker shows a mini-mockup per theme — your colors are visible before you switch. ## Sharing themes From 0279a1a9d67a97983c4509c1760f8c0c89ba4eb2 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 22:13:33 +0200 Subject: [PATCH 108/129] fix(privacy): close the gaps three review passes found in block A The worst of them made the block's own privacy promise backwards. PrivacyPersistChannels was given a non-empty field initializer so a fresh config would record conversations only. Dalamud deserialises with Json.NET's defaults, which means ObjectCreationHandling.Auto: a collection field that already holds items gets *populated*, not replaced. Verified against Newtonsoft 13.0.3 -- saved [] loads as the initializer, saved [Say] loads as initializer plus Say. So the change would have unioned the privacy-first list into every existing config on load and switched channels back on that the user had unticked, while also making the v24 migration unreachable and its self-test vacuous. The field is empty again and the seeding moved to CreateFresh, which only runs when there is no config file at all. Cleanup could delete a channel it had promised to keep. The allowlist could only name channels that were already in the database when the preview ran, so an unrecognised channel whose first message arrived afterwards fell outside it. Where the failsafe is on, the deletion now names what goes -- known channels that are not on the list -- instead of what stays. The window closes completely, and a listed channel that happens to be empty right now is safe for the same reason. The cleanup preview was the one long operation that never took the shared lock, while holding an open reader across a full-table scan. That is precisely the case the lock was written for. Clearing the history reported success when it failed. ClearMessages purges the full-text index between the delete and the VACUUM; if that step throws, the plaintext stays on disk and the user was told it was gone. It has its own error string now, in all 25 languages. Also: - One busy state for the whole tab. Cleanup, clear, maintenance and export reach the same store, and per-section flags left two destructive buttons live at once. The lock turned that into a refusal rather than damage, but a refusal you have to trigger to discover is not an answer. - The gate carries a revision, bumped by every mutating operation that finishes. A preview taken before a retention sweep no longer passes as current afterwards: comparing it against the settings alone cannot see that the rows it counted are gone. - Database metadata moved to a worker. Checking "is anything busy" first is not enough, because an operation can take the lock in the gap before COUNT(*) runs, and then the game stands still for a whole file rewrite. - The clear hint stays hidden until the count has actually been read. "0 messages are stored" in front of the clear button is a lie told at the worst possible moment. - Refusal notices read the operation once. Guard and name were two reads of the same field, so a run finishing in between printed a sentence that stopped at the colon. - The retention sweep cannot start twice. The gate only goes busy once the worker reaches TryBegin, and the due-check runs every tick. - Teardown waits up to five seconds for the store to come free rather than disposing the connection under a running VACUUM. - Maintenance has its own flag and says so when it is refused; reload gets the same guard as its neighbour; the breakdown tree keeps its open state across a language switch. --- HellionChat/Configuration.cs | 28 +- HellionChat/MessageStore.cs | 35 ++ HellionChat/Plugin.cs | 17 +- .../Resources/HellionStrings.Designer.cs | 1 + HellionChat/Resources/HellionStrings.ca.resx | 3 + HellionChat/Resources/HellionStrings.cs.resx | 3 + HellionChat/Resources/HellionStrings.da.resx | 3 + HellionChat/Resources/HellionStrings.de.resx | 3 + HellionChat/Resources/HellionStrings.el.resx | 3 + HellionChat/Resources/HellionStrings.es.resx | 3 + HellionChat/Resources/HellionStrings.fi.resx | 3 + HellionChat/Resources/HellionStrings.fr.resx | 3 + HellionChat/Resources/HellionStrings.hu.resx | 3 + HellionChat/Resources/HellionStrings.it.resx | 3 + HellionChat/Resources/HellionStrings.ja.resx | 3 + HellionChat/Resources/HellionStrings.ko.resx | 3 + HellionChat/Resources/HellionStrings.nb.resx | 3 + HellionChat/Resources/HellionStrings.nl.resx | 3 + HellionChat/Resources/HellionStrings.pl.resx | 3 + .../Resources/HellionStrings.pt-BR.resx | 3 + .../Resources/HellionStrings.pt-PT.resx | 3 + HellionChat/Resources/HellionStrings.resx | 3 + HellionChat/Resources/HellionStrings.ro.resx | 3 + HellionChat/Resources/HellionStrings.ru.resx | 3 + HellionChat/Resources/HellionStrings.sv.resx | 3 + HellionChat/Resources/HellionStrings.tr.resx | 3 + HellionChat/Resources/HellionStrings.uk.resx | 3 + .../Resources/HellionStrings.zh-Hans.resx | 3 + .../Resources/HellionStrings.zh-Hant.resx | 3 + .../Settings/Tabs/DataPrivacyTab.cs | 382 +++++++++++++----- HellionChat/Util/DbOperationGate.cs | 16 +- 31 files changed, 431 insertions(+), 123 deletions(-) diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index 5720e0b..eff9477 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -59,15 +59,16 @@ public class Configuration : IPluginConfiguration // Privacy by Default master switch. Set false to restore upstream behaviour. public bool PrivacyFilterEnabled = true; - // Privacy by Default (DSGVO Art. 25): a config that never met the wizard - // records the player's own conversations and nothing else. Before v1.12.0 - // this started empty, which was harmless only because the failsafe below - // overrode it and stored everything anyway. With the corrected rule an empty - // list means an empty database, so the default has to state the intent. - public HashSet PrivacyPersistChannels = - [ - .. Privacy.PrivacyDefaults.PrivacyFirstWhitelist, - ]; + // Stays empty here. Dalamud deserialises with Json.NET's default settings, + // which means ObjectCreationHandling.Auto: a collection field that already + // holds items is *populated*, not replaced. A non-empty initializer would + // therefore union itself into every config on load and switch channels the + // user had unticked back on. Verified against Newtonsoft 13.0.3: + // saved [] loads as the initializer, saved [Say] loads as initializer + Say. + // + // Privacy by Default (DSGVO Art. 25) is seeded in CreateFresh instead, which + // only runs when there is no config file at all. + public HashSet PrivacyPersistChannels = []; // Failsafe for ChatTypes added by future FFXIV patches. New configs default // to the failsafe via PrivacyDefaults; existing configs keep their saved @@ -82,6 +83,15 @@ public class Configuration : IPluginConfiguration [NonSerialized] private readonly HashSet _warnedUnknownChannels = new(); + // A first-ever start records the player's own conversations and nothing + // else. Deliberately not a field initializer -- see PrivacyPersistChannels. + internal static Configuration CreateFresh() + { + var config = new Configuration(); + config.PrivacyPersistChannels = [.. Privacy.PrivacyDefaults.PrivacyFirstWhitelist]; + return config; + } + public bool IsAllowedForStorage(ChatType type) { if (!PrivacyFilterEnabled) diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index 2afee3b..0f390be 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -635,6 +635,41 @@ internal class MessageStore : IDisposable } } + // Hard-deletes every message whose ChatType IS in the list, then VACUUMs. + // Returns the number of rows deleted. + // + // The mirror image of CleanupRetainOnly, and the privacy filter needs both. + // With the unknown-channel failsafe on, the rule keeps every channel this + // build does not recognise -- and a retain-list can only name the ones that + // were already in the database when the list was built, so a channel whose + // first message arrives after that would be deleted. Naming what goes + // instead of what stays removes the window entirely. + internal long CleanupDeleteTypes(IReadOnlyCollection deleteTypes) + { + if (deleteTypes.Count == 0) + return 0; + + lock (_readLock) + { + long deleted; + using (var cmd = Connection.CreateCommand()) + { + var placeholders = BindIntList(cmd, "dt", deleteTypes); + cmd.CommandText = $"DELETE FROM messages WHERE ChatType IN ({placeholders});"; + cmd.CommandTimeout = 600; + deleted = cmd.ExecuteNonQuery(); + } + + if (deleted > 0) + { + InvalidateFtsIndex(); + PerformMaintenance(); + } + + return deleted; + } + } + internal void PerformMaintenance() { lock (_readLock) diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index aa4bfaa..a80bcbd 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -251,7 +251,7 @@ public sealed class Plugin : IAsyncDalamudPlugin // Migrate config + database from upstream ChatTwo on first start. MigrateFromChatTwoLayout(); - Config = Interface.GetPluginConfig() as Configuration ?? new Configuration(); + Config = Interface.GetPluginConfig() as Configuration ?? Configuration.CreateFresh(); // PlatformUtil and LogProxy are filled from the DI container in // Phase-1 below (`_host.Services.GetRequiredService()` @@ -724,6 +724,21 @@ public sealed class Plugin : IAsyncDalamudPlugin failure ??= ex; } + // The four long-running workers are background threads with no + // cancellation path, and one of them may be holding an open reader or + // sitting inside a VACUUM. Disposing the store under that tears the + // connection out mid-statement. Five seconds is not a guarantee, but it + // covers everything short of a VACUUM over a very large file, and it + // costs nothing when nothing is running. + var grace = Stopwatch.StartNew(); + while (DbOperations.IsBusy && grace.ElapsedMilliseconds < 5_000) + await Task.Delay(50).ConfigureAwait(false); + + if (DbOperations.IsBusy) + Log.Warning( + $"Disposing while {DbOperations.Current} still owns the store; it outlasted the 5s grace period." + ); + // Container disposes services + windows on the framework thread. // MessageManager.DisposeAsync is not idempotent, so we let the // container do it once instead of double-disposing. diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index ca7653a..e2b1f74 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -298,6 +298,7 @@ internal class HellionStrings internal static string Settings_Database_Stats_Heading => Get(nameof(Settings_Database_Stats_Heading)); internal static string Settings_Database_Busy => Get(nameof(Settings_Database_Busy)); internal static string Settings_Database_ClearHint => Get(nameof(Settings_Database_ClearHint)); + internal static string Settings_Database_ClearError => Get(nameof(Settings_Database_ClearError)); internal static string Settings_Database_Op_RetentionSweep => Get(nameof(Settings_Database_Op_RetentionSweep)); internal static string Settings_Database_Op_Export => Get(nameof(Settings_Database_Op_Export)); internal static string Settings_Database_Op_Cleanup => Get(nameof(Settings_Database_Op_Cleanup)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index 70f5adc..b445e98 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1184,4 +1184,7 @@ Ctrl+Maj: executa la neteja de retenció ara mateix en comptes d'esperar el pas diari. Esborra els missatges més antics que els límits de dalt. + + No s'ha pogut esborrar l'historial. No s'ha eliminat res, consulta /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index 58c1546..287615b 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: spustí úklid podle doby uchování hned, místo čekání na denní běh. Smaže zprávy starší než limity výše. + + Vymazání historie selhalo. Nic nebylo odstraněno, viz /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index 777e968..6f31c30 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: kører oprydningen med det samme i stedet for at vente på det daglige gennemløb. Sletter beskeder ældre end grænserne ovenfor. + + Sletning af historikken mislykkedes. Intet blev fjernet, se /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index ece9b51..a3a7f54 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1178,4 +1178,7 @@ Strg+Umschalt: führt den Aufbewahrungslauf sofort aus, statt auf den täglichen Durchlauf zu warten. Löscht Nachrichten, die älter sind als die Grenzen oben. + + Das Löschen des Verlaufs ist fehlgeschlagen. Es wurde nichts entfernt, siehe /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index 51a6550..b818787 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: εκτελεί την εκκαθάριση διατήρησης αμέσως, χωρίς να περιμένει το ημερήσιο πέρασμα. Διαγράφει μηνύματα παλαιότερα από τα παραπάνω όρια. + + Η διαγραφή του ιστορικού απέτυχε. Δεν αφαιρέθηκε τίποτα, δείτε /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index 89660e9..9f44dd6 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1184,4 +1184,7 @@ Ctrl+Mayús: ejecuta la limpieza de retención ahora mismo en lugar de esperar al barrido diario. Borra los mensajes más antiguos que los límites de arriba. + + No se pudo borrar el historial. No se eliminó nada, consulta /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index 743a5ac..a0a6f20 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1183,4 +1183,7 @@ Ctrl+Vaihto: suorittaa säilytysajon heti sen sijaan, että odottaisi päivittäistä ajoa. Poistaa yllä olevia rajoja vanhemmat viestit. + + Historian tyhjennys epäonnistui. Mitään ei poistettu, katso /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index e95fa64..81dd5f0 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1184,4 +1184,7 @@ Ctrl+Maj : lance le nettoyage de rétention immédiatement au lieu d'attendre le passage quotidien. Supprime les messages plus anciens que les limites ci-dessus. + + L'effacement de l'historique a échoué. Rien n'a été supprimé, voir /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index 289949b..8e023c9 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: azonnal lefuttatja a megőrzési takarítást, nem várja meg a napi futást. Törli a fenti korlátoknál régebbi üzeneteket. + + Az előzmények törlése nem sikerült. Semmi sem lett eltávolítva, lásd /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index 254ee3f..f428f96 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1184,4 +1184,7 @@ Ctrl+Maiusc: esegue subito la pulizia di conservazione invece di aspettare il passaggio giornaliero. Cancella i messaggi più vecchi dei limiti sopra. + + La cancellazione della cronologia non è riuscita. Non è stato rimosso nulla, vedi /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index ed0858a..b5c0bd1 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1184,4 +1184,7 @@ Ctrl+Shift: 毎日の処理を待たずに保存期間の整理をすぐ実行します。上の期限より古いメッセージを削除します。 + + 履歴の削除に失敗しました。何も削除されていません。/xllog を確認してください。 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 52c99fe..1e0302e 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1184,4 +1184,7 @@ Ctrl+Shift: 매일 실행을 기다리지 않고 보존 기간 정리를 지금 실행합니다. 위 기한보다 오래된 메시지를 삭제합니다. + + 기록 삭제에 실패했습니다. 아무것도 삭제되지 않았습니다. /xllog를 확인하세요. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index 27f37ac..3ddc5db 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: kjører oppryddingen med en gang i stedet for å vente på det daglige gjennomløpet. Sletter meldinger eldre enn grensene over. + + Sletting av historikken mislyktes. Ingenting ble fjernet, se /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 763cb82..aa8eb65 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1184,4 +1184,7 @@ Ctrl+Shift: voert de opschoning nu meteen uit in plaats van te wachten op de dagelijkse ronde. Wist berichten ouder dan de limieten hierboven. + + Het wissen van de geschiedenis is mislukt. Er is niets verwijderd, zie /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index d719b9a..2381382 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: uruchamia porządkowanie od razu, zamiast czekać na codzienny przebieg. Usuwa wiadomości starsze niż limity powyżej. + + Usuwanie historii nie powiodło się. Nic nie zostało usunięte, zobacz /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index d2e5b25..aa86657 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1184,4 +1184,7 @@ Ctrl+Shift: executa a limpeza de retenção agora em vez de esperar a varredura diária. Apaga mensagens mais antigas que os limites acima. + + Falha ao apagar o histórico. Nada foi removido, veja /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 34b4aae..06c1334 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: executa a limpeza de retenção agora em vez de esperar a varredura diária. Apaga mensagens mais antigas do que os limites acima. + + Falha ao apagar o histórico. Nada foi removido, consulte /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index 9a9583d..9feaf9a 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1204,4 +1204,7 @@ Ctrl+Shift: runs the retention cleanup right now instead of waiting for the daily sweep. Deletes messages older than the limits above. + + Clearing the history failed. Nothing was removed, see /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index 255a0d8..ba41ca9 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1184,4 +1184,7 @@ Ctrl+Shift: rulează curățarea acum, în loc să aștepte trecerea zilnică. Șterge mesajele mai vechi decât limitele de mai sus. + + Ștergerea istoricului a eșuat. Nu a fost eliminat nimic, vezi /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index 5fd3563..04c6f72 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1184,4 +1184,7 @@ Ctrl+Shift: выполняет очистку по сроку хранения сразу, не дожидаясь ежедневного прохода. Удаляет сообщения старше указанных выше пределов. + + Не удалось удалить историю. Ничего не было удалено, см. /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index 4c3e621..b8e95b3 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1184,4 +1184,7 @@ Ctrl+Skift: kör gallringen direkt i stället för att vänta på den dagliga körningen. Raderar meddelanden äldre än gränserna ovan. + + Raderingen av historiken misslyckades. Ingenting togs bort, se /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index 15de60d..47dc6b3 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: günlük taramayı beklemeden saklama temizliğini hemen çalıştırır. Yukarıdaki sınırlardan eski iletileri siler. + + Geçmiş silinemedi. Hiçbir şey kaldırılmadı, /xllog kaydına bakın. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 6c42f66..51d3331 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: виконує очищення за строком зберігання одразу, не чекаючи щоденного проходу. Видаляє повідомлення, старші за вказані вище межі. + + Не вдалося видалити історію. Нічого не було видалено, див. /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index 8681759..a069fac 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1184,4 +1184,7 @@ Ctrl+Shift:立即执行保留期清理,无需等待每日运行。删除早于上方期限的消息。 + + 清空历史记录失败。没有删除任何内容,请查看 /xllog。 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index 2091c3d..5ba3eed 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1184,4 +1184,7 @@ Ctrl+Shift:立即執行保留期清理,無需等待每日執行。刪除早於上方期限的訊息。 + + 清除歷史紀錄失敗。沒有刪除任何內容,請查看 /xllog。 + \ No newline at end of file diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs index 8b459f1..a9e293a 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -44,30 +44,62 @@ internal sealed class DataPrivacyTab // lock, and a VACUUM holds that for the length of a full file rewrite. Once // per frame would not be a stutter, it would be a still image. private long _dbRefreshedAt; + private volatile bool _dbRefreshRunning; + + // Separate from the timestamp so the very first draw can tell "not read yet" + // from "read five seconds ago", and so the throttle cannot swallow the first + // refresh during the machine's first five seconds of uptime. + private volatile bool _dbEverRefreshed; private long _dbSize; private long _dbLogSize; private int _dbMessageCount; private volatile bool _clearRunning; + private volatile bool _maintenanceRunning; + // Shift held while expanding the section. Keeps the developer tools out of // the way without a permanent switch, the way upstream did it. private bool _dbSectionWasOpen; private bool _dbShowAdvanced; + // One answer for the whole tab, not one per section. Cleanup, clear, + // maintenance and export all end up at the same store, and a section that + // only watched its own flag would leave two destructive buttons live at + // once -- the gate turns that into a refusal rather than damage, but a + // refusal the user has to trigger to discover is not an answer. + private DbOperation CurrentOperation => _plugin.DbOperations.Current; + + private bool AnythingRunning => + _exportRunning + || _exportDialogOpen + || _cleanupPreviewRunning + || _cleanupRunning + || _clearRunning + || _maintenanceRunning + || _plugin.RetentionSweepRunning + || CurrentOperation != DbOperation.None; + // What the preview was computed against. All three inputs decide the outcome, // so all three decide whether it is still valid. private sealed record CleanupPreview( IReadOnlyList<(ChatType Type, long Count, bool Keep)> Rows, long KeepCount, long DeleteCount, - IReadOnlyCollection AllowedTypes, + IReadOnlyCollection DeleteTypes, + IReadOnlyCollection RetainTypes, HashSet Listed, bool FilterEnabled, - bool PersistUnknown + bool PersistUnknown, + long Revision ) { - internal bool MatchesConfig() => - FilterEnabled == Plugin.Config.PrivacyFilterEnabled + // Stale on two counts: the settings it was computed against, and the + // database it counted. A retention sweep or a wipe in between leaves the + // numbers describing rows that are already gone, and comparing the + // config alone cannot see that. + internal bool IsCurrent(Plugin plugin) => + Revision == plugin.DbOperations.Revision + && FilterEnabled == Plugin.Config.PrivacyFilterEnabled && PersistUnknown == Plugin.Config.PrivacyPersistUnknownChannels && Listed.SetEquals(Plugin.Config.PrivacyPersistChannels); } @@ -285,9 +317,8 @@ internal sealed class DataPrivacyTab ImGui.Spacing(); var running = _plugin.RetentionSweepRunning; - var current = _plugin.DbOperations.Current; - using (ImRaii.Disabled(running || current != DbOperation.None)) + using (ImRaii.Disabled(AnythingRunning)) { if ( ImGuiUtil.CtrlShiftButton( @@ -300,13 +331,7 @@ internal sealed class DataPrivacyTab // sweep bails silently by design, and a button that does nothing // without saying why is the thing this cycle exists to remove. if (!_plugin.StartRetentionSweep(notify: true)) - WrapperUtil.AddNotification( - string.Format( - HellionStrings.Settings_Database_Busy, - OperationName(_plugin.DbOperations.Current) - ), - NotificationType.Warning - ); + NotifyBusy(); } } @@ -357,12 +382,17 @@ internal sealed class DataPrivacyTab // Beyond the old layout: whoever is about to throw the history away // should see how much of it there is and be told, in the same breath, // that there is a way to keep a copy. - ImGuiUtil.HelpText( - string.Format(HellionStrings.Settings_Database_ClearHint, _dbMessageCount) - ); + // + // Withheld until the count has actually been read. The fields start at + // zero, and "0 messages are stored" in front of the clear button is a + // lie told at the worst possible moment. + if (_dbEverRefreshed) + ImGuiUtil.HelpText( + string.Format(HellionStrings.Settings_Database_ClearHint, _dbMessageCount) + ); - var current = _plugin.DbOperations.Current; - var busy = _clearRunning || current != DbOperation.None; + var current = CurrentOperation; + var busy = AnythingRunning; using (ImRaii.Disabled(busy)) { @@ -386,21 +416,62 @@ internal sealed class DataPrivacyTab DrawAdvancedDatabaseBlock(busy); } - // Suspended while a long-running operation owns the store: MessageCount - // takes the read lock, and asking for it during a VACUUM means waiting for - // the whole file to be rewritten -- on the draw thread. + // MessageCount takes the read lock and COUNT(*) is a full scan in SQLite, so + // this cannot run on the draw thread: checking "is anything busy" first is + // not enough, because an operation can take the lock in the gap between the + // check and the query, and then the game stands still for a whole file + // rewrite. The worker can afford to wait; the frame cannot. + // + // Throttled to once every five seconds, and skipped outright while something + // owns the store -- numbers taken mid-wipe would be wrong by the time they + // are drawn anyway. private void RefreshDatabaseMetadata() { - if (_plugin.DbOperations.IsBusy || _clearRunning) + if (_dbRefreshRunning || AnythingRunning) return; - if (_dbRefreshedAt + 5_000 > Environment.TickCount64) + if (_dbEverRefreshed && _dbRefreshedAt + 5_000 > Environment.TickCount64) return; - _dbSize = _plugin.MessageManager.Store.DatabaseSize(); - _dbLogSize = _plugin.MessageManager.Store.DatabaseLogSize(); - _dbMessageCount = _plugin.MessageManager.Store.MessageCount(); - _dbRefreshedAt = Environment.TickCount64; + _dbRefreshRunning = true; + + var worker = new Thread(() => + { + try + { + _dbSize = _plugin.MessageManager.Store.DatabaseSize(); + _dbLogSize = _plugin.MessageManager.Store.DatabaseLogSize(); + _dbMessageCount = _plugin.MessageManager.Store.MessageCount(); + _dbRefreshedAt = Environment.TickCount64; + _dbEverRefreshed = true; + } + catch (Exception e) + { + _logger.LogError(e, "Reading database metadata failed"); + + // Backs off for the usual interval rather than retrying every + // frame against a store that is unhappy. + _dbRefreshedAt = Environment.TickCount64; + } + finally + { + _dbRefreshRunning = false; + } + }) + { + IsBackground = true, + Name = "HellionChat DB Metadata", + }; + + try + { + worker.Start(); + } + catch (Exception e) + { + _dbRefreshRunning = false; + _logger.LogError(e, "Could not start the metadata thread"); + } } // The old version called ClearMessages straight from the draw thread, VACUUM @@ -419,13 +490,7 @@ internal sealed class DataPrivacyTab { if (!_plugin.DbOperations.TryBegin(DbOperation.Clear)) { - Notify( - string.Format( - HellionStrings.Settings_Database_Busy, - OperationName(_plugin.DbOperations.Current) - ), - NotificationType.Warning - ); + NotifyBusy(); return; } @@ -453,7 +518,7 @@ internal sealed class DataPrivacyTab catch (Exception e) { _logger.LogError(e, "Clearing the database failed"); - Notify(Language.Options_ClearDatabase_Success, NotificationType.Error); + Notify(HellionStrings.Settings_Database_ClearError, NotificationType.Error); } finally { @@ -548,26 +613,43 @@ internal sealed class DataPrivacyTab StartMaintenance(); } - if ( - ImGuiUtil.CtrlShiftButton( - "Reload messages from database", - "Ctrl+Shift: MessageManager.FilterAllTabsAsync()" - ) - ) + // Same guard as its neighbour: it reads the store from the thread pool, + // and doing that during a wipe or a VACUUM is the thing the lock exists + // to prevent. + using (ImRaii.Disabled(busy)) { - _plugin.MessageManager.ClearAllTabs(); - _plugin.MessageManager.FilterAllTabsAsync(); + if ( + ImGuiUtil.CtrlShiftButton( + "Reload messages from database", + "Ctrl+Shift: MessageManager.FilterAllTabsAsync()" + ) + ) + { + _plugin.MessageManager.ClearAllTabs(); + _plugin.MessageManager.FilterAllTabsAsync(); + } } } private void StartMaintenance() { + if (_maintenanceRunning) + return; + + _maintenanceRunning = true; + var worker = new Thread(() => { try { if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup)) + { + // Said out loud, like every other refusal. A developer tool + // that silently does nothing is how you end up debugging the + // wrong thing. + NotifyBusy(); return; + } try { @@ -585,6 +667,7 @@ internal sealed class DataPrivacyTab finally { _dbRefreshedAt = 0; + _maintenanceRunning = false; } }) { @@ -598,6 +681,7 @@ internal sealed class DataPrivacyTab } catch (Exception e) { + _maintenanceRunning = false; _logger.LogError(e, "Could not start the maintenance thread"); } } @@ -636,8 +720,8 @@ internal sealed class DataPrivacyTab return; } - var current = _plugin.DbOperations.Current; - var busy = _cleanupPreviewRunning || _cleanupRunning || current != DbOperation.None; + var current = CurrentOperation; + var busy = AnythingRunning; ImGui.Spacing(); using (ImRaii.Disabled(busy)) @@ -651,7 +735,7 @@ internal sealed class DataPrivacyTab { ImGuiUtil.HelpText(HellionStrings.Cleanup_NoPreview); } - else if (!preview.MatchesConfig()) + else if (!preview.IsCurrent(_plugin)) { ImGuiUtil.HelpText(HellionStrings.Cleanup_Preview_Stale); } @@ -699,11 +783,15 @@ internal sealed class DataPrivacyTab string.Format(HellionStrings.Cleanup_WillDelete, preview.DeleteCount) ); - using var tree = ImRaii.TreeNode(HellionStrings.Cleanup_Breakdown); + // ### so the open/closed state survives a language switch: ImGui derives + // the node's ID from its label, and a translated label is a new node. + // TreeNode indents on its own, so nothing is pushed on top of it. + using var tree = ImRaii.TreeNode( + $"{HellionStrings.Cleanup_Breakdown}###hc-cleanup-breakdown" + ); if (!tree.Success) return; - using var indent = ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false); foreach (var (type, count, keep) in preview.Rows) { var marker = keep @@ -715,6 +803,11 @@ internal sealed class DataPrivacyTab // On a worker: the count is a GROUP BY over every stored row, and the old // version ran it inline on the draw thread. + // + // Takes the shared lock even though it only reads. It holds an open reader + // for the length of the scan, and that is exactly what a VACUUM from any of + // the other three operations cannot survive -- which is the reason the lock + // exists at all. private void StartCleanupPreview() { if (_cleanupPreviewRunning) @@ -732,53 +825,20 @@ internal sealed class DataPrivacyTab { try { - using var conn = _plugin.MessageManager.Store.OpenSecondaryConnection(); - var counts = _plugin.MessageManager.Store.GetMessageCountsByChatType(conn); - - var rows = new List<(ChatType, long, bool)>(counts.Count); - long keepCount = 0; - long deleteCount = 0; - - // The allowlist starts from the whitelist itself, not from what - // happens to be stored right now. A channel with zero messages - // at preview time can receive one before the apply lands, and - // deriving the list from the counts would delete it. - var allowed = new HashSet(listed.Select(t => (int)(ushort)t)); - - foreach (var (raw, count) in counts) + if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup)) { - var type = (ChatType)(ushort)raw; - var known = Enum.IsDefined(type); - var keep = StorageRule.Allows(listed.Contains(type), known, persistUnknown); - - // A stored channel this build does not recognise survives if - // the failsafe says so. That failsafe exists to hold on to a - // new patch's channel until the user decides, and a cleanup - // that deleted it anyway would defeat exactly that. - if (keep) - { - keepCount += count; - allowed.Add(raw); - } - else - { - deleteCount += count; - } - - rows.Add((type, count, keep)); + NotifyBusy(); + return; } - rows.Sort((a, b) => b.Item2.CompareTo(a.Item2)); - - _cleanupPreview = new CleanupPreview( - rows, - keepCount, - deleteCount, - allowed, - listed, - filterEnabled, - persistUnknown - ); + try + { + BuildCleanupPreview(listed, filterEnabled, persistUnknown); + } + finally + { + _plugin.DbOperations.End(DbOperation.Cleanup); + } } catch (Exception e) { @@ -810,6 +870,77 @@ internal sealed class DataPrivacyTab } } + private void BuildCleanupPreview( + HashSet listed, + bool filterEnabled, + bool persistUnknown + ) + { + // Read before the scan. Any operation finishing after this point leaves + // the preview describing rows that may already be gone, and IsCurrent + // will say so. + var revision = _plugin.DbOperations.Revision; + + using var conn = _plugin.MessageManager.Store.OpenSecondaryConnection(); + var counts = _plugin.MessageManager.Store.GetMessageCountsByChatType(conn); + + var rows = new List<(ChatType, long, bool)>(counts.Count); + long keepCount = 0; + long deleteCount = 0; + + foreach (var (raw, count) in counts) + { + var type = (ChatType)(ushort)raw; + var keep = StorageRule.Allows( + listed.Contains(type), + Enum.IsDefined(type), + persistUnknown + ); + + if (keep) + keepCount += count; + else + deleteCount += count; + + rows.Add((type, count, keep)); + } + + rows.Sort((a, b) => b.Item2.CompareTo(a.Item2)); + + // Two shapes, because the two cases genuinely differ. + // + // With the failsafe on, the rule keeps every channel this build does not + // recognise, and there is no way to enumerate those -- so the deletion + // names what goes: known channels that are not on the list. A channel + // whose first message arrives after this preview is therefore safe, and + // so is a listed channel that happens to be empty right now. + // + // With it off, nothing outside the list survives, and a retain-list + // states that exactly. + var deleteTypes = persistUnknown + ? EnumValues + .All.Where(t => !listed.Contains(t)) + .Select(t => (int)(ushort)t) + .ToList() + : (IReadOnlyCollection)Array.Empty(); + + var retainTypes = persistUnknown + ? (IReadOnlyCollection)Array.Empty() + : listed.Select(t => (int)(ushort)t).ToList(); + + _cleanupPreview = new CleanupPreview( + rows, + keepCount, + deleteCount, + deleteTypes, + retainTypes, + listed, + filterEnabled, + persistUnknown, + revision + ); + } + private void StartCleanup(CleanupPreview preview) { if (_cleanupRunning) @@ -819,8 +950,9 @@ internal sealed class DataPrivacyTab // the config. Between the frame that drew the number and the frame that // took the click, nothing can have changed -- but the next revision of // this method should not have to prove that again. - var allowed = preview.AllowedTypes; - if (allowed.Count == 0) + var deleteTypes = preview.DeleteTypes; + var retainTypes = preview.RetainTypes; + if (deleteTypes.Count == 0 && retainTypes.Count == 0) return; _cleanupRunning = true; @@ -831,19 +963,16 @@ internal sealed class DataPrivacyTab { if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup)) { - Notify( - string.Format( - HellionStrings.Settings_Database_Busy, - OperationName(_plugin.DbOperations.Current) - ), - NotificationType.Warning - ); + NotifyBusy(); return; } try { - var deleted = _plugin.MessageManager.Store.CleanupRetainOnly(allowed); + var deleted = + deleteTypes.Count > 0 + ? _plugin.MessageManager.Store.CleanupDeleteTypes(deleteTypes) + : _plugin.MessageManager.Store.CleanupRetainOnly(retainTypes); _logger.LogInformation($"Privacy cleanup: deleted {deleted} messages"); // The tabs still hold the rows that just left the database. @@ -940,8 +1069,8 @@ internal sealed class DataPrivacyTab // Read once: IsBusy and Current are two reads of the same volatile // field, and between them the operation can finish -- which would print // "another operation is running: " with nothing after the colon. - var current = _plugin.DbOperations.Current; - var blocked = _exportDialogOpen || _exportRunning || current != DbOperation.None; + var current = CurrentOperation; + var blocked = AnythingRunning; ImGui.Spacing(); using (ImRaii.Disabled(blocked)) @@ -1028,6 +1157,22 @@ internal sealed class DataPrivacyTab _ => format.ToString(), }; + // Reads Current once. The guard that sent us here and the name are two + // reads of the same field, and if the other operation finished in between, + // formatting would produce "another operation is running:" with nothing + // after the colon. Nothing to report in that case -- the store is free. + private void NotifyBusy() + { + var op = _plugin.DbOperations.Current; + if (op == DbOperation.None) + return; + + Notify( + string.Format(HellionStrings.Settings_Database_Busy, OperationName(op)), + NotificationType.Warning + ); + } + private static string OperationName(DbOperation op) => op switch { @@ -1052,6 +1197,27 @@ internal sealed class DataPrivacyTab _exportDialogOpen = true; + try + { + OpenExportDialog(format, types, from, sender); + } + catch (Exception e) + { + // Only the callback clears this flag, and a throw here means the + // callback will never run -- which would leave the export button + // dead for the rest of the session. + _exportDialogOpen = false; + _logger.LogError(e, "Could not open the export dialog"); + } + } + + private void OpenExportDialog( + ExportFormat format, + List? types, + DateTimeOffset? from, + string? sender + ) + { Plugin.FileDialogManager.SaveFileDialog( HellionStrings.Export_Dialog_Title, format.Filter(), @@ -1093,13 +1259,7 @@ internal sealed class DataPrivacyTab // write a file nobody is waiting for any more. if (!_plugin.DbOperations.TryBegin(DbOperation.Export)) { - Notify( - string.Format( - HellionStrings.Settings_Database_Busy, - OperationName(_plugin.DbOperations.Current) - ), - NotificationType.Warning - ); + NotifyBusy(); return; } diff --git a/HellionChat/Util/DbOperationGate.cs b/HellionChat/Util/DbOperationGate.cs index 3f6cab6..ffa585a 100644 --- a/HellionChat/Util/DbOperationGate.cs +++ b/HellionChat/Util/DbOperationGate.cs @@ -39,6 +39,14 @@ internal sealed class DbOperationGate internal DbOperation Current => _current; + // Bumped whenever an operation that could have changed rows finishes. A + // cleanup preview snapshots it and treats a mismatch as stale: after a + // retention sweep or a wipe its numbers describe a database that is gone, + // and the comparison against the config alone cannot see that. + private long _revision; + + internal long Revision => Interlocked.Read(ref _revision); + internal bool IsBusy => _current != DbOperation.None; // False when another operation already owns the store. Callers must not @@ -72,8 +80,12 @@ internal sealed class DbOperationGate { lock (_lock) { - if (_current == operation) - _current = DbOperation.None; + if (_current != operation) + return; + + _current = DbOperation.None; + if (operation != DbOperation.Export) + Interlocked.Increment(ref _revision); } } } From 06ef0bfb1cb09e57417316c6a1c564d75ce50bb3 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 22:13:41 +0200 Subject: [PATCH 109/129] fix(input): let the arrow keys walk the sent-message history again Reported against v1.11.0: type a command, send it, need it again, and up-arrow does nothing. v1.5.6 recalled it. Everything needed was already in the tree. InputHistoryService holds the last thirty entries with move-to-newest dedup, CompactInputHistoryNavigator owns the cursor maths, and both have their own test mirrors. Neither had a single caller, and the input field never set CallbackHistory, so ImGui had no reason to raise the event in the first place. So: the flag goes on, the callback grows a history branch, and TrySend pushes the trimmed line before it clears the buffer. Editing ends the walk, otherwise down-arrow after changing a recalled line would jump to the next entry and discard the edit. The cursor is per input bar while the history is global. Which line each window is looking at is not something the others should inherit. --- HellionChat/Ui/Components/InputBar.cs | 54 ++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/HellionChat/Ui/Components/InputBar.cs b/HellionChat/Ui/Components/InputBar.cs index cb78a47..bd3b4f0 100644 --- a/HellionChat/Ui/Components/InputBar.cs +++ b/HellionChat/Ui/Components/InputBar.cs @@ -311,6 +311,11 @@ internal sealed class InputBar } } + // -1 means "not browsing". Per input bar, not shared: the history itself is + // global across the main window and every pop-out, but where each of them is + // in it is not. + private int _historyCursor = -1; + private void DrawInputField(Tab? activeTab) { if (Activate) @@ -328,7 +333,8 @@ internal sealed class InputBar ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.CallbackEdit | ImGuiInputTextFlags.CallbackCompletion - | ImGuiInputTextFlags.CallbackAlways, + | ImGuiInputTextFlags.CallbackAlways + | ImGuiInputTextFlags.CallbackHistory, SlashCommandCallback ) ) @@ -360,6 +366,39 @@ internal sealed class InputBar return 0; } + // Up and down walk the sent-message history, the way 1.5.6 did. ImGui + // only raises this event when CallbackHistory is set on the field, which + // is why the arrows did nothing at all before: the service and the + // cursor maths were both here and tested, with no caller and no flag. + if (data.EventFlag == ImGuiInputTextFlags.CallbackHistory) + { + var direction = + data.EventKey == ImGuiKey.UpArrow + ? CompactInputHistoryNavigator.Direction.Up + : CompactInputHistoryNavigator.Direction.Down; + + var (cursor, replacement) = CompactInputHistoryNavigator.Navigate( + direction, + _historyCursor, + _pendingMessage, + () => InputHistoryService.Count, + InputHistoryService.Push, + InputHistoryService.GetByCursor + ); + + _historyCursor = cursor; + if (replacement is null) + return 0; + + // The buffer belongs to ImGui inside a callback; writing the managed + // field here would be overwritten on the way out. + data.DeleteChars(0, data.BufTextLen); + if (replacement.Length > 0) + data.InsertChars(0, replacement); + + return 0; + } + if (data.EventFlag == ImGuiInputTextFlags.CallbackCompletion) { // CursorPos is a BYTE offset into the UTF-8 buffer. We decode the @@ -381,6 +420,13 @@ internal sealed class InputBar // CallbackEdit (or any remaining event): v1.5.6 character-level slash // detection keeps CommandHelpWindow in sync with what the user is // typing without a per-frame poll. + // + // Typing also ends the history walk. Without this, down-arrow after + // editing a recalled line would jump to the next entry and throw the + // edit away. + if (data.EventFlag == ImGuiInputTextFlags.CallbackEdit) + _historyCursor = -1; + _commandHelpWindow.IsOpen = false; var text = Encoding.UTF8.GetString(data.BufTextSpan); @@ -455,6 +501,12 @@ internal sealed class InputBar return; } ChatBox.SendMessageUnsafe(bytes); + + // Pushed before the buffer is cleared, and the trimmed form is what + // goes in: the history is for recalling what you typed, not the + // whitespace around it. + InputHistoryService.Push(text); + _historyCursor = -1; _pendingMessage = string.Empty; // 1.5.6 parity (1d3b429:ChatLogWindow.cs:1558): clear the temp channel From 4f4f5fc86a09e6e0a845e67dd7ea7be68037966b Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 22:14:45 +0200 Subject: [PATCH 110/129] docs: finish holding the documentation against the code The first pass through A6 caught the dead file paths and the two GDPR sections. A review pass walked every remaining claim and found eight more that no longer describe the plugin. The privacy notice carries a "last reviewed" stamp and commits, in its own text, to keeping it accurate for the version it describes. It still said v1.1.0 after the block that rewrote two of its sections. Both documents announced two outbound network calls in their opening paragraphs. The Lodestone font download went away in v1.0.4 and the notice explains that further down, so the summary contradicted its own body. There is one call, and it is BetterTTV. The emote cache is `EmoteCacheV1/`, which the notice states correctly in one place and wrongly in two others, two lines above a line this cycle had already touched. The list of buttons that open a browser named a Ko-fi page that appears nowhere in the source, an issue tracker and a website that have no button, and left out the Discord invite and the two Honorific links that do. In a section that exists precisely to enumerate where a click sends you, both halves matter. The README described the pop-out input bar as an opt-in under a settings section that does not exist. Neither does the switch: PopOutInputEnabled has no reader, the input bar is unconditional, and the field belongs on the deletion list. Said plainly instead. Also: the export narrows by age, not by a date range, in both documents; the tab is called About, not Info, and it has no per-translator list; the sections are Colours and Integrations. Plus a note the users affected by it deserve: the v24 migration switches the privacy filter off where it was on with nothing selected, and until now that was only in the log. --- PRIVACY.md | 38 ++++++++++++++++++++++++++------------ README.md | 23 ++++++++++++----------- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 6ee5570..1918b8f 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -10,7 +10,7 @@ of your data in the GDPR sense, because no data ever leaves your machine on the infrastructure. Independently of that, the plugin is built so that you can act on your own data the way the GDPR expects. -Last reviewed: 2026-05-05 (HellionChat v1.1.0). +Last reviewed: 2026-08-18 (HellionChat v1.12.0). --- @@ -22,8 +22,8 @@ Last reviewed: 2026-05-05 (HellionChat v1.1.0). no remote update check beyond what Dalamud itself does. - One outbound network call exists by design: the BetterTTV emote service (for chat emotes). It is documented in detail below and can be reasoned about per request. -- You can export every message the plugin has stored, in Markdown, JSON or CSV, and you can wipe - stored history per channel, per date range, or globally. +- You can export every message the plugin has stored, in Markdown, JSON or CSV. You can delete it + by channel, by age, or all of it at once. --- @@ -73,9 +73,10 @@ turn the retention sweep on in the settings. Until then, stored messages stay un ## Outbound network calls -HellionChat makes two kinds of automatic outbound network requests. Both are inherited from upstream -Chat 2 and both are documented here because "GDPR-by-design" means you should know what your client -does on your behalf. +HellionChat makes one kind of automatic outbound network request, inherited from upstream Chat 2 and +documented here because "GDPR-by-design" means you should know what your client does on your behalf. +The second one this section used to list, the Lodestone font download, was removed in v1.0.4 and the +font is bundled instead. ### 1. BetterTTV emote service (`api.betterttv.net`, `cdn.betterttv.net`) @@ -93,8 +94,8 @@ does on your behalf. mentioned. - The individual emote _images_ on `cdn.betterttv.net` are fetched on demand, only when an incoming chat message contains a token matching one of the cached IDs. These are cached locally - (`emoteCache/`) and reused across sessions. -- **Cached:** Yes, in `emoteCache/`. A given emote is downloaded once per machine and reused. + (`EmoteCacheV1/`) and reused across sessions. +- **Cached:** Yes, in `EmoteCacheV1/`. A given emote is downloaded once per machine and reused. - **How to opt out:** Turn off the **Show emotes** option in Settings → Chat → Display modes. With it disabled, the emote cache does not load and no requests to BetterTTV are made for the rest of the session. - **BetterTTV's privacy policy:** @@ -117,10 +118,11 @@ Cached `FFXIV_Lodestone_SSF.ttf` files left over from earlier versions remain in ### Links you click yourself (no automatic traffic) -The settings panel contains a few buttons that open external pages in your browser when you click -them: the upstream Chat 2 GitHub repo, the upstream maintainers' Ko-fi pages, the HellionChat issue -tracker and `hellion-media.de`. Nothing happens until you click. They are documented here for -completeness, not because they generate background traffic. +The About tab contains buttons that open external pages in your browser when you click them: the +Hellion Forge Discord invite, the HellionChat Gitea repository, its custom-repo manifest, and -- when +the Honorific integration row is shown -- that plugin's GitHub repository and its author's profile. +Nothing happens until you click. They are documented here for completeness, not because they +generate background traffic. --- @@ -154,6 +156,18 @@ Dalamud file dialog and writes wherever you point it, on your machine. It reads own connection and writes to a temporary file first, so a run that is interrupted leaves the previous export in place rather than a file that looks complete and is not. +### A note on the v1.12.0 filter correction + +Before v1.12.0 the privacy filter applied the unknown-channel failsafe to known channels as well, so +a channel you had unticked was still stored whenever that failsafe was on -- which is its default. +That is fixed: an unticked channel stays out. + +One consequence is worth stating plainly. A configuration that had the filter on, the failsafe on +and no channel selected was storing everything through that hole. The corrected rule would store +nothing at all, so the upgrade turns the filter off for exactly those configurations and writes a +line to `/xllog` saying so. Nothing changes about what is stored; it is now stated where you can see +it. Pick your channels and switch the filter back on whenever you like. + ### Right to erasure (Art. 17) Two options: diff --git a/README.md b/README.md index 6022dce..7c9d010 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,10 @@ Hellion Chat is developed under **Hellion Forge**, the specialized modding and p - **Retroactive cleanup** with preview and Ctrl+Shift confirmation. Applies the current whitelist to an existing database, runs in the background, and calls VACUUM afterward. - **Export** to Markdown, JSON, or CSV via the Dalamud file dialog (GDPR Art. 15 right of access). - Filter by channel, date range, or sender substring. + Narrow it by channel group, by age in days, or by a substring of the sender's name. - **Full privacy overview** in [`PRIVACY.md`](PRIVACY.md) and third-party components in - [`docs/THIRD_PARTY_NOTICES.md`](docs/THIRD_PARTY_NOTICES.md): what is stored, which two outbound - calls exist (BetterTTV opt-out, Square Enix Lodestone font), an explicit no-telemetry statement, + [`docs/THIRD_PARTY_NOTICES.md`](docs/THIRD_PARTY_NOTICES.md): what is stored, the single outbound + call that exists and how to switch it off (BetterTTV), an explicit no-telemetry statement, and the mapping of GDPR rights (Art. 15/17/18/20/21) to concrete plugin functions. ### Onboarding @@ -99,8 +99,8 @@ Hellion Chat is developed under **Hellion Forge**, the specialized modding and p in `HellionStrings..resx`. - **Hellion HUD theme** with cyan-teal accents, slate-violet tabs, and amber highlights for active states. -- **Chat color presets** (v0.6.0) with seven built-in bundles in Settings → Appearance → Chat - Colors: Classic (Chat 2 default), High Contrast, Pastel, Dark Mode Tuned, Hellion (brand), plus +- **Chat color presets** (v0.6.0) with seven built-in bundles in Settings → Appearance → Colours: + Classic (Chat 2 default), High Contrast, Pastel, Dark Mode Tuned, Hellion (brand), plus bonus moods Night Blue and Indigo Violet. One-click apply, battle channels remain untouched. - **Window opacity slider** for combat-friendly transparency. - **Bundled UI font** (Inter Light, OFL-1.1) as an optional default instead of the system font. @@ -118,15 +118,16 @@ Deuteranopia/Protanopia-safe (red-green color blindness) based on the Wong/Okabe - **Honorific custom titles in the chat header.** When the Honorific plugin is active and a custom title is set, it is displayed in the chat header above the message log. Auto-detect with silent - fallback: without Honorific the slot is invisible. Toggle in Settings → About → Extensions → Honorific. + fallback: without Honorific the slot is invisible. Toggle in Settings → About → Integrations. First cycle of a multi-stage plugin integration roadmap (context menu, NotificationMaster, RP status, ExtraChat, and XIVIM to follow). ### Pop-Out Convenience (v0.6.0) -- **Input bar in pop-out windows** as a global opt-in in Settings → Window → Window Frame. When - active, every pop-out window has a compact input at the bottom with a channel-colored icon button - and text field. No more switching back to the main window for a quick reply. +- **Input bar in pop-out windows.** Every pop-out has a compact input at the bottom with a + channel-colored icon button and text field. No more switching back to the main window for a quick + reply. It is always on: the `PopOutInputEnabled` switch this once described has no reader and no + control, and is scheduled for removal. - **Per-pop-out independent text buffer and history cursor.** Changing channels in a pop-out works globally like in the main window (FFXIV channel API), but half-typed input doesn't collide between the main window and pop-outs. @@ -446,8 +447,8 @@ layer. © 2026 Hellion Online Media for the Hellion Chat extensions. - **[Infi](https://github.com/Infiziert90) and [Anna](https://github.com/anna-is-cute) (ascclemens)** for the Chat 2 engine, without which this fork would not exist. - **Dalamud team** for the plugin framework. -- **Chat 2 Crowdin community** for the upstream string translations (see Settings → Info → "Chat 2 - community translators"). +- **Chat 2 Crowdin community** for the upstream string translations. The About tab credits the + upstream project; the per-translator list lives here rather than in the client. ### FFXIV Disclaimer From 9b634f54e9f7e0b0d462159fcca5d2d46f58c72e Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 22:25:23 +0200 Subject: [PATCH 111/129] fix(tabs): stop a tell from a popped-out partner hijacking the main window Reported from the field: a new tell from someone whose tab is popped out throws the main window onto the General tab, every time. The router revealed the tab by activating it in the main window. That window does not display a popped-out tab -- PickMainActiveTab re-anchors on the next frame, and it anchors to the FIRST non-popped tab in list order, not to the one the user was reading. So the reveal did nothing it intended and threw away the active tab on the way. Nothing needed revealing in the first place: the tab was already on screen in its own window. That decision now lives in PlanTellReveal, next to the pop-out helpers it belongs with, pure and pinned by nine facts -- the mode, the switch and the popped-out state have eight combinations between them and only two of them should touch anything. ActivateTab refuses a popped-out tab outright as well. The router is the caller that got it wrong, but the invariant belongs to the window: its active tab is never one that something else is drawing. --- HellionChat/Services/TellRouterService.cs | 54 ++++++++++++----------- HellionChat/Ui/Windows/MainWindow.cs | 8 ++++ HellionChat/Util/TabLifecycleHelpers.cs | 33 ++++++++++++++ 3 files changed, 69 insertions(+), 26 deletions(-) diff --git a/HellionChat/Services/TellRouterService.cs b/HellionChat/Services/TellRouterService.cs index ce081d0..4133c86 100644 --- a/HellionChat/Services/TellRouterService.cs +++ b/HellionChat/Services/TellRouterService.cs @@ -75,36 +75,38 @@ internal sealed class TellRouterService : IDisposable if (tab == null) return; // nothing to reveal (auto-tell-tabs off -> no tab created) - switch (mode) - { - case TellAutoOpenMode.Sidebar: - case TellAutoOpenMode.TopTab: - // Switching to the tab on every tell is user-gated - // (TellAutoOpenSwitchAlways, default on); when off the tab still - // appears with its unread badge but the active tab is left alone. - // The mode also picks the layout, so Sidebar vs TopTab are actually - // distinct outcomes, not the same ActivateTab. - if (Plugin.Config.TellAutoOpenSwitchAlways) - { - var wantLayout = - mode == TellAutoOpenMode.TopTab - ? MainWindowLayoutMode.TopTabs - : MainWindowLayoutMode.Sidebar; - if (Plugin.Config.MainWindowLayoutMode != wantLayout) - { - Plugin.Config.MainWindowLayoutMode = wantLayout; - Plugin.Instance.SaveConfig(); - } + // Switching to the tab on every tell is user-gated + // (TellAutoOpenSwitchAlways, default on); when off the tab still + // appears with its unread badge but the active tab is left alone. A + // tab that is already popped out needs no reveal at all -- it is on + // screen, and pulling the main window onto it costs the user the tab + // they were reading. + var reveal = TabLifecycleHelpers.PlanTellReveal( + mode, + Plugin.Config.TellAutoOpenSwitchAlways, + Plugin.Instance.ChannelPopoutPool.IsOpen(tab.Identifier) + ); - Plugin.Instance.MainWindow?.ActivateTab(tab); + switch (reveal) + { + case TabLifecycleHelpers.TellReveal.MainWindow: + // The mode also picks the layout, so Sidebar vs TopTab are + // actually distinct outcomes, not the same ActivateTab. + var wantLayout = + mode == TellAutoOpenMode.TopTab + ? MainWindowLayoutMode.TopTabs + : MainWindowLayoutMode.Sidebar; + if (Plugin.Config.MainWindowLayoutMode != wantLayout) + { + Plugin.Config.MainWindowLayoutMode = wantLayout; + Plugin.Instance.SaveConfig(); } + Plugin.Instance.MainWindow?.ActivateTab(tab); break; - case TellAutoOpenMode.Popout: - // IsOpen-guard: don't double-pop a tab the AutoTellTabsOpenAsPopout - // path already opened (the two switches stay decoupled). - if (!Plugin.Instance.ChannelPopoutPool.IsOpen(tab.Identifier)) - Plugin.Instance.ChannelPopoutPool.TryOpen(tab); + + case TabLifecycleHelpers.TellReveal.Popout: + Plugin.Instance.ChannelPopoutPool.TryOpen(tab); break; } }); diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index b162b93..737d73c 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -162,6 +162,14 @@ internal sealed class MainWindow : Window, IFocusableChatWindow // header pick strips tell-state and resets unread the way a real click does. internal void ActivateTab(Tab tab) { + // A popped-out tab is not a surface this window owns. Taking it as + // active does not show it -- PickMainActiveTab re-anchors on the next + // frame, and it anchors to the first non-popped tab, which is not the + // one the user was reading. Callers that mean "bring it forward" have + // to reach for the pool instead. + if (_pool.IsOpen(tab.Identifier)) + return; + if (ReferenceEquals(_activeTab, tab)) return; diff --git a/HellionChat/Util/TabLifecycleHelpers.cs b/HellionChat/Util/TabLifecycleHelpers.cs index b8fb729..fb0a4f3 100644 --- a/HellionChat/Util/TabLifecycleHelpers.cs +++ b/HellionChat/Util/TabLifecycleHelpers.cs @@ -180,6 +180,39 @@ internal static class TabLifecycleHelpers return null; } + // What an incoming tell should do about the tab it belongs to. + internal enum TellReveal + { + None, + MainWindow, + Popout, + } + + // POP-1f: the alreadyPopped case is the whole reason this is a function. + // + // Revealing a popped-out tab in the main window looks like it does nothing, + // and then does something worse: PickMainActiveTab re-anchors on the next + // frame, and it anchors to the FIRST non-popped tab, not to the one the user + // was reading. So a tell from a partner whose tab is popped out threw the + // main window back to the first tab every single time. + // + // The tab is already on screen in its own window. There is nothing to + // reveal. + // + // Pure + Dalamud-free. + // TEST-MIRROR: ../../../Hellion Build test/_Helpers/PlanTellRevealTests.cs + internal static TellReveal PlanTellReveal( + TellAutoOpenMode mode, + bool switchAlways, + bool alreadyPopped + ) => + mode switch + { + TellAutoOpenMode.Off => TellReveal.None, + TellAutoOpenMode.Popout => alreadyPopped ? TellReveal.None : TellReveal.Popout, + _ => switchAlways && !alreadyPopped ? TellReveal.MainWindow : TellReveal.None, + }; + // POP-1e: popout-aware sibling of WrapTabIndex for the ChatTabForward/Backward // keybind. Steps from current by delta's sign (±1), wrapping, and returns the // first index whose tab is NOT popped out within tabs.Count steps; returns current From 4f3bdc3c7bab514a082562eeb9f1f0ea797eb0f9 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 22:30:12 +0200 Subject: [PATCH 112/129] fix(privacy): translate the channel list and give it back its presets Reported after a translation pass: the channel list in Data & Privacy reads English in every language. It was not, as assumed, the client's own naming -- the list called ToString() on the enum member, so it showed "FreeCompanyLoginLogout" while Language.ChatType_* sat next to it holding "Freie Gesellschaft (An-/Abmeldung)" in all 25 languages. Fixing the label exposed how little else the list had. Six translated strings written for exactly this control had no caller: the explanation of what it does, the note that it governs the database and not the chat window, and three presets -- data minimisation, select all, deselect all. Eighty-nine checkboxes without a "recommended" button is not a choice anyone makes. So the list now carries the same eight groups the export uses, with the individual channels one fold away. It is the one control that decides what reaches the database; the two screens describing channels the same way is worth more here than anywhere else. The unknown-channel switch gets its description back, and the telemetry section stops being the last English literal in the tab. It stays a statement rather than a switch: a toggle would imply there is something to turn off. --- .../Resources/HellionStrings.Designer.cs | 2 + HellionChat/Resources/HellionStrings.ca.resx | 6 ++ HellionChat/Resources/HellionStrings.cs.resx | 6 ++ HellionChat/Resources/HellionStrings.da.resx | 6 ++ HellionChat/Resources/HellionStrings.de.resx | 6 ++ HellionChat/Resources/HellionStrings.el.resx | 6 ++ HellionChat/Resources/HellionStrings.es.resx | 6 ++ HellionChat/Resources/HellionStrings.fi.resx | 6 ++ HellionChat/Resources/HellionStrings.fr.resx | 6 ++ HellionChat/Resources/HellionStrings.hu.resx | 6 ++ HellionChat/Resources/HellionStrings.it.resx | 6 ++ HellionChat/Resources/HellionStrings.ja.resx | 6 ++ HellionChat/Resources/HellionStrings.ko.resx | 6 ++ HellionChat/Resources/HellionStrings.nb.resx | 6 ++ HellionChat/Resources/HellionStrings.nl.resx | 6 ++ HellionChat/Resources/HellionStrings.pl.resx | 6 ++ .../Resources/HellionStrings.pt-BR.resx | 6 ++ .../Resources/HellionStrings.pt-PT.resx | 6 ++ HellionChat/Resources/HellionStrings.resx | 6 ++ HellionChat/Resources/HellionStrings.ro.resx | 6 ++ HellionChat/Resources/HellionStrings.ru.resx | 6 ++ HellionChat/Resources/HellionStrings.sv.resx | 6 ++ HellionChat/Resources/HellionStrings.tr.resx | 6 ++ HellionChat/Resources/HellionStrings.uk.resx | 6 ++ .../Resources/HellionStrings.zh-Hans.resx | 6 ++ .../Resources/HellionStrings.zh-Hant.resx | 6 ++ .../Settings/Tabs/DataPrivacyTab.cs | 91 +++++++++++++++---- 27 files changed, 224 insertions(+), 19 deletions(-) diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index e2b1f74..a6910a7 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -380,6 +380,8 @@ internal class HellionStrings internal static string Settings_Section_Retention => Get(nameof(Settings_Section_Retention)); internal static string Settings_Section_Cleanup => Get(nameof(Settings_Section_Cleanup)); internal static string Settings_Section_Export => Get(nameof(Settings_Section_Export)); + internal static string Settings_Section_Telemetry => Get(nameof(Settings_Section_Telemetry)); + internal static string Settings_Telemetry_None => Get(nameof(Settings_Telemetry_None)); internal static string Settings_Section_Database => Get(nameof(Settings_Section_Database)); // Hellion Chat — v1.2.1 Migration v15 → v16 toast diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index b445e98..13dfaa5 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1187,4 +1187,10 @@ No s'ha pogut esborrar l'historial. No s'ha eliminat res, consulta /xllog. + + Telemetria + + + No es recull cap telemetria. El complement no envia res sobre tu ni sobre el teu ús enlloc. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index 287615b..434ff9d 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1186,4 +1186,10 @@ Vymazání historie selhalo. Nic nebylo odstraněno, viz /xllog. + + Telemetrie + + + Nesbírá se žádná telemetrie. Plugin nikam neposílá nic o vás ani o vašem používání. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index 6f31c30..dc811a0 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1186,4 +1186,10 @@ Sletning af historikken mislykkedes. Intet blev fjernet, se /xllog. + + Telemetri + + + Der indsamles ingen telemetri. Pluginet sender intet om dig eller din brug nogen steder. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index a3a7f54..b67c823 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1181,4 +1181,10 @@ Das Löschen des Verlaufs ist fehlgeschlagen. Es wurde nichts entfernt, siehe /xllog. + + Telemetrie + + + Es wird keine Telemetrie erhoben. Das Plugin sendet nichts über dich oder deine Nutzung irgendwohin. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index b818787..4da4420 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1186,4 +1186,10 @@ Η διαγραφή του ιστορικού απέτυχε. Δεν αφαιρέθηκε τίποτα, δείτε /xllog. + + Τηλεμετρία + + + Δεν συλλέγεται τηλεμετρία. Το πρόσθετο δεν στέλνει πουθενά τίποτα για εσάς ή τη χρήση σας. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index 9f44dd6..71befcd 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1187,4 +1187,10 @@ No se pudo borrar el historial. No se eliminó nada, consulta /xllog. + + Telemetría + + + No se recopila telemetría. El complemento no envía nada sobre ti ni sobre tu uso a ninguna parte. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index a0a6f20..f30b33f 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1186,4 +1186,10 @@ Historian tyhjennys epäonnistui. Mitään ei poistettu, katso /xllog. + + Telemetria + + + Telemetriaa ei kerätä. Lisäosa ei lähetä mitään sinusta tai käytöstäsi minnekään. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index 81dd5f0..98e7e99 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1187,4 +1187,10 @@ L'effacement de l'historique a échoué. Rien n'a été supprimé, voir /xllog. + + Télémétrie + + + Aucune télémétrie n'est collectée. Le plugin n'envoie rien vous concernant ni sur votre utilisation. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index 8e023c9..39e2de0 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1186,4 +1186,10 @@ Az előzmények törlése nem sikerült. Semmi sem lett eltávolítva, lásd /xllog. + + Telemetria + + + Nem gyűjtünk telemetriát. A bővítmény semmit sem küld rólad vagy a használatodról sehová. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index f428f96..93a4e63 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1187,4 +1187,10 @@ La cancellazione della cronologia non è riuscita. Non è stato rimosso nulla, vedi /xllog. + + Telemetria + + + Non viene raccolta alcuna telemetria. Il plugin non invia nulla su di te o sul tuo utilizzo. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index b5c0bd1..2d292a1 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1187,4 +1187,10 @@ 履歴の削除に失敗しました。何も削除されていません。/xllog を確認してください。 + + テレメトリ + + + テレメトリは収集していません。あなたや利用状況に関する情報をどこにも送信しません。 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 1e0302e..31809e0 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1187,4 +1187,10 @@ 기록 삭제에 실패했습니다. 아무것도 삭제되지 않았습니다. /xllog를 확인하세요. + + 원격 측정 + + + 원격 측정 정보를 수집하지 않습니다. 사용자나 사용 내역에 관한 어떤 정보도 전송하지 않습니다. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index 3ddc5db..4269ae8 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1186,4 +1186,10 @@ Sletting av historikken mislyktes. Ingenting ble fjernet, se /xllog. + + Telemetri + + + Ingen telemetri samles inn. Programtillegget sender ingenting om deg eller bruken din noe sted. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index aa8eb65..2d325b7 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1187,4 +1187,10 @@ Het wissen van de geschiedenis is mislukt. Er is niets verwijderd, zie /xllog. + + Telemetrie + + + Er wordt geen telemetrie verzameld. De plug-in stuurt niets over jou of je gebruik ergens heen. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index 2381382..a0bddf8 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1186,4 +1186,10 @@ Usuwanie historii nie powiodło się. Nic nie zostało usunięte, zobacz /xllog. + + Telemetria + + + Nie zbieramy żadnej telemetrii. Wtyczka nigdzie nie wysyła informacji o tobie ani o twoim użytkowaniu. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index aa86657..8bc2429 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1187,4 +1187,10 @@ Falha ao apagar o histórico. Nada foi removido, veja /xllog. + + Telemetria + + + Nenhuma telemetria é coletada. O plugin não envia nada sobre você ou seu uso para lugar nenhum. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 06c1334..57383ab 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1186,4 +1186,10 @@ Falha ao apagar o histórico. Nada foi removido, consulte /xllog. + + Telemetria + + + Não é recolhida qualquer telemetria. O plugin não envia nada sobre si ou sobre a sua utilização. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index 9feaf9a..7cf4df6 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1207,4 +1207,10 @@ Clearing the history failed. Nothing was removed, see /xllog. + + Telemetry + + + No telemetry is collected. The plugin sends nothing about you or your usage anywhere. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index ba41ca9..145c502 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1187,4 +1187,10 @@ Ștergerea istoricului a eșuat. Nu a fost eliminat nimic, vezi /xllog. + + Telemetrie + + + Nu se colectează telemetrie. Extensia nu trimite nimic despre tine sau despre utilizarea ta nicăieri. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index 04c6f72..e7b8baf 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1187,4 +1187,10 @@ Не удалось удалить историю. Ничего не было удалено, см. /xllog. + + Телеметрия + + + Телеметрия не собирается. Плагин никуда не отправляет данные о вас или о вашем использовании. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index b8e95b3..b8f5610 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1187,4 +1187,10 @@ Raderingen av historiken misslyckades. Ingenting togs bort, se /xllog. + + Telemetri + + + Ingen telemetri samlas in. Tillägget skickar ingenting om dig eller din användning någonstans. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index 47dc6b3..07d3675 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1186,4 +1186,10 @@ Geçmiş silinemedi. Hiçbir şey kaldırılmadı, /xllog kaydına bakın. + + Telemetri + + + Hiçbir telemetri toplanmıyor. Eklenti sizinle veya kullanımınızla ilgili hiçbir şeyi hiçbir yere göndermiyor. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 51d3331..93695bb 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1186,4 +1186,10 @@ Не вдалося видалити історію. Нічого не було видалено, див. /xllog. + + Телеметрія + + + Телеметрія не збирається. Плагін нікуди не надсилає дані про вас або про ваше використання. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index a069fac..e62ce07 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1187,4 +1187,10 @@ 清空历史记录失败。没有删除任何内容,请查看 /xllog。 + + 遥测 + + + 不收集任何遥测数据。插件不会向任何地方发送关于你或你使用情况的信息。 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index 5ba3eed..1baea38 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1187,4 +1187,10 @@ 清除歷史紀錄失敗。沒有刪除任何內容,請查看 /xllog。 + + 遙測 + + + 不收集任何遙測資料。外掛不會向任何地方傳送關於你或你使用情況的資訊。 + \ No newline at end of file diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs index a9e293a..a889602 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -186,7 +186,7 @@ internal sealed class DataPrivacyTab _w.ToggleRow( ImGui.GetID("privacy.filter.unknown"u8), HellionStrings.Privacy_PersistUnknown_Name, - null, + HellionStrings.Privacy_PersistUnknown_Description, () => Plugin.Config.PrivacyPersistUnknownChannels, v => Plugin.Config.PrivacyPersistUnknownChannels = v ); @@ -226,11 +226,18 @@ internal sealed class DataPrivacyTab if (dbOpen) DrawDatabaseSection(); - if (_w.Section(ImGui.GetID("privacy.telemetry"u8), "Telemetry", open: false)) + if ( + _w.Section( + ImGui.GetID("privacy.telemetry"u8), + HellionStrings.Settings_Section_Telemetry, + open: false + ) + ) { - // Read-only placeholder; no telemetry is wired in v1.7.0. Do not - // promote this to a toggle without an explicit Sub-Spec change. - ImGui.TextUnformatted("No telemetry is collected."); + // Read-only statement, not a switch. Do not promote it to one + // without an explicit Sub-Spec change: a toggle implies there is + // something to turn off. + ImGuiUtil.HelpText(HellionStrings.Settings_Telemetry_None); } } @@ -1331,33 +1338,79 @@ internal sealed class DataPrivacyTab WrapperUtil.AddNotification(message, type); } + // The one control that decides what reaches the database, so it is worth + // being usable. Eighty-nine checkboxes in one flat run is not; the same + // eight groups the export uses carry it, with the individual channels one + // fold away for the cases that need them. private void DrawPrivacyPersistChannelsGrid() { - // HashSet: iterate Enum.GetValues() for stable - // display order (HashSet itself has none); toggle membership via - // Contains/Add/Remove. - ImGui.TextUnformatted("Persist channels:"); - foreach (var ct in Enum.GetValues()) + ImGuiUtil.HelpText(HellionStrings.Privacy_Whitelist_Help); + ImGuiUtil.HelpText(HellionStrings.Privacy_FilterEnabled_StorageOnly_Help); + + ImGui.Spacing(); + if (ImGui.Button(HellionStrings.Privacy_Preset_PrivacyFirst)) + ApplyPersistPreset(PrivacyDefaults.PrivacyFirstWhitelist); + ImGui.SameLine(); + if (ImGui.Button(HellionStrings.Privacy_Preset_SelectAll)) + ApplyPersistPreset(EnumValues.All); + ImGui.SameLine(); + if (ImGui.Button(HellionStrings.Privacy_Preset_ClearAll)) + ApplyPersistPreset([]); + + ImGui.Spacing(); + + for (var i = 0; i < ChannelGroups.All.Length; i++) { - var label = ct.ToString(); - var present = Plugin.Config.PrivacyPersistChannels.Contains(ct); - if (ImGui.Checkbox($"{label}##privacy-persist-{label}", ref present)) + var (heading, types) = ChannelGroups.All[i]; + + var picked = 0; + foreach (var type in types) + if (Plugin.Config.PrivacyPersistChannels.Contains(type)) + picked++; + + // ### keeps the node's identity while the visible count changes and + // while the language changes; without it every tick would collapse + // the fold the user just opened. + using var tree = ImRaii.TreeNode( + $"{heading()} ({picked}/{types.Length})###hc-persist-group-{i}" + ); + if (!tree.Success) + continue; + + foreach (var type in types) { + var present = Plugin.Config.PrivacyPersistChannels.Contains(type); + + // Name(), not ToString(): the enum member is an identifier, and + // this list showed "FreeCompanyLoginLogout" in every language + // while the translated channel name sat unused next to it. + if (!ImGui.Checkbox($"{type.Name()}##privacy-persist-{(int)type}", ref present)) + continue; + // Lock closes before SaveConfig: taking ConfigMapsLock across a save // would invert the lock order (SaveConfig can reach TabsListLock). lock (_plugin.ConfigMapsLock) { if (present) - { - Plugin.Config.PrivacyPersistChannels.Add(ct); - } + Plugin.Config.PrivacyPersistChannels.Add(type); else - { - Plugin.Config.PrivacyPersistChannels.Remove(ct); - } + Plugin.Config.PrivacyPersistChannels.Remove(type); } + _plugin.SaveConfig(); } } } + + private void ApplyPersistPreset(IReadOnlyCollection types) + { + lock (_plugin.ConfigMapsLock) + { + Plugin.Config.PrivacyPersistChannels.Clear(); + foreach (var type in types) + Plugin.Config.PrivacyPersistChannels.Add(type); + } + + _plugin.SaveConfig(); + } } From 1bb968211b87f11a81003baab05f70140f005323 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 22:35:13 +0200 Subject: [PATCH 113/129] feat(tabs): give pinning a way in, and a way back out Pinning has been complete since v1.4.7: pools, a cap of five, persistence, logout symmetry, the notification when the cap is hit. The menu that called it went away, and nothing has called it since. That is not only a missing feature, it is a dead end in saved data. A tab pinned in v1.5.6 survives every save and load and permanently occupies one of the five slots, and there has been no way anywhere to release it. Unpin is the reason this task exists. So the context menu grows a pin section for temp tabs, and the sidebar grows the marker that says which rows are pinned -- a small thumbtack in the icon's lower left, drawn from the row's own rectangle so it claims no layout of its own. The unread badge owns the upper right, and badges in this sidebar are where drawing into unreserved space caught this project last. At the cap the item is disabled rather than hidden: that is a state the user can undo by unpinning something, and the tooltip switches to say which. Five translated strings that had no caller now have one. Promote-to-permanent stays out. It was removed on purpose after a tester kept hitting it by accident. Reconnecting every method that lost its caller, without asking why it lost it, would rebuild the problem this cycle is supposed to be cleaning up. --- HellionChat/Ui/Components/Sidebar.cs | 39 ++++++++++++-- HellionChat/Ui/Components/TabContextMenu.cs | 57 +++++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index 9faa784..179eaa5 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -44,6 +44,10 @@ internal sealed class Sidebar internal int LastRenderedGreetedGlyphCount; internal int LastRenderedUnreadDotCount; + // Small enough to read as a marker on the icon rather than as a second icon + // beside it. + private const float PinGlyphScale = 0.6f; + // B3-4 render observability: section headers actually drawn this frame. // Incremented only in the real header branch; reset at Draw start. internal int LastDrawnSectionHeaderCount; @@ -348,15 +352,40 @@ internal sealed class Sidebar // height would misplace the glyph at any other body size. var iconStr = icon.ToIconString(); var iconSize = ImGui.CalcTextSize(iconStr); - dl.AddText( + var iconPos = origin - + new Vector2(iconInset + contentX, MetricsMath.CenterY(RowHeight, iconSize.Y)), - iconColor, - iconStr - ); + + new Vector2(iconInset + contentX, MetricsMath.CenterY(RowHeight, iconSize.Y)); + dl.AddText(iconPos, iconColor, iconStr); iconRight = iconInset + contentX + iconSize.X; + + // Pinned marker: a small thumbtack tucked into the icon's lower + // left. Drawn from the same font push and inside the row rectangle + // the InvisibleButton already reserved, so it claims no layout of + // its own -- badges in this very sidebar are where "draw into + // unreserved space" caught this project last. + // + // Lower left because the unread dot owns the upper right. + if (tab.IsPinned) + { + var pinStr = FontAwesomeIcon.Thumbtack.ToIconString(); + var pinFontSize = ImGui.GetFontSize() * PinGlyphScale; + var pinSize = ImGui.CalcTextSize(pinStr) * PinGlyphScale; + dl.AddText( + ImGui.GetFont(), + pinFontSize, + iconPos + new Vector2(-pinSize.X * 0.45f, iconSize.Y - pinSize.Y * 0.75f), + _palette.Abgr(Token.AccentPrimary, colors), + pinStr + ); + } } + // Only for pinned rows, and only in the sidebar's own hover state -- + // this is the one place a user meets the marker without having opened + // the menu that produced it. + if (tab.IsPinned && surfaceHovered) + ImGuiUtil.Tooltip(HellionStrings.PinTab_PinnedTooltip); + if (expanded) dl.AddText(origin + new Vector2(iconRight + 6f * scale, contentY), textAbgr, tab.Name); diff --git a/HellionChat/Ui/Components/TabContextMenu.cs b/HellionChat/Ui/Components/TabContextMenu.cs index f11b639..10ffa80 100644 --- a/HellionChat/Ui/Components/TabContextMenu.cs +++ b/HellionChat/Ui/Components/TabContextMenu.cs @@ -97,6 +97,63 @@ internal static class TabContextMenu if (ImGui.MenuItem("Pop Out")) pool.TryOpen(tab); + + DrawPinControls(tab); + } + + // Pinning has been complete since v1.4.7 -- pools, cap, persistence, logout + // symmetry, the notification -- and has had no way in since the menu that + // called it was removed. + // + // That left a dead end in saved data, which is the real reason this is here: + // a tab pinned in v1.5.6 survives every save and load, permanently occupying + // one of five pool slots, with nothing anywhere to release it. + // + // Promote-to-permanent deliberately does not come back. It was removed on + // purpose after a tester kept hitting it by accident, and reconnecting every + // caller-less method without asking why it lost its caller would rebuild the + // problem. + private static void DrawPinControls(Tab tab) + { + if (!tab.IsTempTab) + return; + + // Instance property today, not the static the old menu reached for. + var service = Plugin.Instance.AutoTellTabsService; + if (service is null) + return; + + ImGui.Separator(); + + if (tab.IsPinned) + { + if (ImGui.MenuItem(HellionStrings.PinTab_MenuUnpin)) + { + service.Unpin(tab); + ImGui.CloseCurrentPopup(); + } + + return; + } + + var atCap = service.PinnedTempTabCount >= AutoTellTabsService.MaxPinnedTempTabs; + + // Disabled rather than absent: the cap is a state the user can undo by + // unpinning something, and the tooltip below is what says so. + if (ImGui.MenuItem(HellionStrings.PinTab_MenuPin, enabled: !atCap) && service.TryPin(tab)) + ImGui.CloseCurrentPopup(); + + if (!ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + return; + + ImGuiUtil.Tooltip( + atCap + ? string.Format( + HellionStrings.PinTab_LimitReached, + AutoTellTabsService.MaxPinnedTempTabs + ) + : HellionStrings.PinTab_PinTooltip + ); } // The flush depends on Draw running once more for this tab. If it never does — From 75c4acd19a3ba936a7316da65bdffabc70cf29e3 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 22:40:54 +0200 Subject: [PATCH 114/129] feat(settings): make eleven settings reachable again Every field here has a reader in the running plugin and had no control in the window. The config file was the only way to any of them. HideChat is the one that mattered. It defaults to on, it suppresses the game's own chat window, and the only other path to it is a right-click item that sets it to true and can never set it back. Anyone who used that item once had to edit JSON to get their chat back. The new toggle reads and writes the same field, so the two agree instead of fighting. The rest, by tab: - Window: the three remaining hide conditions, the preview minimum that belongs to a toggle already on screen, native item tooltips and their offset. - Chat: an emotes section holding the BetterTTV switch, the cache state, and an editor for the blocked-code list that has been in the config since v1.0 with nowhere to edit it. Blocking one code is a finer instrument than switching emotes off wholesale. Plus auto-translate sorting. - General: the failed-tell warning. The game reports a failed tell in the log only, where somebody who is typing does not see it. - Data & Privacy: battle messages. It decides whether battle lines are persisted at all, which makes it a storage decision rather than a chat one, and it sits in front of the whitelist rather than inside it. MaxParallelPopouts stays out. It is read once in the pool's constructor, the windows are registered once, and registering at runtime is explicitly forbidden -- a slider would do nothing until a reload, which is exactly the kind of decoration this cycle exists to remove. Four new strings in all 25 languages; the other fifteen labels were already translated and waiting. --- .../Resources/HellionStrings.Designer.cs | 4 + HellionChat/Resources/HellionStrings.ca.resx | 12 ++ HellionChat/Resources/HellionStrings.cs.resx | 12 ++ HellionChat/Resources/HellionStrings.da.resx | 12 ++ HellionChat/Resources/HellionStrings.de.resx | 12 ++ HellionChat/Resources/HellionStrings.el.resx | 12 ++ HellionChat/Resources/HellionStrings.es.resx | 12 ++ HellionChat/Resources/HellionStrings.fi.resx | 12 ++ HellionChat/Resources/HellionStrings.fr.resx | 12 ++ HellionChat/Resources/HellionStrings.hu.resx | 12 ++ HellionChat/Resources/HellionStrings.it.resx | 12 ++ HellionChat/Resources/HellionStrings.ja.resx | 12 ++ HellionChat/Resources/HellionStrings.ko.resx | 12 ++ HellionChat/Resources/HellionStrings.nb.resx | 12 ++ HellionChat/Resources/HellionStrings.nl.resx | 12 ++ HellionChat/Resources/HellionStrings.pl.resx | 12 ++ .../Resources/HellionStrings.pt-BR.resx | 12 ++ .../Resources/HellionStrings.pt-PT.resx | 12 ++ HellionChat/Resources/HellionStrings.resx | 12 ++ HellionChat/Resources/HellionStrings.ro.resx | 12 ++ HellionChat/Resources/HellionStrings.ru.resx | 12 ++ HellionChat/Resources/HellionStrings.sv.resx | 12 ++ HellionChat/Resources/HellionStrings.tr.resx | 12 ++ HellionChat/Resources/HellionStrings.uk.resx | 12 ++ .../Resources/HellionStrings.zh-Hans.resx | 12 ++ .../Resources/HellionStrings.zh-Hant.resx | 12 ++ .../Ui/Components/Settings/Tabs/ChatTab.cs | 107 ++++++++++++++++-- .../Settings/Tabs/DataPrivacyTab.cs | 12 ++ .../Ui/Components/Settings/Tabs/GeneralTab.cs | 10 ++ .../Ui/Components/Settings/Tabs/WindowTab.cs | 74 ++++++++++++ 30 files changed, 496 insertions(+), 11 deletions(-) diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index a6910a7..7f57fef 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -381,6 +381,10 @@ internal class HellionStrings internal static string Settings_Section_Cleanup => Get(nameof(Settings_Section_Cleanup)); internal static string Settings_Section_Export => Get(nameof(Settings_Section_Export)); internal static string Settings_Section_Telemetry => Get(nameof(Settings_Section_Telemetry)); + internal static string Settings_Section_AutoTranslate => Get(nameof(Settings_Section_AutoTranslate)); + internal static string Settings_Emotes_Block => Get(nameof(Settings_Emotes_Block)); + internal static string Settings_NotifyFailedTell_Name => Get(nameof(Settings_NotifyFailedTell_Name)); + internal static string Settings_NotifyFailedTell_Description => Get(nameof(Settings_NotifyFailedTell_Description)); internal static string Settings_Telemetry_None => Get(nameof(Settings_Telemetry_None)); internal static string Settings_Section_Database => Get(nameof(Settings_Section_Database)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index 13dfaa5..63004cc 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1193,4 +1193,16 @@ No es recull cap telemetria. El complement no envia res sobre tu ni sobre el teu ús enlloc. + + Traducció automàtica + + + Bloqueja + + + Avisa quan un xiuxiueig no arriba + + + El joc només informa d'un xiuxiueig fallit al registre. Això mostra una notificació, perquè un missatge a algú desconnectat o d'un altre món no passi desapercebut. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index 434ff9d..9741bc4 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1192,4 +1192,16 @@ Nesbírá se žádná telemetrie. Plugin nikam neposílá nic o vás ani o vašem používání. + + Automatický překlad + + + Zablokovat + + + Upozornit, když šeptání nedorazí + + + Hra hlásí neúspěšné šeptání jen v logu. Toto místo toho zobrazí upozornění, aby zpráva někomu offline nebo na jiném světě nezůstala bez povšimnutí. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index dc811a0..976d7e9 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1192,4 +1192,16 @@ Der indsamles ingen telemetri. Pluginet sender intet om dig eller din brug nogen steder. + + Auto-oversættelse + + + Bloker + + + Advar når en tell ikke når frem + + + Spillet melder kun en mislykket tell i loggen. Dette giver en notifikation i stedet, så en besked til nogen offline eller på en anden verden ikke overses. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index b67c823..e075eee 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1187,4 +1187,16 @@ Es wird keine Telemetrie erhoben. Das Plugin sendet nichts über dich oder deine Nutzung irgendwohin. + + Auto-Übersetzung + + + Sperren + + + Warnen, wenn ein Flüstern nicht ankommt + + + Das Spiel meldet ein fehlgeschlagenes Flüstern nur im Protokoll. Das hier zeigt stattdessen eine Benachrichtigung, damit eine Nachricht an jemanden offline oder auf einer anderen Welt nicht untergeht. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index 4da4420..ff8a3db 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1192,4 +1192,16 @@ Δεν συλλέγεται τηλεμετρία. Το πρόσθετο δεν στέλνει πουθενά τίποτα για εσάς ή τη χρήση σας. + + Αυτόματη μετάφραση + + + Αποκλεισμός + + + Προειδοποίηση όταν ένα tell δεν φτάνει + + + Το παιχνίδι αναφέρει ένα αποτυχημένο tell μόνο στο αρχείο καταγραφής. Αυτό εμφανίζει ειδοποίηση, ώστε ένα μήνυμα σε κάποιον εκτός σύνδεσης ή σε άλλον κόσμο να μην περάσει απαρατήρητο. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index 71befcd..2fe74f7 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1193,4 +1193,16 @@ No se recopila telemetría. El complemento no envía nada sobre ti ni sobre tu uso a ninguna parte. + + Traducción automática + + + Bloquear + + + Avisar cuando un susurro no llega + + + El juego solo informa de un susurro fallido en el registro. Esto muestra una notificación, para que un mensaje a alguien desconectado o de otro mundo no pase desapercibido. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index f30b33f..e5904d0 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1192,4 +1192,16 @@ Telemetriaa ei kerätä. Lisäosa ei lähetä mitään sinusta tai käytöstäsi minnekään. + + Automaattikäännös + + + Estä + + + Varoita, kun kuiskaus ei mene perille + + + Peli ilmoittaa epäonnistuneesta kuiskauksesta vain lokissa. Tämä näyttää sen sijaan ilmoituksen, jottei viesti offline-tilassa tai toisessa maailmassa olevalle jää huomaamatta. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index 98e7e99..7790d0b 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1193,4 +1193,16 @@ Aucune télémétrie n'est collectée. Le plugin n'envoie rien vous concernant ni sur votre utilisation. + + Traduction automatique + + + Bloquer + + + Avertir quand un message privé n'arrive pas + + + Le jeu ne signale un message privé échoué que dans le journal. Ceci affiche une notification à la place, pour qu'un message à quelqu'un hors ligne ou sur un autre monde ne passe pas inaperçu. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index 39e2de0..f27d9aa 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1192,4 +1192,16 @@ Nem gyűjtünk telemetriát. A bővítmény semmit sem küld rólad vagy a használatodról sehová. + + Automatikus fordítás + + + Letiltás + + + Figyelmeztetés, ha egy suttogás nem érkezik meg + + + A játék csak a naplóban jelzi a sikertelen suttogást. Ez helyette értesítést jelenít meg, hogy egy offline vagy másik világon lévő játékosnak küldött üzenet ne maradjon észrevétlen. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index 93a4e63..31ebf8b 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1193,4 +1193,16 @@ Non viene raccolta alcuna telemetria. Il plugin non invia nulla su di te o sul tuo utilizzo. + + Traduzione automatica + + + Blocca + + + Avvisa quando un sussurro non arriva + + + Il gioco segnala un sussurro fallito solo nel registro. Questo mostra invece una notifica, così un messaggio a qualcuno offline o su un altro mondo non passa inosservato. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index 2d292a1..a6648e5 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1193,4 +1193,16 @@ テレメトリは収集していません。あなたや利用状況に関する情報をどこにも送信しません。 + + 定型文 + + + ブロック + + + テルが届かなかったときに警告する + + + ゲームはテルの失敗をログにしか表示しません。この設定では代わりに通知を出すので、オフラインや別ワールドの相手へのメッセージを見落としません。 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 31809e0..e362943 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1193,4 +1193,16 @@ 원격 측정 정보를 수집하지 않습니다. 사용자나 사용 내역에 관한 어떤 정보도 전송하지 않습니다. + + 정형문 + + + 차단 + + + 귓속말이 전달되지 않으면 알림 + + + 게임은 귓속말 실패를 로그에만 표시합니다. 이 설정은 대신 알림을 띄워, 오프라인이거나 다른 월드에 있는 상대에게 보낸 메시지를 놓치지 않게 합니다. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index 4269ae8..777a644 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1192,4 +1192,16 @@ Ingen telemetri samles inn. Programtillegget sender ingenting om deg eller bruken din noe sted. + + Auto-oversettelse + + + Blokker + + + Varsle når en tell ikke kommer frem + + + Spillet melder en mislykket tell bare i loggen. Dette gir et varsel i stedet, så en melding til noen offline eller på en annen verden ikke blir oversett. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 2d325b7..974a2d8 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1193,4 +1193,16 @@ Er wordt geen telemetrie verzameld. De plug-in stuurt niets over jou of je gebruik ergens heen. + + Automatische vertaling + + + Blokkeren + + + Waarschuwen als een tell niet aankomt + + + Het spel meldt een mislukte tell alleen in het log. Dit toont in plaats daarvan een melding, zodat een bericht aan iemand offline of op een andere wereld niet onopgemerkt blijft. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index a0bddf8..c6d2962 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1192,4 +1192,16 @@ Nie zbieramy żadnej telemetrii. Wtyczka nigdzie nie wysyła informacji o tobie ani o twoim użytkowaniu. + + Autotłumaczenie + + + Zablokuj + + + Ostrzegaj, gdy szept nie dotrze + + + Gra zgłasza nieudany szept tylko w dzienniku. To zamiast tego wyświetla powiadomienie, żeby wiadomość do kogoś offline lub z innego świata nie umknęła. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index 8bc2429..3e43666 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1193,4 +1193,16 @@ Nenhuma telemetria é coletada. O plugin não envia nada sobre você ou seu uso para lugar nenhum. + + Tradução automática + + + Bloquear + + + Avisar quando um sussurro não chega + + + O jogo relata um sussurro que falhou apenas no registro. Isto mostra uma notificação, para que uma mensagem a alguém offline ou de outro mundo não passe despercebida. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 57383ab..4e0c160 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1192,4 +1192,16 @@ Não é recolhida qualquer telemetria. O plugin não envia nada sobre si ou sobre a sua utilização. + + Tradução automática + + + Bloquear + + + Avisar quando um sussurro não chega + + + O jogo comunica um sussurro falhado apenas no registo. Isto mostra uma notificação, para que uma mensagem a alguém offline ou de outro mundo não passe despercebida. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index 7cf4df6..5e4e12f 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1213,4 +1213,16 @@ No telemetry is collected. The plugin sends nothing about you or your usage anywhere. + + Auto-translate + + + Block + + + Warn when a tell does not arrive + + + The game reports a failed tell in the log only. This raises a notification instead, so a message to someone offline or on another world does not go unnoticed. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index 145c502..5c723db 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1193,4 +1193,16 @@ Nu se colectează telemetrie. Extensia nu trimite nimic despre tine sau despre utilizarea ta nicăieri. + + Traducere automată + + + Blochează + + + Avertizează când un mesaj privat nu ajunge + + + Jocul raportează un mesaj privat eșuat doar în jurnal. Aceasta afișează în schimb o notificare, ca un mesaj către cineva offline sau de pe altă lume să nu treacă neobservat. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index e7b8baf..4bbc887 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1193,4 +1193,16 @@ Телеметрия не собирается. Плагин никуда не отправляет данные о вас или о вашем использовании. + + Автоперевод + + + Заблокировать + + + Предупреждать, когда шёпот не доходит + + + Игра сообщает о недоставленном шёпоте только в журнале. Здесь вместо этого показывается уведомление, чтобы сообщение офлайн-игроку или игроку с другого мира не осталось незамеченным. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index b8f5610..30c72ff 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1193,4 +1193,16 @@ Ingen telemetri samlas in. Tillägget skickar ingenting om dig eller din användning någonstans. + + Autoöversättning + + + Blockera + + + Varna när ett viskmeddelande inte kommer fram + + + Spelet rapporterar ett misslyckat viskmeddelande bara i loggen. Det här visar en avisering i stället, så att ett meddelande till någon offline eller på en annan värld inte missas. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index 07d3675..3a3a05a 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1192,4 +1192,16 @@ Hiçbir telemetri toplanmıyor. Eklenti sizinle veya kullanımınızla ilgili hiçbir şeyi hiçbir yere göndermiyor. + + Otomatik çeviri + + + Engelle + + + Bir fısıltı ulaşmadığında uyar + + + Oyun başarısız fısıltıyı yalnızca günlükte bildirir. Bu ayar bunun yerine bir bildirim gösterir, böylece çevrimdışı ya da başka dünyadaki birine gönderilen ileti gözden kaçmaz. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 93695bb..e24af8a 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1192,4 +1192,16 @@ Телеметрія не збирається. Плагін нікуди не надсилає дані про вас або про ваше використання. + + Автопереклад + + + Заблокувати + + + Попереджати, коли шепіт не доходить + + + Гра повідомляє про недоставлений шепіт лише в журналі. Це натомість показує сповіщення, щоб повідомлення офлайн-гравцю або гравцю з іншого світу не залишилося непоміченим. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index e62ce07..668ff4e 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1193,4 +1193,16 @@ 不收集任何遥测数据。插件不会向任何地方发送关于你或你使用情况的信息。 + + 自动翻译 + + + 屏蔽 + + + 密语未送达时提醒 + + + 游戏只在日志里提示密语发送失败。此设置改为弹出通知,让发给离线或异世界玩家的消息不会被忽略。 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index 1baea38..e0c3fc3 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1193,4 +1193,16 @@ 不收集任何遙測資料。外掛不會向任何地方傳送關於你或你使用情況的資訊。 + + 自動翻譯 + + + 封鎖 + + + 密語未送達時提醒 + + + 遊戲只在日誌裡提示密語傳送失敗。此設定改為彈出通知,讓發給離線或異世界玩家的訊息不會被忽略。 + \ No newline at end of file diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs index eab173a..0116007 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs @@ -1,6 +1,10 @@ using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using HellionChat.Resources; using HellionChat.Ui.StyleEngine; +using HellionChat.Util; namespace HellionChat.Ui.Components.Settings.Tabs; @@ -9,6 +13,8 @@ internal sealed class ChatTab private readonly Plugin _plugin; private readonly SettingsWidgets _w; + private string _blockedEmoteInput = string.Empty; + public ChatTab(Plugin plugin, TokenResolver resolver) { _plugin = plugin; @@ -34,17 +40,6 @@ internal sealed class ChatTab v => Plugin.Config.Use24HourClock = v ); - // PRIVACY.md names this switch as the way to stop the one outbound - // call the plugin makes, and it has had no control since May. A - // documented opt-out that only exists in the JSON is not an opt-out. - _w.ToggleRow( - ImGui.GetID("chat.display.emotes"u8), - Language.Options_ShowEmotes_Name, - Language.Options_ShowEmotes_Desc, - () => Plugin.Config.ShowEmotes, - v => Plugin.Config.ShowEmotes = v - ); - // Descriptions move out of the help markers and onto the row. They // were written to be read; a (?) the user has to hover is where an // explanation goes to be ignored. @@ -115,5 +110,95 @@ internal sealed class ChatTab v => Plugin.Config.NotifyPluginDisclosure = v ); } + + if ( + _w.Section( + ImGui.GetID("chat.emotes"u8), + HellionStrings.Settings_Section_Emotes, + open: false + ) + ) + { + DrawEmotes(); + } + + if ( + _w.Section( + ImGui.GetID("chat.autotranslate"u8), + HellionStrings.Settings_Section_AutoTranslate, + open: false + ) + ) + { + _w.ToggleRow( + ImGui.GetID("chat.autotranslate.sort"u8), + Language.Options_SortAutoTranslate_Name, + Language.Options_SortAutoTranslate_Description, + () => Plugin.Config.SortAutoTranslate, + v => Plugin.Config.SortAutoTranslate = v + ); + } + } + + // PRIVACY.md names the switch below as the way to stop the one outbound + // call the plugin makes, and it had no control at all since May. A + // documented opt-out that lives only in the JSON is not an opt-out. + private void DrawEmotes() + { + _w.ToggleRow( + ImGui.GetID("chat.emotes.show"u8), + Language.Options_ShowEmotes_Name, + Language.Options_ShowEmotes_Desc, + () => Plugin.Config.ShowEmotes, + v => Plugin.Config.ShowEmotes = v + ); + + if (!Plugin.Config.ShowEmotes) + return; + + ImGui.Spacing(); + ImGui.TextUnformatted( + EmoteCache.State == EmoteCache.LoadingState.Done + ? $"{Language.Options_Emote_Loaded} {EmoteCache.SortedCodeArray.Length:N0} ({Language.Options_Emote_Ready})" + : $"{Language.Options_Emote_Loaded} {Language.Options_Emote_NotReady}" + ); + + ImGui.Spacing(); + ImGui.TextUnformatted(Language.Options_Emote_BlockedEmotes); + + // A blocked code stops that one emote from ever being drawn or + // downloaded, which is a finer instrument than switching emotes off + // wholesale. The list has been in the config since v1.0 with nowhere to + // edit it. + ImGui.SetNextItemWidth(220f * ImGuiHelpers.GlobalScale); + ImGui.InputText("##hc-blocked-emote", ref _blockedEmoteInput, 64); + ImGui.SameLine(); + + var candidate = _blockedEmoteInput.Trim(); + using (ImRaii.Disabled(candidate.Length == 0)) + { + if (ImGui.Button(HellionStrings.Settings_Emotes_Block)) + { + lock (_plugin.ConfigMapsLock) + Plugin.Config.BlockedEmotes.Add(candidate); + _blockedEmoteInput = string.Empty; + _plugin.SaveConfig(); + } + } + + // Snapshot: the remove button mutates the set inside the loop. + foreach (var blocked in Plugin.Config.BlockedEmotes.OrderBy(e => e).ToList()) + { + using var id = ImRaii.PushId(blocked); + if (ImGuiUtil.IconButton(FontAwesomeIcon.Trash, "##remove")) + { + lock (_plugin.ConfigMapsLock) + Plugin.Config.BlockedEmotes.Remove(blocked); + _plugin.SaveConfig(); + } + + ImGui.SameLine(); + ImGui.TextUnformatted(blocked); + } } } diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs index a889602..1841ce3 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -190,6 +190,18 @@ internal sealed class DataPrivacyTab () => Plugin.Config.PrivacyPersistUnknownChannels, v => Plugin.Config.PrivacyPersistUnknownChannels = v ); + // Semantically a storage decision, not a chat one: it governs + // whether battle lines are persisted at all. It sits outside the + // channel list because the filter is a whitelist and this is a + // separate gate in front of it. + _w.ToggleRow( + ImGui.GetID("privacy.filter.battle"u8), + Language.Options_DatabaseBattleMessages_Name, + Language.Options_DatabaseBattleMessages_Description, + () => Plugin.Config.DatabaseBattleMessages, + v => Plugin.Config.DatabaseBattleMessages = v + ); + DrawPrivacyPersistChannelsGrid(); } diff --git a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs index 10a7653..3bec045 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs @@ -66,6 +66,16 @@ internal sealed class GeneralTab if (_w.Section(ImGui.GetID("general.notifications"u8), "Notifications")) { + // The game reports a failed tell in the log only, where a player who + // is typing does not see it. On by default and never reachable. + _w.ToggleRow( + ImGui.GetID("general.notifications.failedtell"u8), + HellionStrings.Settings_NotifyFailedTell_Name, + HellionStrings.Settings_NotifyFailedTell_Description, + () => Plugin.Config.NotifyFailedTell, + v => Plugin.Config.NotifyFailedTell = v + ); + _w.ToggleRow( ImGui.GetID("general.notifications.sounds"u8), Language.Options_PlaySounds_Name, diff --git a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs index f4feb8f..03849cc 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs @@ -138,6 +138,80 @@ internal sealed class WindowTab () => Plugin.Config.OnlyPreviewIf, v => Plugin.Config.OnlyPreviewIf = v ); + + // Partner of the toggle above and useless without it: how many + // characters have to be typed before the preview appears. + _w.SliderIntRow( + ImGui.GetID("window.preview.minimum"u8), + Language.Options_PreviewMinimum_Name, + Language.Options_PreviewMinimum_Description, + () => Plugin.Config.PreviewMinimum, + v => Plugin.Config.PreviewMinimum = v, + 0, + 20 + ); + } + + // HideChat is the one with real bite: it defaults to on, it suppresses + // the game's own chat window, and the only other way to it is a + // right-click item that can turn it on and never off. Somebody who used + // that item once had to edit JSON to get their chat back. + if (_w.Section(ImGui.GetID("window.hide"u8), HellionStrings.Settings_Section_Hide)) + { + _w.ToggleRow( + ImGui.GetID("window.hide.chat"u8), + Language.Options_HideChat_Name, + Language.Options_HideChat_Description, + () => Plugin.Config.HideChat, + v => Plugin.Config.HideChat = v + ); + _w.ToggleRow( + ImGui.GetID("window.hide.uihidden"u8), + Language.Options_HideWhenUiHidden_Name, + Language.Options_HideWhenUiHidden_Description, + () => Plugin.Config.HideWhenUiHidden, + v => Plugin.Config.HideWhenUiHidden = v + ); + _w.ToggleRow( + ImGui.GetID("window.hide.loading"u8), + Language.Options_HideInLoadingScreens_Name, + Language.Options_HideInLoadingScreens_Description, + () => Plugin.Config.HideInLoadingScreens, + v => Plugin.Config.HideInLoadingScreens = v + ); + _w.ToggleRow( + ImGui.GetID("window.hide.newgameplus"u8), + Language.Options_HideInNewGamePlusMenu_Name, + Language.Options_HideInNewGamePlusMenu_Description, + () => Plugin.Config.HideInNewGamePlusMenu, + v => Plugin.Config.HideInNewGamePlusMenu = v + ); + } + + if ( + _w.Section( + ImGui.GetID("window.tooltips"u8), + HellionStrings.Settings_Section_LinksTooltips, + open: false + ) + ) + { + _w.ToggleRow( + ImGui.GetID("window.tooltips.native"u8), + Language.Options_NativeItemTooltips_Name, + Language.Options_NativeItemTooltips_Description, + () => Plugin.Config.NativeItemTooltips, + v => Plugin.Config.NativeItemTooltips = v + ); + _w.SliderIntRow( + ImGui.GetID("window.tooltips.offset"u8), + Language.Options_TooltipOffset_Name, + Language.Options_TooltipOffset_Desc, + () => (int)Plugin.Config.TooltipOffset, + v => Plugin.Config.TooltipOffset = v, + 0, + 200 + ); } } } From 3cb20b6f6578af7c06cf33643c4dbc197cb05c86 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 22:43:09 +0200 Subject: [PATCH 115/129] feat(chat): insert map-flag and item-link tokens again, and finish the resx parity The two menu items went out with the v1.5.6 chat window in cf4705e. Their strings stayed, in the base file only, so the feature was gone and its labels were untranslated in all 24 other languages. They live in the chat context menu now, next to hide-chat, which is where the rest of that window's items ended up. The game expands and at send time, so appending the literal token is the whole implementation; each entry is disabled while its precondition is missing so a token cannot be sent only to expand into nothing at the other end. ChatLog_ScrollToBottom_Tooltip is the one that stings: it has had a caller since v1.9.0 and existed in the base file alone, so every player outside English read that tooltip in English. All three are translated now, and with them the resource files are at full parity for the first time -- 25 files, every key in every one. --- HellionChat/PayloadHandler.cs | 38 +++++++++++++++++++ HellionChat/Resources/HellionStrings.ca.resx | 9 +++++ HellionChat/Resources/HellionStrings.cs.resx | 9 +++++ HellionChat/Resources/HellionStrings.da.resx | 9 +++++ HellionChat/Resources/HellionStrings.de.resx | 9 +++++ HellionChat/Resources/HellionStrings.el.resx | 9 +++++ HellionChat/Resources/HellionStrings.es.resx | 9 +++++ HellionChat/Resources/HellionStrings.fi.resx | 9 +++++ HellionChat/Resources/HellionStrings.fr.resx | 9 +++++ HellionChat/Resources/HellionStrings.hu.resx | 9 +++++ HellionChat/Resources/HellionStrings.it.resx | 9 +++++ HellionChat/Resources/HellionStrings.ja.resx | 9 +++++ HellionChat/Resources/HellionStrings.ko.resx | 9 +++++ HellionChat/Resources/HellionStrings.nb.resx | 9 +++++ HellionChat/Resources/HellionStrings.nl.resx | 9 +++++ HellionChat/Resources/HellionStrings.pl.resx | 9 +++++ .../Resources/HellionStrings.pt-BR.resx | 9 +++++ .../Resources/HellionStrings.pt-PT.resx | 9 +++++ HellionChat/Resources/HellionStrings.ro.resx | 9 +++++ HellionChat/Resources/HellionStrings.ru.resx | 9 +++++ HellionChat/Resources/HellionStrings.sv.resx | 9 +++++ HellionChat/Resources/HellionStrings.tr.resx | 9 +++++ HellionChat/Resources/HellionStrings.uk.resx | 9 +++++ .../Resources/HellionStrings.zh-Hans.resx | 9 +++++ .../Resources/HellionStrings.zh-Hant.resx | 9 +++++ 25 files changed, 254 insertions(+) diff --git a/HellionChat/PayloadHandler.cs b/HellionChat/PayloadHandler.cs index 82143d8..c8d9b2a 100644 --- a/HellionChat/PayloadHandler.cs +++ b/HellionChat/PayloadHandler.cs @@ -15,6 +15,7 @@ using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; using FFXIVClientStructs.FFXIV.Component.GUI; using HellionChat.Code; using HellionChat.Resources; @@ -191,6 +192,8 @@ internal sealed class PayloadHandler if (ImGui.Selectable(Language.Context_HideChat)) Plugin.Config.HideChat = true; + DrawTokenInserts(); + if (chunk.Message is { } message) { if (ImGui.Selectable(Language.Context_Copy)) @@ -936,4 +939,39 @@ internal sealed class PayloadHandler _popup = (chunk, payload); ImGui.OpenPopup(PopupId); } + + // The game expands and at send time, so inserting the literal + // token is enough. Both entries were in the v1.5.6 chat window and went out + // with it; the strings survived, untranslated in 24 files until now. + // + // Disabled when the precondition is missing, so the token cannot be sent + // only to expand into nothing on the other side. + private void DrawTokenInserts() + { + bool flagSet; + bool itemSet; + unsafe + { + flagSet = AgentMap.Instance()->FlagMarkerCount > 0; + itemSet = AgentChatLog.Instance()->LinkedItem.ItemId != 0; + } + + using (ImRaii.Disabled(!flagSet)) + { + if (ImGui.Selectable(HellionStrings.ChatLog_Insert_MapFlag)) + AppendToInput(""); + } + + using (ImRaii.Disabled(!itemSet)) + { + if (ImGui.Selectable(HellionStrings.ChatLog_Insert_ItemLink)) + AppendToInput(""); + } + } + + private void AppendToInput(string token) + { + var current = _inputBar.PendingMessage; + _inputBar.SetPendingMessage(current + token); + } } diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index 63004cc..c238ed5 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1205,4 +1205,13 @@ El joc només informa d'un xiuxiueig fallit al registre. Això mostra una notificació, perquè un missatge a algú desconnectat o d'un altre món no passi desapercebut. + + Vés al missatge més recent + + + Insereix la marca del mapa <flag> + + + Insereix l'objecte enllaçat <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index 9741bc4..dca569f 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1204,4 +1204,13 @@ Hra hlásí neúspěšné šeptání jen v logu. Toto místo toho zobrazí upozornění, aby zpráva někomu offline nebo na jiném světě nezůstala bez povšimnutí. + + Přejít na nejnovější zprávu + + + Vložit značku mapy <flag> + + + Vložit odkázaný předmět <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index 976d7e9..af39218 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1204,4 +1204,13 @@ Spillet melder kun en mislykket tell i loggen. Dette giver en notifikation i stedet, så en besked til nogen offline eller på en anden verden ikke overses. + + Hop til den seneste besked + + + Indsæt kortmarkering <flag> + + + Indsæt linket genstand <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index e075eee..d86d3fa 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1199,4 +1199,13 @@ Das Spiel meldet ein fehlgeschlagenes Flüstern nur im Protokoll. Das hier zeigt stattdessen eine Benachrichtigung, damit eine Nachricht an jemanden offline oder auf einer anderen Welt nicht untergeht. + + Zur neuesten Nachricht springen + + + Kartenmarkierung einfügen <flag> + + + Verlinkten Gegenstand einfügen <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index ff8a3db..d0d44c0 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1204,4 +1204,13 @@ Το παιχνίδι αναφέρει ένα αποτυχημένο tell μόνο στο αρχείο καταγραφής. Αυτό εμφανίζει ειδοποίηση, ώστε ένα μήνυμα σε κάποιον εκτός σύνδεσης ή σε άλλον κόσμο να μην περάσει απαρατήρητο. + + Μετάβαση στο πιο πρόσφατο μήνυμα + + + Εισαγωγή σημαίας χάρτη <flag> + + + Εισαγωγή συνδεδεμένου αντικειμένου <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index 2fe74f7..b550565 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1205,4 +1205,13 @@ El juego solo informa de un susurro fallido en el registro. Esto muestra una notificación, para que un mensaje a alguien desconectado o de otro mundo no pase desapercibido. + + Ir al mensaje más reciente + + + Insertar marca del mapa <flag> + + + Insertar objeto enlazado <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index e5904d0..4527a49 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1204,4 +1204,13 @@ Peli ilmoittaa epäonnistuneesta kuiskauksesta vain lokissa. Tämä näyttää sen sijaan ilmoituksen, jottei viesti offline-tilassa tai toisessa maailmassa olevalle jää huomaamatta. + + Siirry uusimpaan viestiin + + + Lisää karttamerkki <flag> + + + Lisää linkitetty esine <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index 7790d0b..3385228 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1205,4 +1205,13 @@ Le jeu ne signale un message privé échoué que dans le journal. Ceci affiche une notification à la place, pour qu'un message à quelqu'un hors ligne ou sur un autre monde ne passe pas inaperçu. + + Aller au message le plus récent + + + Insérer le marqueur de carte <flag> + + + Insérer l'objet lié <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index f27d9aa..89231d5 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1204,4 +1204,13 @@ A játék csak a naplóban jelzi a sikertelen suttogást. Ez helyette értesítést jelenít meg, hogy egy offline vagy másik világon lévő játékosnak küldött üzenet ne maradjon észrevétlen. + + Ugrás a legutóbbi üzenetre + + + Térképjelölő beszúrása <flag> + + + Hivatkozott tárgy beszúrása <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index 31ebf8b..e21a16f 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1205,4 +1205,13 @@ Il gioco segnala un sussurro fallito solo nel registro. Questo mostra invece una notifica, così un messaggio a qualcuno offline o su un altro mondo non passa inosservato. + + Vai al messaggio più recente + + + Inserisci il segnalino mappa <flag> + + + Inserisci l'oggetto collegato <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index a6648e5..99fa915 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1205,4 +1205,13 @@ ゲームはテルの失敗をログにしか表示しません。この設定では代わりに通知を出すので、オフラインや別ワールドの相手へのメッセージを見落としません。 + + 最新のメッセージへ移動 + + + マップフラグを挿入 <flag> + + + リンクしたアイテムを挿入 <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index e362943..645788a 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1205,4 +1205,13 @@ 게임은 귓속말 실패를 로그에만 표시합니다. 이 설정은 대신 알림을 띄워, 오프라인이거나 다른 월드에 있는 상대에게 보낸 메시지를 놓치지 않게 합니다. + + 최신 메시지로 이동 + + + 지도 표시 삽입 <flag> + + + 연결된 아이템 삽입 <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index 777a644..c9e9ee5 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1204,4 +1204,13 @@ Spillet melder en mislykket tell bare i loggen. Dette gir et varsel i stedet, så en melding til noen offline eller på en annen verden ikke blir oversett. + + Hopp til den nyeste meldingen + + + Sett inn kartmarkør <flag> + + + Sett inn lenket gjenstand <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 974a2d8..215be2f 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1205,4 +1205,13 @@ Het spel meldt een mislukte tell alleen in het log. Dit toont in plaats daarvan een melding, zodat een bericht aan iemand offline of op een andere wereld niet onopgemerkt blijft. + + Ga naar het nieuwste bericht + + + Kaartmarkering invoegen <flag> + + + Gekoppeld voorwerp invoegen <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index c6d2962..7d02ae7 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1204,4 +1204,13 @@ Gra zgłasza nieudany szept tylko w dzienniku. To zamiast tego wyświetla powiadomienie, żeby wiadomość do kogoś offline lub z innego świata nie umknęła. + + Przejdź do najnowszej wiadomości + + + Wstaw znacznik mapy <flag> + + + Wstaw powiązany przedmiot <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index 3e43666..d97fa64 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1205,4 +1205,13 @@ O jogo relata um sussurro que falhou apenas no registro. Isto mostra uma notificação, para que uma mensagem a alguém offline ou de outro mundo não passe despercebida. + + Ir para a mensagem mais recente + + + Inserir marcação do mapa <flag> + + + Inserir item vinculado <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 4e0c160..9e9813b 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1204,4 +1204,13 @@ O jogo comunica um sussurro falhado apenas no registo. Isto mostra uma notificação, para que uma mensagem a alguém offline ou de outro mundo não passe despercebida. + + Ir para a mensagem mais recente + + + Inserir marcação do mapa <flag> + + + Inserir item ligado <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index 5c723db..eed5fcb 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1205,4 +1205,13 @@ Jocul raportează un mesaj privat eșuat doar în jurnal. Aceasta afișează în schimb o notificare, ca un mesaj către cineva offline sau de pe altă lume să nu treacă neobservat. + + Salt la cel mai recent mesaj + + + Inserează marcajul hărții <flag> + + + Inserează obiectul legat <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index 4bbc887..96e5a9b 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1205,4 +1205,13 @@ Игра сообщает о недоставленном шёпоте только в журнале. Здесь вместо этого показывается уведомление, чтобы сообщение офлайн-игроку или игроку с другого мира не осталось незамеченным. + + Перейти к последнему сообщению + + + Вставить метку карты <flag> + + + Вставить связанный предмет <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index 30c72ff..f563572 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1205,4 +1205,13 @@ Spelet rapporterar ett misslyckat viskmeddelande bara i loggen. Det här visar en avisering i stället, så att ett meddelande till någon offline eller på en annan värld inte missas. + + Hoppa till det senaste meddelandet + + + Infoga kartmarkering <flag> + + + Infoga länkat föremål <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index 3a3a05a..14c9e78 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1204,4 +1204,13 @@ Oyun başarısız fısıltıyı yalnızca günlükte bildirir. Bu ayar bunun yerine bir bildirim gösterir, böylece çevrimdışı ya da başka dünyadaki birine gönderilen ileti gözden kaçmaz. + + En son iletiye git + + + Harita işaretini ekle <flag> + + + Bağlantılı eşyayı ekle <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index e24af8a..2a49c92 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1204,4 +1204,13 @@ Гра повідомляє про недоставлений шепіт лише в журналі. Це натомість показує сповіщення, щоб повідомлення офлайн-гравцю або гравцю з іншого світу не залишилося непоміченим. + + Перейти до останнього повідомлення + + + Вставити позначку карти <flag> + + + Вставити пов'язаний предмет <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index 668ff4e..0264a9c 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1205,4 +1205,13 @@ 游戏只在日志里提示密语发送失败。此设置改为弹出通知,让发给离线或异世界玩家的消息不会被忽略。 + + 跳到最新消息 + + + 插入地图标记 <flag> + + + 插入关联物品 <item> + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index e0c3fc3..5f84eaf 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1205,4 +1205,13 @@ 遊戲只在日誌裡提示密語傳送失敗。此設定改為彈出通知,讓發給離線或異世界玩家的訊息不會被忽略。 + + 跳到最新訊息 + + + 插入地圖標記 <flag> + + + 插入關聯物品 <item> + \ No newline at end of file From e8d06e05fa206e52c6fc1a0313125632027fb466 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 22:45:36 +0200 Subject: [PATCH 116/129] fix(input): put the right-click menu back on the input field Reported by a tester: the input field in v1.5.6 had right-click actions, the current one has no right-click at all. Correct, and the commit before this one put those actions in the wrong place. The v1.5.6 menu was ImRaii.ContextPopupItem("ChatInputContext") bound to the input field itself; I hung the two token entries off the chat-message menu, where the rest of that window's items had landed. They are on the input field now, which is also where they are useful: you insert a token while composing, not while reading. Two details from the original that the misplaced version had lost: - The agent pointers are null-checked. Both can be null during a zone transition, which is exactly when somebody is most likely to be typing a flag into party chat. - Inserting refocuses the field and puts the caret behind the token. Picking from a menu and then having to click back into the field is the kind of friction that makes a feature go unused. Hiding the chat is not repeated in the menu. It was in the v1.5.6 one, but it has its own button two widgets to the right now, and one way in is enough. --- HellionChat/PayloadHandler.cs | 38 ----------------- HellionChat/Ui/Components/InputBar.cs | 59 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 38 deletions(-) diff --git a/HellionChat/PayloadHandler.cs b/HellionChat/PayloadHandler.cs index c8d9b2a..82143d8 100644 --- a/HellionChat/PayloadHandler.cs +++ b/HellionChat/PayloadHandler.cs @@ -15,7 +15,6 @@ using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.UI; -using FFXIVClientStructs.FFXIV.Client.UI.Agent; using FFXIVClientStructs.FFXIV.Component.GUI; using HellionChat.Code; using HellionChat.Resources; @@ -192,8 +191,6 @@ internal sealed class PayloadHandler if (ImGui.Selectable(Language.Context_HideChat)) Plugin.Config.HideChat = true; - DrawTokenInserts(); - if (chunk.Message is { } message) { if (ImGui.Selectable(Language.Context_Copy)) @@ -939,39 +936,4 @@ internal sealed class PayloadHandler _popup = (chunk, payload); ImGui.OpenPopup(PopupId); } - - // The game expands and at send time, so inserting the literal - // token is enough. Both entries were in the v1.5.6 chat window and went out - // with it; the strings survived, untranslated in 24 files until now. - // - // Disabled when the precondition is missing, so the token cannot be sent - // only to expand into nothing on the other side. - private void DrawTokenInserts() - { - bool flagSet; - bool itemSet; - unsafe - { - flagSet = AgentMap.Instance()->FlagMarkerCount > 0; - itemSet = AgentChatLog.Instance()->LinkedItem.ItemId != 0; - } - - using (ImRaii.Disabled(!flagSet)) - { - if (ImGui.Selectable(HellionStrings.ChatLog_Insert_MapFlag)) - AppendToInput(""); - } - - using (ImRaii.Disabled(!itemSet)) - { - if (ImGui.Selectable(HellionStrings.ChatLog_Insert_ItemLink)) - AppendToInput(""); - } - } - - private void AppendToInput(string token) - { - var current = _inputBar.PendingMessage; - _inputBar.SetPendingMessage(current + token); - } } diff --git a/HellionChat/Ui/Components/InputBar.cs b/HellionChat/Ui/Components/InputBar.cs index bd3b4f0..60bac08 100644 --- a/HellionChat/Ui/Components/InputBar.cs +++ b/HellionChat/Ui/Components/InputBar.cs @@ -5,6 +5,7 @@ using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; using HellionChat._Helpers; using HellionChat.Code; using HellionChat.GameFunctions; @@ -342,10 +343,68 @@ internal sealed class InputBar _commandHelpWindow.IsOpen = false; TrySend(activeTab); } + DrawInputContextMenu(); + _isFocused = ImGui.IsItemFocused(); _wasInputTextHovered = ImGui.IsItemHovered(); } + // Right-clicking the input field opened this in v1.5.6 and has opened + // nothing since the chat window was retired. Reported by a tester who went + // looking for the map-flag entry. + // + // Must sit immediately after the InputText call: ContextPopupItem binds to + // the last submitted item. + // + // Hiding the chat is not repeated here -- it has its own button two widgets + // to the right, and one way in is enough. + private void DrawInputContextMenu() + { + using var context = ImRaii.ContextPopupItem("##hellion-input-context"); + if (!context.Success) + return; + + // The game expands and at send time, so inserting the + // literal token is the whole implementation. Each entry is disabled + // while its precondition is missing, so the token cannot be sent only + // to expand into nothing at the other end. + bool flagSet; + bool itemSet; + unsafe + { + // Null before dereferencing: both agents can be null during a zone + // transition, which is precisely when somebody is most likely to be + // typing a flag into a party chat. + var map = AgentMap.Instance(); + var chatLog = AgentChatLog.Instance(); + flagSet = map != null && map->FlagMarkerCount > 0; + itemSet = chatLog != null && chatLog->LinkedItem.ItemId != 0; + } + + using (ImRaii.Disabled(!flagSet)) + { + if (ImGui.Selectable(HellionStrings.ChatLog_Insert_MapFlag)) + InsertToken(""); + } + + using (ImRaii.Disabled(!itemSet)) + { + if (ImGui.Selectable(HellionStrings.ChatLog_Insert_ItemLink)) + InsertToken(""); + } + } + + // Focus returns to the field and the caret lands behind the token, so the + // user can keep typing. Picking from a menu and then having to click back + // into the field is the kind of small friction that makes a feature go + // unused. + private void InsertToken(string token) + { + SetPendingMessage(_pendingMessage + token); + Activate = true; + _activatePos = _pendingMessage.Length; + } + // Dispatches across three ImGui callback events: CallbackAlways (cursor // restore after popup commit), CallbackCompletion (Tab opens the auto- // translate picker), CallbackEdit (slash-command help window sync). From 6829a80ff20e368cf54e39c9edcd7a95ca1c0a6d Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 22:54:06 +0200 Subject: [PATCH 117/129] refactor(wizard): drop LoadPreviousSession, which never did anything The wizard asked for it, coupled it to a neighbour, listed it in the summary as applied, and wrote it to the config. No code in the plugin has ever read it. A wizard that collects a decision and reports an effect that does not happen is worse than one that never asked. Its partner, FilterIncludePreviousSessions, has a real reader and stays. The coupling was two-way -- switching this on forced the partner on, switching the partner off forced this one off -- so removing it leaves a single checkbox that means what it says, and the summary line now reports the setting that actually took effect. The self-test asserted that skipping step 3 does not overwrite either field. That assertion is rewritten rather than repaired: it was pinning the null-semantics of the wizard's pending state, which still matters, just with one field instead of two. --- HellionChat/Configuration.cs | 2 - HellionChat/SelfTests/WizardStateSmokeStep.cs | 16 ++---- HellionChat/Ui/FirstRunWizard.cs | 50 +++++++------------ 3 files changed, 22 insertions(+), 46 deletions(-) diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index eff9477..c1291c5 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -227,7 +227,6 @@ public class Configuration : IPluginConfiguration public bool ShowTitleBar = true; public bool ShowPopOutTitleBar = true; public bool DatabaseBattleMessages; - public bool LoadPreviousSession; public bool FilterIncludePreviousSessions; public bool SortAutoTranslate; public bool CollapseDuplicateMessages; @@ -362,7 +361,6 @@ public class Configuration : IPluginConfiguration ShowTitleBar = other.ShowTitleBar; ShowPopOutTitleBar = other.ShowPopOutTitleBar; DatabaseBattleMessages = other.DatabaseBattleMessages; - LoadPreviousSession = other.LoadPreviousSession; FilterIncludePreviousSessions = other.FilterIncludePreviousSessions; SortAutoTranslate = other.SortAutoTranslate; CollapseDuplicateMessages = other.CollapseDuplicateMessages; diff --git a/HellionChat/SelfTests/WizardStateSmokeStep.cs b/HellionChat/SelfTests/WizardStateSmokeStep.cs index f815314..61ba2b9 100644 --- a/HellionChat/SelfTests/WizardStateSmokeStep.cs +++ b/HellionChat/SelfTests/WizardStateSmokeStep.cs @@ -62,11 +62,11 @@ internal sealed class WizardStateSmokeStep : ISelfTestStep // Variant 2: skip Step 3 explicitly. Picks Roleplay on Step 2, // jumps straight to Step 4 (no Step-3 entry → no seed for - // LoadPreviousSession / FilterIncludePreviousSessions), commits, - // and asserts the two coupled history toggles remained on their - // pre-test value. Pins the null-semantics from Spec Z.176 so a - // regression in CommitPending that started writing seeded - // recommendations unconditionally would surface here. + // FilterIncludePreviousSessions), commits, and asserts the history + // toggle remained on its pre-test value. Pins the null-semantics + // from Spec Z.176 so a regression in CommitPending that started + // writing seeded recommendations unconditionally would surface + // here. // CommitPending → ApplyRoleplay overwrites six privacy / // retention fields, so snapshot them first and let CleanUp // restore them after the assert. Keeps /xlperf idempotent. @@ -79,17 +79,11 @@ internal sealed class WizardStateSmokeStep : ISelfTestStep this.snapshotRetentionDefaultDays = Plugin.Config.RetentionDefaultDays; this.snapshotRetentionPerChannelDays = Plugin.Config.RetentionPerChannelDays; - var loadPrevBefore = Plugin.Config.LoadPreviousSession; var filterPrevBefore = Plugin.Config.FilterIncludePreviousSessions; wizard.TestOnly_AdvanceTo(2); wizard.TestOnly_SetPendingProfile(FirstRunWizard.PrivacyProfile.Roleplay); wizard.TestOnly_AdvanceTo(4); wizard.CommitPending(); - if (Plugin.Config.LoadPreviousSession != loadPrevBefore) - { - ImGui.Text("Skip-Step-3 path overwrote LoadPreviousSession"); - return SelfTestStepResult.Fail; - } if (Plugin.Config.FilterIncludePreviousSessions != filterPrevBefore) { ImGui.Text("Skip-Step-3 path overwrote FilterIncludePreviousSessions"); diff --git a/HellionChat/Ui/FirstRunWizard.cs b/HellionChat/Ui/FirstRunWizard.cs index da28c30..240badf 100644 --- a/HellionChat/Ui/FirstRunWizard.cs +++ b/HellionChat/Ui/FirstRunWizard.cs @@ -341,17 +341,16 @@ public sealed class FirstRunWizard : Window private void DrawStepPowerSettings() { - // Seed only the two recommendation fields here. Other fields remain - // null until the user touches the corresponding control. - // Spec FR-4: the wizard explicitly recommends LoadPreviousSession = - // true and FilterIncludePreviousSessions = true (Config defaults are - // false). The other four fields (AutoTellTabsHistoryPreload, - // UseCompactDensity, PrettierTimestamps, Theme) follow the generic - // null-semantics from Spec Z.176: a null pending means the user did - // not touch that control, so CommitPending must not write back. They - // are read live from Plugin.Config below for the ImGui ref-binding - // but never seeded into Pending* without a user gesture. - _state.PendingLoadPreviousSession ??= true; + // Seed only the recommendation field here. Other fields remain null + // until the user touches the corresponding control. + // Spec FR-4: the wizard explicitly recommends + // FilterIncludePreviousSessions = true (the Config default is false). + // The other four fields (AutoTellTabsHistoryPreload, UseCompactDensity, + // PrettierTimestamps, Theme) follow the generic null-semantics from + // Spec Z.176: a null pending means the user did not touch that control, + // so CommitPending must not write back. They are read live from + // Plugin.Config below for the ImGui ref-binding but never seeded into + // Pending* without a user gesture. _state.PendingFilterIncludePreviousSessions ??= true; ImGui.TextUnformatted(HellionStrings.Wizard_Step3_Title); @@ -361,20 +360,11 @@ public sealed class FirstRunWizard : Window using (ImRaii.PushColor(ImGuiCol.Text, ForgeBronze)) ImGui.TextUnformatted(HellionStrings.Wizard_Step3_Section_History); - var loadPrev = _state.PendingLoadPreviousSession ?? true; - if (ImGui.Checkbox(HellionStrings.Wizard_Step3_LoadPreviousSession_Label, ref loadPrev)) - { - _state.PendingLoadPreviousSession = loadPrev; - // Mirror the DataAndPrivacy coupling: turning load-previous on - // also turns filter-include on (otherwise old messages bypass - // the filter chain), and turning filter-include off forces - // load-previous off. The settings tab that used to hold the same - // pairing was removed in May; the coupling lives here alone until - // the two checkboxes are reachable again. - if (loadPrev) - _state.PendingFilterIncludePreviousSessions = true; - } - + // One checkbox, not two. LoadPreviousSession was asked for here, shown + // as applied in the summary and written to the config, and no code in + // the plugin has ever read it -- so the wizard was collecting a + // decision and reporting an effect that never happened. Its partner + // does the work on its own. var filterPrev = _state.PendingFilterIncludePreviousSessions ?? true; if ( ImGui.Checkbox( @@ -384,8 +374,6 @@ public sealed class FirstRunWizard : Window ) { _state.PendingFilterIncludePreviousSessions = filterPrev; - if (!filterPrev) - _state.PendingLoadPreviousSession = false; } ImGui.Spacing(); @@ -496,8 +484,8 @@ public sealed class FirstRunWizard : Window ); var historyLabel = - (_state.PendingLoadPreviousSession ?? false) - ? HellionStrings.Wizard_Step3_LoadPreviousSession_Label + (_state.PendingFilterIncludePreviousSessions ?? false) + ? HellionStrings.Wizard_Step3_FilterIncludePreviousSessions_Label : HellionStrings.Wizard_Step4_Summary_Unchanged; ImGui.TextWrapped( string.Format(HellionStrings.Wizard_Step4_Summary_History, historyLabel) @@ -573,9 +561,6 @@ public sealed class FirstRunWizard : Window break; } - if (_state.PendingLoadPreviousSession.HasValue) - Plugin.Config.LoadPreviousSession = _state.PendingLoadPreviousSession.Value; - if (_state.PendingFilterIncludePreviousSessions.HasValue) Plugin.Config.FilterIncludePreviousSessions = _state .PendingFilterIncludePreviousSessions @@ -675,7 +660,6 @@ public sealed class FirstRunWizard : Window { public int CurrentStep { get; set; } = 1; public PrivacyProfile? PendingProfile { get; set; } - public bool? PendingLoadPreviousSession { get; set; } public bool? PendingFilterIncludePreviousSessions { get; set; } public int? PendingAutoTellTabsHistoryPreload { get; set; } public bool? PendingUseCompactDensity { get; set; } From 8e38e3e805700bf127be14ed2a8613c12169035c Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 23:36:54 +0200 Subject: [PATCH 118/129] refactor(config): delete what was never read, restore what was only orphaned Block D of v1.12.0. The line between the two is the whole job, and I got it wrong once on the way: six cache fields on Tab looked dead because nothing writes them, and nothing writes them because AutoTellTabTint and TabTintCache went out with the chat window in cf4705e. Deleting the fields would have cemented a loss instead of recording a decision. So they are back, and the sidebar uses them again: an auto-tell tab is tinted and glyphed from its partner, twelve colours against seven icons. Four open tells are no longer four identical envelopes in one colour. Their own header promised the same partner keeps its colour "across sessions" while hashing with string.GetHashCode, which .NET salts per process -- every game start reshuffled every tab. FNV-1a now, with a lowbias32 finalizer that is not decoration: without it a probe over 144 similar keys reached six of the twelve colours, because the caller takes the low bits with a modulo and FNV leaves those correlated. Three pinned values guard it, which is also the only assertion that can catch a regression to a salted hash. The same question, asked of the three hide conditions this block had quietly orphaned: HideDuringCutscenes, HideInBattle, HideWhenNotLoggedIn all had readers in v1.5.6 and lost them in the same commit. Two of them are states rather than conditions -- a cutscene the user dismissed stays dismissed until it ends, and combat must not seize a chat that is already hidden for another reason -- so they come back as a small state machine with eight pinned transitions, and three toggles whose labels were already translated in all 25 languages. Actually deleted, with a reader search each time: - Six per-tab hide fields. Their reader was the pop-out window and it stopped consulting them in cf4705e. Per-tab was the wrong unit anyway: "hide during cutscenes" is a statement about the screen. - Tab.ChatCodes, whose migration the v16 schema gate had already made unreachable. - InactivityHideTimeout and InactivityHideActiveDuringBattle, MaxLinesToRender which had stopped bounding anything, and the 155 lines of Configuration.UpdateFrom with no caller at all. Config version 25, at all three places that carry it. No migration step: the gate only refuses anything under 16 and Json.NET drops keys it does not know, so the deleted fields simply stop being written. One thing a review pass caught that matters more than any of the above: the clone parity guard had gone hollow. It compares collections by count, ChatCodes was the only collection the probe seeded, and removing it left the guard comparing zero against zero. Verified by making Tab.Clone discard both remaining collections and watching every assertion stay green. The probe seeds them now, and the same sabotage fails as it should. --- HellionChat/Configuration.cs | 197 ++---------------- HellionChat/Plugin.cs | 44 +++- .../Resources/HellionStrings.Designer.cs | 1 + HellionChat/Resources/HellionStrings.ca.resx | 3 + HellionChat/Resources/HellionStrings.cs.resx | 3 + HellionChat/Resources/HellionStrings.da.resx | 3 + HellionChat/Resources/HellionStrings.de.resx | 3 + HellionChat/Resources/HellionStrings.el.resx | 3 + HellionChat/Resources/HellionStrings.es.resx | 3 + HellionChat/Resources/HellionStrings.fi.resx | 3 + HellionChat/Resources/HellionStrings.fr.resx | 3 + HellionChat/Resources/HellionStrings.hu.resx | 3 + HellionChat/Resources/HellionStrings.it.resx | 3 + HellionChat/Resources/HellionStrings.ja.resx | 3 + HellionChat/Resources/HellionStrings.ko.resx | 3 + HellionChat/Resources/HellionStrings.nb.resx | 3 + HellionChat/Resources/HellionStrings.nl.resx | 3 + HellionChat/Resources/HellionStrings.pl.resx | 3 + .../Resources/HellionStrings.pt-BR.resx | 3 + .../Resources/HellionStrings.pt-PT.resx | 3 + HellionChat/Resources/HellionStrings.resx | 3 + HellionChat/Resources/HellionStrings.ro.resx | 3 + HellionChat/Resources/HellionStrings.ru.resx | 3 + HellionChat/Resources/HellionStrings.sv.resx | 3 + HellionChat/Resources/HellionStrings.tr.resx | 3 + HellionChat/Resources/HellionStrings.uk.resx | 3 + .../Resources/HellionStrings.zh-Hans.resx | 3 + .../Resources/HellionStrings.zh-Hant.resx | 3 + ...onV24Step.cs => ConfigMigrationV25Step.cs} | 10 +- HellionChat/Ui/AutoTellTabTint.cs | 97 +++++++++ HellionChat/Ui/Components/MessageList.cs | 7 +- .../Ui/Components/Settings/Tabs/WindowTab.cs | 33 ++- HellionChat/Ui/Components/Sidebar.cs | 29 ++- HellionChat/Ui/FirstRunWizard.cs | 14 +- HellionChat/Ui/TabTintCache.cs | 38 ++++ HellionChat/Ui/Windows/MainWindow.cs | 4 + HellionChat/Util/ChatHideState.cs | 76 +++++++ HellionChat/Util/LayoutFingerprint.cs | 4 +- HellionChat/Util/TabLifecycleHelpers.cs | 2 +- 39 files changed, 424 insertions(+), 207 deletions(-) rename HellionChat/SelfTests/{ConfigMigrationV24Step.cs => ConfigMigrationV25Step.cs} (92%) create mode 100644 HellionChat/Ui/AutoTellTabTint.cs create mode 100644 HellionChat/Ui/TabTintCache.cs create mode 100644 HellionChat/Util/ChatHideState.cs diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index c1291c5..94427ac 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -35,7 +35,7 @@ public class ConfigKeyBind [Serializable] public class Configuration : IPluginConfiguration { - internal const int LatestVersion = 24; + internal const int LatestVersion = 25; public int Version { get; set; } = LatestVersion; @@ -184,8 +184,6 @@ public class Configuration : IPluginConfiguration // v1.2.1: default flipped false → true for consistency with other hide defaults. public bool HideInNewGamePlusMenu = true; public bool HideWhenInactive; - public int InactivityHideTimeout = 10; - public bool InactivityHideActiveDuringBattle = true; [Obsolete("Use InactivityHideChannelsV2 instead")] public Dictionary InactivityHideChannels = []; @@ -243,7 +241,6 @@ public class Configuration : IPluginConfiguration // UI-11: warn before sending a message that carries plugin-only glyphs. public bool NotifyPluginDisclosure = true; public bool KeepInputFocus = true; - public int MaxLinesToRender = 2_500; // 1-10000 public bool Use24HourClock = true; public bool ShowEmotes = true; public HashSet BlockedEmotes = []; @@ -310,164 +307,6 @@ public class Configuration : IPluginConfiguration // v22 field: MainWindow layout mode (sidebar vs. horizontal top tabs). // Initializer doubles as the migration default for configs loaded at v21. public MainWindowLayoutMode MainWindowLayoutMode = MainWindowLayoutMode.Sidebar; - - public void UpdateFrom(Configuration other, bool backToOriginal) - { - if (backToOriginal) - { - // NOTE (v1.8.0): this only flips the PopOut flag back. If a future - // caller ever wires UpdateFrom(backToOriginal: true) to a live - // settings-cancel path, that CALL-SITE must also iterate - // ChannelPopoutPool.TryClose over the affected Tab.Identifiers, - // otherwise pool windows stay IsOpen=true while the flag is false - // (orphan window). The pool is not reachable from this POCO by design. - foreach (var tab in Tabs.Where(t => t.PopOut)) - tab.PopOut = false; - } - - HideChat = other.HideChat; - HideDuringCutscenes = other.HideDuringCutscenes; - HideWhenNotLoggedIn = other.HideWhenNotLoggedIn; - HideWhenUiHidden = other.HideWhenUiHidden; - HideInLoadingScreens = other.HideInLoadingScreens; - HideInBattle = other.HideInBattle; - HideInNewGamePlusMenu = other.HideInNewGamePlusMenu; - HideWhenInactive = other.HideWhenInactive; - InactivityHideTimeout = other.InactivityHideTimeout; - InactivityHideActiveDuringBattle = other.InactivityHideActiveDuringBattle; - InactivityHideChannelsV2 = other.InactivityHideChannelsV2.ToDictionary( - pair => pair.Key, - pair => pair.Value - ); - InactivityHideExtraChatAll = other.InactivityHideExtraChatAll; - InactivityHideExtraChatChannels = other.InactivityHideExtraChatChannels.ToHashSet(); - ShowHideButton = other.ShowHideButton; - NativeItemTooltips = other.NativeItemTooltips; - ScreenshotMode = other.ScreenshotMode; - PrettierTimestamps = other.PrettierTimestamps; - MoreCompactPretty = other.MoreCompactPretty; - HideSameTimestamps = other.HideSameTimestamps; - ShowNoviceNetwork = other.ShowNoviceNetwork; - SidebarTabView = other.SidebarTabView; - PrintChangelog = other.PrintChangelog; - OnlyPreviewIf = other.OnlyPreviewIf; - PreviewMinimum = other.PreviewMinimum; - PreviewPosition = other.PreviewPosition; - CommandHelpSide = other.CommandHelpSide; - KeybindMode = other.KeybindMode; - LanguageOverride = other.LanguageOverride; - CanMove = other.CanMove; - CanResize = other.CanResize; - ShowTitleBar = other.ShowTitleBar; - ShowPopOutTitleBar = other.ShowPopOutTitleBar; - DatabaseBattleMessages = other.DatabaseBattleMessages; - FilterIncludePreviousSessions = other.FilterIncludePreviousSessions; - SortAutoTranslate = other.SortAutoTranslate; - CollapseDuplicateMessages = other.CollapseDuplicateMessages; - CollapseKeepUniqueLinks = other.CollapseKeepUniqueLinks; - SymbolPickerEnabled = other.SymbolPickerEnabled; - PlaySounds = other.PlaySounds; - CustomSoundVolume = other.CustomSoundVolume; - NotifyFailedTell = other.NotifyFailedTell; - NotifyPluginDisclosure = other.NotifyPluginDisclosure; - KeepInputFocus = other.KeepInputFocus; - MaxLinesToRender = other.MaxLinesToRender; - Use24HourClock = other.Use24HourClock; - ShowEmotes = other.ShowEmotes; - // Deep-copy so settings window edits don't leak into live config before Save. - BlockedEmotes = new HashSet(other.BlockedEmotes); - FontsEnabled = other.FontsEnabled; - ItalicEnabled = other.ItalicEnabled; - ExtraGlyphRanges = other.ExtraGlyphRanges; - FontSizeV2 = other.FontSizeV2; - GlobalFontV2 = other.GlobalFontV2; - JapaneseFontV2 = other.JapaneseFontV2; - ItalicFontV2 = other.ItalicFontV2; - SymbolsFontSizeV2 = other.SymbolsFontSizeV2; - TooltipOffset = other.TooltipOffset; - ChatColours = other.ChatColours.ToDictionary(entry => entry.Key, entry => entry.Value); - ColorSelectedInputChannelButton = other.ColorSelectedInputChannelButton; - - // Keep live temp tabs alive across UpdateFrom — a settings save must - // not destroy open tell conversations. Pinned TempTabs are persistent - // and come through `other` like regular tabs; unpinned TempTabs are - // session-only and held from the local state. For persistent tabs - // (incl. pinned), capture live runtime state by Identifier and restore - // it onto the freshly cloned tabs — CurrentChannel is critical because - // the user may have switched channel in-game between settings-open - // and settings-save, and we'd otherwise overwrite that with the - // settings-time snapshot. - var liveUnpinnedTempTabs = Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList(); - var livePersistentSession = Tabs.Where(t => !TabLifecycleHelpers.IsInUnpinnedPool(t)) - .ToDictionary(t => t.Identifier, t => (t.Messages, t.LastSendUnread, t.CurrentChannel)); - - Tabs = other - .Tabs.Where(t => !t.IsTempTab || t.IsPinned) - .Select(t => - { - var clone = t.Clone(); - if (livePersistentSession.TryGetValue(clone.Identifier, out var live)) - { - clone.Messages = live.Messages; - clone.LastSendUnread = live.LastSendUnread; - clone.CurrentChannel = live.CurrentChannel; - } - return clone; - }) - .ToList(); - Tabs.AddRange(liveUnpinnedTempTabs); - - ChatTabForward = other.ChatTabForward; - ChatTabBackward = other.ChatTabBackward; - - PrivacyFilterEnabled = other.PrivacyFilterEnabled; - PrivacyPersistChannels = [.. other.PrivacyPersistChannels]; - PrivacyPersistUnknownChannels = other.PrivacyPersistUnknownChannels; - - RetentionEnabled = other.RetentionEnabled; - RetentionDefaultDays = other.RetentionDefaultDays; - RetentionPerChannelDays = other.RetentionPerChannelDays.ToDictionary( - p => p.Key, - p => p.Value - ); - RetentionLastRunAt = other.RetentionLastRunAt; - - FirstRunCompleted = other.FirstRunCompleted; - WizardLastShownVersion = other.WizardLastShownVersion; - UseHellionFont = other.UseHellionFont; - ShowHonorificTitleInHeader = other.ShowHonorificTitleInHeader; - ShowHonorificGlow = other.ShowHonorificGlow; - - // v1.1.0 theme engine fields - Theme = other.Theme; - WindowOpacity = other.WindowOpacity; - WindowOpacityInactive = other.WindowOpacityInactive; - ReduceMotion = other.ReduceMotion; - UseCompactDensity = other.UseCompactDensity; - - EnableAutoTellTabs = other.EnableAutoTellTabs; - AutoTellTabsLimit = other.AutoTellTabsLimit; - AutoTellTabsCompactDisplay = other.AutoTellTabsCompactDisplay; - AutoTellTabsHistoryPreload = other.AutoTellTabsHistoryPreload; - SidebarWidth = other.SidebarWidth; - AutoTellTabsShowGreetedToggle = other.AutoTellTabsShowGreetedToggle; - - SeenPopOutInputHint = other.SeenPopOutInputHint; - PopOutInputEnabled = other.PopOutInputEnabled; - SeenPopOutHeaderHint = other.SeenPopOutHeaderHint; - AutoTellTabsOpenAsPopout = other.AutoTellTabsOpenAsPopout; - - WorldSuffixMode = other.WorldSuffixMode; - NameFormMode = other.NameFormMode; - - MainWindowOpen = other.MainWindowOpen; - SettingsWindowOpen = other.SettingsWindowOpen; - MaxParallelPopouts = other.MaxParallelPopouts; - TellAutoOpenMode = other.TellAutoOpenMode; - TellAutoOpenSwitchAlways = other.TellAutoOpenSwitchAlways; - SidebarAutoSwitchThresholdPx = other.SidebarAutoSwitchThresholdPx; - MainWindowLayoutMode = other.MainWindowLayoutMode; - } } [Serializable] @@ -540,9 +379,6 @@ public class Tab // Optional FontAwesome glyph name; null falls back to TabIconMapping default. public string? Icon = null; - [Obsolete("Removed in favor of SelectedChannels")] - public Dictionary ChatCodes = new(); - public Dictionary SelectedChannels = new(); public bool ExtraChatAll; public HashSet ExtraChatChannels = []; @@ -559,12 +395,15 @@ public class Tab public bool CanMove = true; public bool CanResize = true; - public bool IndependentHide; - public bool HideDuringCutscenes = true; - public bool HideWhenNotLoggedIn = true; - public bool HideWhenUiHidden = true; - public bool HideInLoadingScreens; - public bool HideInBattle; + // Six per-tab hide conditions used to live here. Their reader was the + // pop-out window, which stopped consulting them in cf4705e; the equivalents + // that survive are the window-level fields of the same name further up this + // file, and v1.12.0 gave every one of those a control. + // + // Per-tab was the wrong unit anyway: "hide during cutscenes" is a statement + // about the screen, not about one conversation. + // + // HideWhenInactive stays -- the auto-tell service writes it. public bool HideWhenInactive; public bool IsTempTab; @@ -667,15 +506,14 @@ public class Tab public Tab Clone() { -#pragma warning disable CS0618 // ChatCodes is obsolete but still serialized and must survive a clone return new Tab { Name = Name, - // Both were missing: Icon feeds the sidebar glyph, ChatCodes carries - // legacy migration data that is still written to the JSON. A clone - // round-trip used to drop them silently. + // Icon feeds the sidebar glyph and a clone round-trip used to drop + // it silently. ChatCodes sat beside it until v1.12.0, carrying data + // for a migration that the v16 schema gate had already made + // unreachable. Icon = Icon, - ChatCodes = new Dictionary(ChatCodes), SelectedChannels = SelectedChannels.ToDictionary(pair => pair.Key, pair => pair.Value), ExtraChatAll = ExtraChatAll, ExtraChatChannels = ExtraChatChannels.ToHashSet(), @@ -693,12 +531,6 @@ public class Tab CurrentChannel = CurrentChannel.Clone(), CanMove = CanMove, CanResize = CanResize, - IndependentHide = IndependentHide, - HideDuringCutscenes = HideDuringCutscenes, - HideWhenNotLoggedIn = HideWhenNotLoggedIn, - HideWhenUiHidden = HideWhenUiHidden, - HideInLoadingScreens = HideInLoadingScreens, - HideInBattle = HideInBattle, HideWhenInactive = HideWhenInactive, IsTempTab = IsTempTab, IsPinned = IsPinned, @@ -708,7 +540,6 @@ public class Tab NotificationSoundId = NotificationSoundId, IsGreeted = IsGreeted, }; -#pragma warning restore CS0618 } /// Ordered message list with duplicate ID tracking, sorting and mutex protection. diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index a80bcbd..9e28c46 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -167,6 +167,14 @@ public sealed class Plugin : IAsyncDalamudPlugin // via Framework.RunOnTick (v1.4.8 B3 retention sweep) can early-bail // before they touch state that has already been torn down. Volatile // because the tick reads it from a different thread than the writer. + // The three hide conditions v1.5.6 evaluated and cf4705e left without a + // reader. Advanced once per draw, before any window is drawn. + private Util.ChatHideReason _hideReason = Util.ChatHideReason.None; + + // Set by the chat-activation keybind, consumed by the next hide evaluation. + // A cutscene the user dismissed stays dismissed until it ends. + internal bool ChatActivationRequested; + private volatile bool _isDisposing; // Read by background workers that outlive a teardown -- the export thread @@ -309,7 +317,13 @@ public sealed class Plugin : IAsyncDalamudPlugin ); } - Config.Version = 24; + // v25 carries no migration step. The schema gate above only refuses + // anything under 16, and Json.NET drops keys it does not recognise on + // load, so the fields v1.12.0 deleted simply stop being written on the + // next save. The bump is documentation, and it has to be consistent: + // the constant and this stamp are two separate places, and changing + // only one gives a config that re-stamps itself on every start. + Config.Version = 25; // Unpinned TempTabs are session-only and dropped on every load. Pinned // TempTabs survive reload — Jin's tester feedback (v1.4.7). @@ -470,7 +484,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.SettingsWindowOpenStep(this), new SelfTests.OnOpenMainUiRoutesMainWindowStep(this), new SelfTests.TypingIpcStateStep(this), - new SelfTests.ConfigMigrationV24Step(this), + new SelfTests.ConfigMigrationV25Step(this), new SelfTests.ChannelPopoutBindStep(this), new SelfTests.HoverStateFootprintStep(), new SelfTests.HonorificHeaderRenderStep(this), @@ -1203,6 +1217,32 @@ public sealed class Plugin : IAsyncDalamudPlugin Interface.UiBuilder.DisableUserUiHide = !Config.HideWhenUiHidden; + // Stateless, so it needs no machine: there is no gesture that shows + // the chat while nobody is logged in. + if (Config.HideWhenNotLoggedIn && !ClientState.IsLoggedIn) + { + TypingIpc.Update(); + return; + } + + _hideReason = Util.ChatHideState.Next( + _hideReason, + new Util.ChatHideState.Inputs( + Config.HideInBattle, + InBattle, + Config.HideDuringCutscenes, + CutsceneActive || GposeActive, + ChatActivationRequested + ) + ); + ChatActivationRequested = false; + + if (Util.ChatHideState.Hides(_hideReason)) + { + TypingIpc.Update(); + return; + } + // RegularFont is nullable only because the live rebuild path // disposes it before reassigning; both ends of that swap happen on // this same draw thread, so it cannot be null here. diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index 7f57fef..c4b4a68 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -147,6 +147,7 @@ internal class HellionStrings internal static string Wizard_Step4_Summary_TellTabs => Get(nameof(Wizard_Step4_Summary_TellTabs)); internal static string Wizard_Step4_Summary_Visual => Get(nameof(Wizard_Step4_Summary_Visual)); internal static string Wizard_Step4_Summary_Unchanged => Get(nameof(Wizard_Step4_Summary_Unchanged)); + internal static string Wizard_Step4_Summary_Off => Get(nameof(Wizard_Step4_Summary_Off)); internal static string Wizard_Step4_TestHint => Get(nameof(Wizard_Step4_TestHint)); internal static string Wizard_Step4_SettingsHint => Get(nameof(Wizard_Step4_SettingsHint)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index c238ed5..dfbc04c 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1214,4 +1214,7 @@ Insereix l'objecte enllaçat <item> + + desactivat + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index dca569f..4508fe2 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1213,4 +1213,7 @@ Vložit odkázaný předmět <item> + + vypnuto + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index af39218..5ca8ba7 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1213,4 +1213,7 @@ Indsæt linket genstand <item> + + fra + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index d86d3fa..2f657d5 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1208,4 +1208,7 @@ Verlinkten Gegenstand einfügen <item> + + aus + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index d0d44c0..8b0c41c 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1213,4 +1213,7 @@ Εισαγωγή συνδεδεμένου αντικειμένου <item> + + ανενεργό + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index b550565..d3b323d 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1214,4 +1214,7 @@ Insertar objeto enlazado <item> + + desactivado + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index 4527a49..0d88537 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1213,4 +1213,7 @@ Lisää linkitetty esine <item> + + pois + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index 3385228..ec917d2 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1214,4 +1214,7 @@ Insérer l'objet lié <item> + + désactivé + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index 89231d5..7884c81 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1213,4 +1213,7 @@ Hivatkozott tárgy beszúrása <item> + + kikapcsolva + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index e21a16f..ec73987 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1214,4 +1214,7 @@ Inserisci l'oggetto collegato <item> + + disattivato + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index 99fa915..8a02c96 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1214,4 +1214,7 @@ リンクしたアイテムを挿入 <item> + + オフ + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 645788a..c07c4bf 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1214,4 +1214,7 @@ 연결된 아이템 삽입 <item> + + 끔 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index c9e9ee5..43f0671 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1213,4 +1213,7 @@ Sett inn lenket gjenstand <item> + + av + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 215be2f..77f653c 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1214,4 +1214,7 @@ Gekoppeld voorwerp invoegen <item> + + uit + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index 7d02ae7..5fa9e63 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1213,4 +1213,7 @@ Wstaw powiązany przedmiot <item> + + wyłączone + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index d97fa64..8544c04 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1214,4 +1214,7 @@ Inserir item vinculado <item> + + desativado + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 9e9813b..40c813b 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1213,4 +1213,7 @@ Inserir item ligado <item> + + desativado + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index 5e4e12f..9d91d8c 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1225,4 +1225,7 @@ The game reports a failed tell in the log only. This raises a notification instead, so a message to someone offline or on another world does not go unnoticed. + + off + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index eed5fcb..66138da 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1214,4 +1214,7 @@ Inserează obiectul legat <item> + + dezactivat + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index 96e5a9b..097e003 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1214,4 +1214,7 @@ Вставить связанный предмет <item> + + выключено + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index f563572..7576dea 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1214,4 +1214,7 @@ Infoga länkat föremål <item> + + av + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index 14c9e78..c8d2658 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1213,4 +1213,7 @@ Bağlantılı eşyayı ekle <item> + + kapalı + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 2a49c92..8ac78e7 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1213,4 +1213,7 @@ Вставити пов'язаний предмет <item> + + вимкнено + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index 0264a9c..95890cd 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1214,4 +1214,7 @@ 插入关联物品 <item> + + 关闭 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index 5f84eaf..4dbda0a 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1214,4 +1214,7 @@ 插入關聯物品 <item> + + 關閉 + \ No newline at end of file diff --git a/HellionChat/SelfTests/ConfigMigrationV24Step.cs b/HellionChat/SelfTests/ConfigMigrationV25Step.cs similarity index 92% rename from HellionChat/SelfTests/ConfigMigrationV24Step.cs rename to HellionChat/SelfTests/ConfigMigrationV25Step.cs index 1d44dee..3219a13 100644 --- a/HellionChat/SelfTests/ConfigMigrationV24Step.cs +++ b/HellionChat/SelfTests/ConfigMigrationV25Step.cs @@ -8,20 +8,20 @@ namespace HellionChat.SelfTests; // below must carry valid values here. This probe never rewrites config; the // migrations themselves are load-time and verified by the prepared-config smoke // in the plan. -internal sealed class ConfigMigrationV24Step : ISelfTestStep +internal sealed class ConfigMigrationV25Step : ISelfTestStep { - public ConfigMigrationV24Step(Plugin plugin) + public ConfigMigrationV25Step(Plugin plugin) { _ = plugin; } - public string Name => "Hellion Chat - Config v24 migration"; + public string Name => "Hellion Chat - Config v25 migration"; public SelfTestStepResult RunStep() { - if (Plugin.Config.Version != 24) + if (Plugin.Config.Version != 25) { - ImGui.Text($"Config.Version is {Plugin.Config.Version}, expected 24"); + ImGui.Text($"Config.Version is {Plugin.Config.Version}, expected 25"); return SelfTestStepResult.Fail; } diff --git a/HellionChat/Ui/AutoTellTabTint.cs b/HellionChat/Ui/AutoTellTabTint.cs new file mode 100644 index 0000000..70035c3 --- /dev/null +++ b/HellionChat/Ui/AutoTellTabTint.cs @@ -0,0 +1,97 @@ +namespace HellionChat.Ui; + +// 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 +{ + // Fallback for invalid input (empty name or world=0). White matches + // TextPrimary default so the sidebar stays visually consistent. + public const uint Fallback = 0xFFFFFFFFu; + + // 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 Palette = new uint[] + { + 0x00BED2FFu, // Arctic Cyan + 0xF97316FFu, // Ember Orange + 0xB585FFFFu, // Light Cosmic Purple + 0xE374E8FFu, // Bloom Magenta + 0x5DD39EFFu, // Mint Green + 0xF0AD4EFFu, // Warning Yellow + 0xE85C6AFFu, // Coral + 0x5CB85CFFu, // Status Green + 0x6278FFFFu, // Bloom Blue + 0xC9982EFFu, // Warm Gold + 0x9CCB7CFFu, // Soft Sage + 0xE85D04FFu, // Deep Ember + }; + + public static uint For(string name, uint world) + { + if (string.IsNullOrEmpty(name) || world == 0) + return Fallback; + + return Palette[(int)(StableHash($"{name}@{world}") % Palette.Count)]; + } + + // 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 IconPool = new[] + { + "envelope", + "star", + "heart", + "bell", + "bookmark", + "flag", + "fire", + }; + + // "envelope" matches the tell context better than the old hardcoded "clock". + public const string IconFallback = "envelope"; + + public static string IconFor(string name, uint world) + { + if (string.IsNullOrEmpty(name) || world == 0) + return IconFallback; + + // 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. + return IconPool[(int)(StableHash($"{world}@{name}") % IconPool.Count)]; + } + + // FNV-1a, not string.GetHashCode. The header of this file promises the same + // partner produces the same colour "across sessions", and GetHashCode cannot + // keep that: .NET salts string hashing per process, so every game start + // would reshuffle every tell tab. The tests never caught it because they + // only ever compared two calls inside one run. + // Returns the full uint. The old int-based version masked off the sign bit + // before its modulo; on a uint that mask only throws away a bit of entropy. + private static uint StableHash(string key) + { + const uint offsetBasis = 2166136261u; + const uint prime = 16777619u; + + var hash = offsetBasis; + foreach (var b in System.Text.Encoding.UTF8.GetBytes(key)) + { + hash ^= b; + hash *= prime; + } + + // Avalanche step, and not optional. FNV-1a alone leaves the low bits + // correlated for keys that differ only slightly, and the caller takes + // exactly those bits with a modulo -- a probe over 144 near-identical + // keys reached only 6 of the 12 colours. With fmix32 it reaches all 12. + hash ^= hash >> 16; + hash *= 0x7feb352du; + hash ^= hash >> 15; + hash *= 0x846ca68bu; + hash ^= hash >> 16; + + return hash; + } +} diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs index 67982eb..e9fc90a 100644 --- a/HellionChat/Ui/Components/MessageList.cs +++ b/HellionChat/Ui/Components/MessageList.cs @@ -37,9 +37,10 @@ internal sealed class MessageList private readonly Action _drawCompactRow; private readonly Action _drawCardRow; - // Reused across frames: at the default MaxLinesToRender of 2500 a fresh - // array per frame is 10 KB of garbage, and A2 put the default density on - // this path. + // Reused across frames: at MessageManager.MessageDisplayLimit a fresh array + // per frame is 40 KB of garbage, and A2 put the default density on this + // path. The old comment named MaxLinesToRender and its 2500 default, a + // config field that had stopped bounding anything. private float[] _heightScratch = []; // §6.2: setter-injection breaks the PayloadHandler → MainWindow → MessageList → PayloadHandler 3-cycle. diff --git a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs index 03849cc..4ca54ba 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs @@ -121,7 +121,13 @@ internal sealed class WindowTab ); } - if (_w.Section(ImGui.GetID("window.preview"u8), "Input preview", open: false)) + if ( + _w.Section( + ImGui.GetID("window.preview"u8), + HellionStrings.Settings_Section_InputPreview, + open: false + ) + ) { _w.EnumComboRow( ImGui.GetID("window.preview.position"u8), @@ -186,6 +192,31 @@ internal sealed class WindowTab () => Plugin.Config.HideInNewGamePlusMenu, v => Plugin.Config.HideInNewGamePlusMenu = v ); + + // The three that lost their reader with the chat window. Pressing + // the chat key during a cutscene brings the chat back for that + // cutscene; combat has no such escape, exactly as in v1.5.6. + _w.ToggleRow( + ImGui.GetID("window.hide.cutscenes"u8), + Language.Options_HideDuringCutscenes_Name, + Language.Options_HideDuringCutscenes_Description, + () => Plugin.Config.HideDuringCutscenes, + v => Plugin.Config.HideDuringCutscenes = v + ); + _w.ToggleRow( + ImGui.GetID("window.hide.battle"u8), + Language.Options_HideInBattle_Name, + Language.Options_HideInBattle_Description, + () => Plugin.Config.HideInBattle, + v => Plugin.Config.HideInBattle = v + ); + _w.ToggleRow( + ImGui.GetID("window.hide.notloggedin"u8), + Language.Options_HideWhenNotLoggedIn_Name, + Language.Options_HideWhenNotLoggedIn_Description, + () => Plugin.Config.HideWhenNotLoggedIn, + v => Plugin.Config.HideWhenNotLoggedIn = v + ); } if ( diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index 179eaa5..b0b4a02 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -324,10 +324,17 @@ internal sealed class Sidebar var icon = ResolveTabIcon(tab); - // Dim precedence (1.5.6): the active tab always keeps its regular - // color; only greeted, non-active tabs drop to TextDim. + // Dim precedence (1.5.6): the active tab never dims; only greeted, + // non-active tabs drop to TextDim. "Regular colour" is the tint for an + // auto-tell tab and the theme text colour for everything else. + // + // Below that, an auto-tell tab is tinted from its partner. Twelve + // colours against seven glyphs is 84 combinations, which is plenty for + // the one to five conversations anybody actually runs in parallel. The + // greeted dim still wins: it says something about this tab right now, + // the tint only says who it belongs to. var isCurrentTab = tab == activeTab; - var iconColor = textAbgr; + var iconColor = tab.IsTempTab ? ColourUtil.RgbaToAbgr(TabTintCache.GetTint(tab)) : textAbgr; if ( !isCurrentTab && greetedConfigured @@ -486,10 +493,20 @@ internal sealed class Sidebar ) return mapped; - // Auto-tell tabs always show the envelope, regardless of what their - // SelectedChannels filter is set to. + // Auto-tell tabs get one of seven glyphs derived from the partner, not + // one envelope for all of them. With four tells open, identical rows in + // identical colour are four rows you have to read to tell apart. if (tab.IsTempTab) - return FontAwesomeIcon.Envelope; + { + // TryGetValue rather than an indexer: every glyph in the pool is in + // the table today, and a lookup miss would be somebody editing one + // of the two lists without the other. A wrong envelope beats a + // KeyNotFoundException on the draw thread. + var hashed = TabTintCache.GetIcon(tab); + return IconByName.TryGetValue(hashed, out var tellGlyph) + ? tellGlyph + : FontAwesomeIcon.Envelope; + } // Channel-type fallback. Walk every selected key, not just the first, // so a System tab that filters multiple system-flavoured ChatTypes diff --git a/HellionChat/Ui/FirstRunWizard.cs b/HellionChat/Ui/FirstRunWizard.cs index 240badf..ea2017d 100644 --- a/HellionChat/Ui/FirstRunWizard.cs +++ b/HellionChat/Ui/FirstRunWizard.cs @@ -483,10 +483,16 @@ public sealed class FirstRunWizard : Window string.Format(HellionStrings.Wizard_Step4_Summary_Profile, profileLabel) ); - var historyLabel = - (_state.PendingFilterIncludePreviousSessions ?? false) - ? HellionStrings.Wizard_Step3_FilterIncludePreviousSessions_Label - : HellionStrings.Wizard_Step4_Summary_Unchanged; + // Three states, not two. "Unchanged" is for a step the user + // never entered; an unticked box is a decision that gets + // committed, and reporting it as unchanged was the same kind of + // lie the deleted LoadPreviousSession told. + var historyLabel = _state.PendingFilterIncludePreviousSessions switch + { + true => HellionStrings.Wizard_Step3_FilterIncludePreviousSessions_Label, + false => HellionStrings.Wizard_Step4_Summary_Off, + null => HellionStrings.Wizard_Step4_Summary_Unchanged, + }; ImGui.TextWrapped( string.Format(HellionStrings.Wizard_Step4_Summary_History, historyLabel) ); diff --git a/HellionChat/Ui/TabTintCache.cs b/HellionChat/Ui/TabTintCache.cs new file mode 100644 index 0000000..5364ca4 --- /dev/null +++ b/HellionChat/Ui/TabTintCache.cs @@ -0,0 +1,38 @@ +namespace HellionChat.Ui; + +// Per-Tab cache wrapper around the pure AutoTellTabTint hash helpers. +// Each cache (tint, icon) carries its own name+world validation key so +// neither read path mutates the other's state — refilling one never +// invalidates the other. No string allocation in the steady-state lookup. +internal static class TabTintCache +{ + public static uint GetTint(Tab tab) + { + var name = tab.TellTarget.Name; + var world = tab.TellTarget.World; + if (tab._cachedTintTellName != name || tab._cachedTintTellWorld != world) + { + tab._cachedTintTellName = name; + tab._cachedTintTellWorld = world; + tab._cachedTellTint = AutoTellTabTint.For(name, world); + } + return tab._cachedTellTint; + } + + public static string GetIcon(Tab tab) + { + var name = tab.TellTarget.Name; + var world = tab.TellTarget.World; + if ( + tab._cachedTellIcon is null + || tab._cachedIconTellName != name + || tab._cachedIconTellWorld != world + ) + { + tab._cachedIconTellName = name; + tab._cachedIconTellWorld = world; + tab._cachedTellIcon = AutoTellTabTint.IconFor(name, world); + } + return tab._cachedTellIcon; + } +} diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index 737d73c..9684f62 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -226,6 +226,10 @@ internal sealed class MainWindow : Window, IFocusableChatWindow internal void ActivateChat() { _userHidden = false; + + // Also lifts a cutscene hide for the duration of that cutscene. Without + // this the key would appear to do nothing at all during one. + Plugin.Instance.ChatActivationRequested = true; if (!IsOpen) { IsOpen = true; diff --git a/HellionChat/Util/ChatHideState.cs b/HellionChat/Util/ChatHideState.cs new file mode 100644 index 0000000..1c99b88 --- /dev/null +++ b/HellionChat/Util/ChatHideState.cs @@ -0,0 +1,76 @@ +namespace HellionChat.Util; + +// Why the chat is currently hidden, if it is. +internal enum ChatHideReason +{ + None, + + // In combat, and the user asked for that to hide the chat. + Battle, + + // A cutscene or gpose is running. + Cutscene, + + // A cutscene is running and the user pressed the activation key anyway. + // Distinct from None so the state returns to hidden on its own once the + // cutscene ends, without a second gesture. + CutsceneOverride, +} + +// The hide conditions v1.5.6 evaluated every frame, as a state machine. +// +// Three of the four went missing with the chat window in cf4705e: their config +// fields survived, their translated labels survived, nothing read them. They +// need a machine rather than three ifs because two of them are not conditions +// but states: a cutscene the user has dismissed must stay dismissed until the +// cutscene ends, and combat that started while the chat was already hidden for +// another reason must not take ownership of it. +// +// Pure and Dalamud-free, so the transitions can be pinned without a game. +internal static class ChatHideState +{ + internal readonly record struct Inputs( + bool HideInBattle, + bool InBattle, + bool HideDuringCutscenes, + bool CutsceneActive, + bool ActivateRequested + ); + + internal static ChatHideReason Next(ChatHideReason current, in Inputs inputs) + { + // Leaving a state comes first. Otherwise a frame in which combat ends + // and a cutscene starts would keep reporting Battle. + if (current == ChatHideReason.Battle && !inputs.InBattle) + current = ChatHideReason.None; + + if ( + current is ChatHideReason.Cutscene or ChatHideReason.CutsceneOverride + && !inputs.CutsceneActive + ) + current = ChatHideReason.None; + + // The user asked for the chat during a cutscene. Held until the cutscene + // ends, which the branch above takes care of. + if (current == ChatHideReason.Cutscene && inputs.ActivateRequested) + return ChatHideReason.CutsceneOverride; + + if (current != ChatHideReason.None) + return current; + + // Entering. Cutscene wins over battle: a cutscene during combat is the + // more specific situation, and it is the one with an escape hatch. + if (inputs.HideDuringCutscenes && inputs.CutsceneActive) + return ChatHideReason.Cutscene; + + if (inputs.HideInBattle && inputs.InBattle) + return ChatHideReason.Battle; + + return ChatHideReason.None; + } + + // CutsceneOverride is a hide state that does not hide -- that is the whole + // point of it. + internal static bool Hides(ChatHideReason reason) => + reason is ChatHideReason.Battle or ChatHideReason.Cutscene; +} diff --git a/HellionChat/Util/LayoutFingerprint.cs b/HellionChat/Util/LayoutFingerprint.cs index 22052ba..f6c6785 100644 --- a/HellionChat/Util/LayoutFingerprint.cs +++ b/HellionChat/Util/LayoutFingerprint.cs @@ -21,8 +21,8 @@ internal readonly record struct LayoutFingerprint( // Dragging a window edge or the Dalamud UI-scale slider moves the continuous // half of the fingerprint on every frame. Acting on each one drops the height // cache and sends the whole tab through the linear measure path (up to -// Config.MaxLinesToRender rows). Waiting for those to settle turns a drag into -// one rebuild. Discrete changes bypass the wait entirely. +// MessageManager.MessageDisplayLimit rows). Waiting for those to settle turns a +// drag into one rebuild. Discrete changes bypass the wait entirely. internal sealed class LayoutFingerprintGate { internal const long SettleMs = 200; diff --git a/HellionChat/Util/TabLifecycleHelpers.cs b/HellionChat/Util/TabLifecycleHelpers.cs index fb0a4f3..093eec4 100644 --- a/HellionChat/Util/TabLifecycleHelpers.cs +++ b/HellionChat/Util/TabLifecycleHelpers.cs @@ -22,7 +22,7 @@ internal static class TabLifecycleHelpers // with no window. Unconditional (pinned included) because pinned TempTabs // survive the load and are the main stale-flag source; a !IsPinned filter // would leave exactly those leaking. Lockstep with the two in-memory resets - // (AutoTellTabsService pool-full + Configuration.UpdateFrom backToOriginal). + // (AutoTellTabsService pool-full + the settings round trip backToOriginal). // TEST-MIRROR: ../../../Hellion Build test/_Helpers/PopOutResetOnLoadTests.cs internal static void ResetPopOutOnLoad(IEnumerable tabs) { From 6bb020547dac37c53d2782f640cb2e42c0a42fd8 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 23:39:43 +0200 Subject: [PATCH 119/129] feat(tabs): groundwork for the tab editor The two halves that can be settled without a window, before the window exists. Concurrency first, because the editor is the first thing that ever writes SelectedChannels after load. All three channel-filter fields are read without a lock from the pending-message thread, the filter worker and the draw thread, and mutating a live Dictionary while Matches walks it is the standard way to get a wrong answer on somebody else's stack. ReplaceChannelFilter builds the replacements and swaps the references, so a reader sees the old set or the new one and never half of either. It deliberately stops short of making the three writes one atomic step. A reader can catch the new dictionary with the old ExtraChat flag for a single message, which the editor's closing clear-and-refilter reconsiders anyway. Doing better would mean one reference for all three, and all three are serialized fields whose shape the config file already has. Then the index maths, which is where temp tabs make this more than list arithmetic. They live in the same collection, they are not editable -- their name is a conversation partner and deleting one would be deleting a conversation -- and a move has to step over them rather than swap with them, or moving a tab down and back up would not return it to where it started. Reversible in the editable order, which is the order the user sees; the sidebar draws temp tabs under their own headers regardless of where they sit in the list. Twelve facts, and one of them started out asserting the wrong property: that the whole list is restored by a move and its reverse. It is not, and it does not need to be. The last editable tab cannot be deleted. The message list has no empty state, so a window with nothing to draw is not a state to offer. --- HellionChat/Configuration.cs | 28 ++++++++++ HellionChat/Util/TabLifecycleHelpers.cs | 73 +++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index 94427ac..4d83a50 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -472,6 +472,34 @@ public class Tab [NonSerialized] internal float _cardHoverAlpha; + // Copy-on-write for the three channel-filter fields. They are read without + // any lock from the pending-message thread, the filter worker and the draw + // thread, and until v1.12.0 nothing ever wrote them after load -- so the + // tab editor is their first writer, and mutating a live Dictionary while + // Matches enumerates it is the classic way to get a wrong answer or an + // exception on somebody else's thread. + // + // Building the replacements and swapping the references means a reader sees + // either the old set or the new one, never half of either. + // + // What this deliberately does not do is make the three writes one atomic + // step. A reader can catch the new dictionary with the old ExtraChat flag + // for a single message. That is harmless: the editor finishes by clearing + // and refiltering every tab, so any message placed by a mixed view is + // reconsidered a moment later. Making it truly atomic would mean one + // reference for all three, and these three are serialized fields with a + // shape the config file already has. + internal void ReplaceChannelFilter( + Dictionary selected, + bool extraChatAll, + HashSet extraChatChannels + ) + { + Volatile.Write(ref SelectedChannels, selected); + Volatile.Write(ref ExtraChatChannels, extraChatChannels); + Volatile.Write(ref ExtraChatAll, extraChatAll); + } + public bool Matches(Message message) { if (!message.Matches(SelectedChannels, ExtraChatAll, ExtraChatChannels)) diff --git a/HellionChat/Util/TabLifecycleHelpers.cs b/HellionChat/Util/TabLifecycleHelpers.cs index 093eec4..f08aaa6 100644 --- a/HellionChat/Util/TabLifecycleHelpers.cs +++ b/HellionChat/Util/TabLifecycleHelpers.cs @@ -13,6 +13,79 @@ internal static class TabLifecycleHelpers public static bool IsInPinnedPool(Tab t) => t.IsTempTab && t.IsPinned; + // A temp tab belongs to its conversation, not to the user's layout. It is + // created and dropped by the auto-tell service, its name is the partner, and + // deleting it in an editor would be deleting a conversation. Pinning is the + // only editing gesture it accepts, and that lives in the context menu. + public static bool IsEditable(Tab t) => !t.IsTempTab; + + // Where a new tab lands: after the last editable one, so it never appears + // among the temp tabs at the bottom of the list. + public static int InsertIndexForNewTab(IReadOnlyList tabs) + { + for (var i = tabs.Count - 1; i >= 0; i--) + if (IsEditable(tabs[i])) + return i + 1; + return 0; + } + + // Where an editable tab ends up when the user moves it by one step. + // + // Steps over temp tabs rather than swapping with them: a swap would push a + // conversation into the middle of the layout. Reversible in the editable + // order, which is the order the user sees -- the temp tab keeps its slot in + // the collection and the sidebar draws it under its own section header + // anyway. Returns the original index when there is no editable neighbour in + // that direction, which the caller reads as "no move". + public static int MoveIndex(IReadOnlyList tabs, int index, int delta) + { + if (index < 0 || index >= tabs.Count || delta == 0) + return index; + if (!IsEditable(tabs[index])) + return index; + + var step = Math.Sign(delta); + for (var i = index + step; i >= 0 && i < tabs.Count; i += step) + if (IsEditable(tabs[i])) + return i; + + return index; + } + + // Which tab the editor should select after deleting the one at index. + // + // The next editable tab below, else the one above, else nothing. Returning + // the index the caller should select *after* the removal, so it is already + // shifted. + public static int SelectionAfterDelete(IReadOnlyList tabs, int deletedIndex) + { + for (var i = deletedIndex + 1; i < tabs.Count; i++) + if (IsEditable(tabs[i])) + return i - 1; + + for (var i = deletedIndex - 1; i >= 0; i--) + if (IsEditable(tabs[i])) + return i; + + return -1; + } + + // Deleting the last editable tab leaves a window with nothing to draw, and + // the message list has no empty state. The editor offers templates instead + // of a delete in that situation. + public static bool CanDelete(IReadOnlyList tabs, int index) + { + if (index < 0 || index >= tabs.Count || !IsEditable(tabs[index])) + return false; + + var editable = 0; + foreach (var tab in tabs) + if (IsEditable(tab)) + editable++; + + return editable > 1; + } + public static bool ShouldStripOnLoad(Tab t) => IsInUnpinnedPool(t); public static bool ShouldStripOnSave(Tab t) => IsInUnpinnedPool(t); From 83306f1f479ed2613ba4b10fd842308be72befd1 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 00:08:19 +0200 Subject: [PATCH 120/129] i18n(settings): translate the settings window The settings window was rebuilt in v1.10.0 and v1.11.0 with its labels written straight into the C#. Every section heading and most row labels across five tabs read English in all 25 languages, which the memory notes had accepted as a known backlog. Forty-nine keys close it: seventeen section headings, the rows under them, and the two layout choices. All 25 languages, 437 keys each, no gaps and no placeholder drift in either direction. The layout labels are a property rather than a static array now. A static one would hold whichever language the plugin started in, and this plugin switches language at runtime. What stays English, deliberately: log lines and thread names, which no user reads; the two developer tools behind Shift plus Ctrl+Shift, per the decision that developer surfaces stay English; and the About tab's brand lines, attributions and licence identifiers, which are names. --- .../Resources/HellionStrings.Designer.cs | 49 ++++++ HellionChat/Resources/HellionStrings.ca.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.cs.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.da.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.de.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.el.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.es.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.fi.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.fr.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.hu.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.it.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.ja.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.ko.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.nb.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.nl.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.pl.resx | 147 ++++++++++++++++++ .../Resources/HellionStrings.pt-BR.resx | 147 ++++++++++++++++++ .../Resources/HellionStrings.pt-PT.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.ro.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.ru.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.sv.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.tr.resx | 147 ++++++++++++++++++ HellionChat/Resources/HellionStrings.uk.resx | 147 ++++++++++++++++++ .../Resources/HellionStrings.zh-Hans.resx | 147 ++++++++++++++++++ .../Resources/HellionStrings.zh-Hant.resx | 147 ++++++++++++++++++ .../Ui/Components/Settings/Tabs/AboutTab.cs | 17 +- .../Components/Settings/Tabs/ChannelsTab.cs | 23 ++- .../Ui/Components/Settings/Tabs/ChatTab.cs | 35 +++-- .../Ui/Components/Settings/Tabs/GeneralTab.cs | 26 +++- .../Ui/Components/Settings/Tabs/WindowTab.cs | 45 ++++-- 31 files changed, 3821 insertions(+), 49 deletions(-) diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index c4b4a68..92b9bbb 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -382,6 +382,55 @@ internal class HellionStrings internal static string Settings_Section_Cleanup => Get(nameof(Settings_Section_Cleanup)); internal static string Settings_Section_Export => Get(nameof(Settings_Section_Export)); internal static string Settings_Section_Telemetry => Get(nameof(Settings_Section_Telemetry)); + internal static string Settings_Section_Links => Get(nameof(Settings_Section_Links)); + internal static string Settings_Section_Behaviour => Get(nameof(Settings_Section_Behaviour)); + internal static string Settings_Section_Keybinds => Get(nameof(Settings_Section_Keybinds)); + internal static string Settings_Section_Notifications => Get(nameof(Settings_Section_Notifications)); + internal static string Settings_Section_DisplayModes => Get(nameof(Settings_Section_DisplayModes)); + internal static string Settings_Section_History => Get(nameof(Settings_Section_History)); + internal static string Settings_Section_CommandHelp => Get(nameof(Settings_Section_CommandHelp)); + internal static string Settings_Section_PluginDisclosure => Get(nameof(Settings_Section_PluginDisclosure)); + internal static string Settings_Section_LayoutMode => Get(nameof(Settings_Section_LayoutMode)); + internal static string Settings_Section_Opacity => Get(nameof(Settings_Section_Opacity)); + internal static string Settings_Section_ResizeBehaviour => Get(nameof(Settings_Section_ResizeBehaviour)); + internal static string Settings_Section_TellAutoOpen => Get(nameof(Settings_Section_TellAutoOpen)); + internal static string Settings_Section_Sidebar => Get(nameof(Settings_Section_Sidebar)); + internal static string Settings_Section_Brand => Get(nameof(Settings_Section_Brand)); + internal static string Settings_Section_Integrations => Get(nameof(Settings_Section_Integrations)); + internal static string Settings_Section_Credits => Get(nameof(Settings_Section_Credits)); + internal static string Settings_Section_License => Get(nameof(Settings_Section_License)); + internal static string Settings_General_ReduceMotion_Description => Get(nameof(Settings_General_ReduceMotion_Description)); + internal static string Settings_Keybinds_Hint => Get(nameof(Settings_Keybinds_Hint)); + internal static string Settings_Keybinds_CycleNext => Get(nameof(Settings_Keybinds_CycleNext)); + internal static string Settings_Keybinds_CyclePrevious => Get(nameof(Settings_Keybinds_CyclePrevious)); + internal static string Settings_General_Language_Description => Get(nameof(Settings_General_Language_Description)); + internal static string Settings_Chat_CompactDensity_Name => Get(nameof(Settings_Chat_CompactDensity_Name)); + internal static string Settings_Chat_CompactDensity_Description => Get(nameof(Settings_Chat_CompactDensity_Description)); + internal static string Settings_Chat_Clock24_Name => Get(nameof(Settings_Chat_Clock24_Name)); + internal static string Settings_Chat_PreviousSessions_Name => Get(nameof(Settings_Chat_PreviousSessions_Name)); + internal static string Settings_Chat_PreviousSessions_Description => Get(nameof(Settings_Chat_PreviousSessions_Description)); + internal static string Settings_Chat_CommandHelpSide_Name => Get(nameof(Settings_Chat_CommandHelpSide_Name)); + internal static string Settings_Chat_CommandHelpSide_Description => Get(nameof(Settings_Chat_CommandHelpSide_Description)); + internal static string Settings_Channels_TellAutoOpenMode_Name => Get(nameof(Settings_Channels_TellAutoOpenMode_Name)); + internal static string Settings_Channels_TellAutoOpenMode_Description => Get(nameof(Settings_Channels_TellAutoOpenMode_Description)); + internal static string Settings_Channels_TellSwitchAlways_Name => Get(nameof(Settings_Channels_TellSwitchAlways_Name)); + internal static string Settings_Channels_TellSwitchAlways_Description => Get(nameof(Settings_Channels_TellSwitchAlways_Description)); + internal static string Settings_Window_LayoutSidebar => Get(nameof(Settings_Window_LayoutSidebar)); + internal static string Settings_Window_LayoutTopTabs => Get(nameof(Settings_Window_LayoutTopTabs)); + internal static string Settings_Window_TabPlacement_Name => Get(nameof(Settings_Window_TabPlacement_Name)); + internal static string Settings_Window_TabPlacement_Description => Get(nameof(Settings_Window_TabPlacement_Description)); + internal static string Settings_Window_TitleBar_Name => Get(nameof(Settings_Window_TitleBar_Name)); + internal static string Settings_Window_PopoutTitleBar_Name => Get(nameof(Settings_Window_PopoutTitleBar_Name)); + internal static string Settings_Window_InactiveOpacity_Name => Get(nameof(Settings_Window_InactiveOpacity_Name)); + internal static string Settings_Window_InactiveOpacity_Description => Get(nameof(Settings_Window_InactiveOpacity_Description)); + internal static string Settings_Window_AllowMove_Name => Get(nameof(Settings_Window_AllowMove_Name)); + internal static string Settings_Window_AllowResize_Name => Get(nameof(Settings_Window_AllowResize_Name)); + internal static string Settings_Window_SidebarThreshold_Name => Get(nameof(Settings_Window_SidebarThreshold_Name)); + internal static string Settings_Window_SidebarThreshold_Description => Get(nameof(Settings_Window_SidebarThreshold_Description)); + internal static string Settings_Window_PreviewPosition_Name => Get(nameof(Settings_Window_PreviewPosition_Name)); + internal static string Settings_Window_PreviewOnlyTyping_Name => Get(nameof(Settings_Window_PreviewOnlyTyping_Name)); + internal static string Settings_About_GiteaRepo => Get(nameof(Settings_About_GiteaRepo)); + internal static string Settings_About_CustomRepo => Get(nameof(Settings_About_CustomRepo)); internal static string Settings_Section_AutoTranslate => Get(nameof(Settings_Section_AutoTranslate)); internal static string Settings_Emotes_Block => Get(nameof(Settings_Emotes_Block)); internal static string Settings_NotifyFailedTell_Name => Get(nameof(Settings_NotifyFailedTell_Name)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index dfbc04c..1bc978d 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1217,4 +1217,151 @@ desactivat + + Comportament + + + Dreceres + + + Notificacions + + + Modes de visualització + + + Historial + + + Ajuda d'ordres + + + Avís de complement + + + Mode de disposició + + + Opacitat + + + Comportament de mida + + + Obertura automàtica de xiuxiueigs + + + Barra lateral + + + Marca + + + Enllaços + + + Integracions + + + Crèdits + + + Llicència + + + Desactiva els fosos del tema, els efectes en passar el cursor i les partícules. + + + Fes clic a un botó i després prem la combinació de tecles. Esc esborra. + + + Passa a la pestanya següent + + + Passa a la pestanya anterior + + + Canviar-ho reconstrueix l'atles de fonts, així que el xat queda en blanc un moment. + + + Densitat compacta + + + Canvia la disposició de targetes per una línia per missatge. + + + Rellotge de 24 hores + + + Mostra l'historial de sessions anteriors + + + Desactivat, el registre comença buit cada vegada que s'inicia el joc i només s'omple amb els missatges rebuts a partir d'aleshores. + + + Costat de l'ajuda d'ordres + + + A quin costat apareix la llista de suggeriments mentre escrius. + + + Mode d'obertura automàtica + + + On s'obre un xiuxiueig quan arriba. + + + Canvia a la pestanya a cada xiuxiueig + + + Altrament la pestanya s'obre en segon pla després de la primera. + + + Barra lateral + + + Pestanyes superiors + + + Ubicació de les pestanyes + + + On es col·loca la llista de pestanyes a la finestra principal. + + + Mostra la barra de títol + + + Mostra la barra de títol a les finestres desacoblades + + + Opacitat inactiva + + + S'aplica mentre una altra finestra té el focus. + + + Permet moure + + + Permet redimensionar + + + Llindar de canvi automàtic de la barra lateral + + + Per sota d'aquesta amplada la barra lateral es plega en pestanyes superiors, en píxels. + + + Posició de la vista prèvia + + + Mostra la vista prèvia només en escriure + + + Repositori Gitea + + + Manifest del repositori personalitzat + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index 4508fe2..0d06a6b 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1216,4 +1216,151 @@ vypnuto + + Chování + + + Klávesové zkratky + + + Oznámení + + + Režimy zobrazení + + + Historie + + + Nápověda k příkazům + + + Upozornění na plugin + + + Režim rozvržení + + + Neprůhlednost + + + Chování při změně velikosti + + + Automatické otevření šeptání + + + Postranní panel + + + Značka + + + Odkazy + + + Integrace + + + Poděkování + + + Licence + + + Vypne prolínání motivů, přechody při najetí myší a poletující částice. + + + Klikněte na tlačítko a poté stiskněte kombinaci kláves. Esc smaže. + + + Přepnout na další záložku + + + Přepnout na předchozí záložku + + + Přepnutí přestaví atlas písem, takže chat na okamžik zmizí. + + + Kompaktní hustota + + + Vymění rozvržení karet za jeden řádek na zprávu. + + + 24hodinový formát + + + Zobrazit historii z předchozích relací + + + Vypnuto znamená, že se protokol při každém spuštění hry začíná prázdný a plní se jen zprávami přijatými od té chvíle. + + + Strana nápovědy k příkazům + + + Na které straně se během psaní zobrazuje seznam nápověd. + + + Režim automatického otevření + + + Kde se šeptání otevře, když dorazí. + + + Přepnout na záložku při každém šeptání + + + Jinak se záložka po první zprávě otevře na pozadí. + + + Postranní panel + + + Horní záložky + + + Umístění záložek + + + Kde v hlavním okně sedí seznam záložek. + + + Zobrazit záhlaví + + + Zobrazit záhlaví u vysunutých oken + + + Neprůhlednost při neaktivitě + + + Platí, když má fokus jiné okno. + + + Povolit přesouvání + + + Povolit změnu velikosti + + + Práh automatického přepnutí postranního panelu + + + Pod touto šířkou se postranní panel složí do horních záložek, v pixelech. + + + Umístění náhledu + + + Zobrazit náhled jen při psaní + + + Repozitář Gitea + + + Manifest vlastního repozitáře + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index 5ca8ba7..524aaa9 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1216,4 +1216,151 @@ fra + + Adfærd + + + Tastaturgenveje + + + Notifikationer + + + Visningstilstande + + + Historik + + + Kommandohjælp + + + Plugin-oplysning + + + Layouttilstand + + + Uigennemsigtighed + + + Størrelsesændring + + + Automatisk åbning af tells + + + Sidepanel + + + Brand + + + Links + + + Integrationer + + + Krediteringer + + + Licens + + + Slår temaovergange, hover-fade og de svævende partikler fra. + + + Klik på en knap, og tryk derefter tastekombinationen. Esc rydder. + + + Skift til næste faneblad + + + Skift til forrige faneblad + + + Skift genopbygger fontatlasset, så chatten er tom et øjeblik. + + + Kompakt tæthed + + + Bytter kortlayoutet ud med én linje pr. besked. + + + 24-timers ur + + + Vis historik fra tidligere sessioner + + + Slået fra starter loggen tom, hver gang spillet startes, og fyldes kun med beskeder modtaget derefter. + + + Side for kommandohjælp + + + Hvilken side listen med kommandotips vises på, mens du skriver. + + + Tilstand for automatisk åbning + + + Hvor en tell åbnes, når den ankommer. + + + Skift til fanebladet ved hver tell + + + Ellers åbnes fanebladet i baggrunden efter den første. + + + Sidepanel + + + Faneblade øverst + + + Placering af faneblade + + + Hvor fanelisten sidder i hovedvinduet. + + + Vis titellinje + + + Vis titellinje for pop-out-vinduer + + + Uigennemsigtighed når inaktiv + + + Gælder, mens et andet vindue har fokus. + + + Tillad flytning + + + Tillad størrelsesændring + + + Grænse for automatisk skift af sidepanel + + + Under denne bredde folder sidepanelet sig til faneblade øverst, i pixels. + + + Placering af forhåndsvisning + + + Vis kun forhåndsvisning under skrivning + + + Gitea-repositorium + + + Manifest for brugerdefineret repo + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index 2f657d5..bf9e99a 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1211,4 +1211,151 @@ aus + + Verhalten + + + Tastenkürzel + + + Benachrichtigungen + + + Anzeigemodi + + + Verlauf + + + Befehlshilfe + + + Plugin-Hinweis + + + Anordnung + + + Deckkraft + + + Größenverhalten + + + Flüstern automatisch öffnen + + + Seitenleiste + + + Marke + + + Links + + + Integrationen + + + Mitwirkende + + + Lizenz + + + Schaltet Theme-Überblendungen, Hover-Verläufe und die schwebenden Partikel ab. + + + Auf einen Knopf klicken, dann die Tastenkombination drücken. Esc löscht. + + + Zum nächsten Chat-Tab wechseln + + + Zum vorherigen Chat-Tab wechseln + + + Ein Wechsel baut den Schrift-Atlas neu auf, deshalb ist der Chat kurz leer. + + + Kompakte Dichte + + + Tauscht das Karten-Layout gegen eine Zeile pro Nachricht. + + + 24-Stunden-Uhr + + + Verlauf früherer Sitzungen anzeigen + + + Aus bedeutet, der Verlauf startet bei jedem Spielstart leer und füllt sich nur mit Nachrichten, die danach eintreffen. + + + Seite der Befehlshilfe + + + Auf welcher Seite die Befehlsliste beim Tippen erscheint. + + + Modus für automatisches Öffnen + + + Wo ein Flüstern aufgeht, wenn es ankommt. + + + Bei jedem Flüstern zum Tab wechseln + + + Sonst öffnet der Tab nach dem ersten im Hintergrund. + + + Seitenleiste + + + Tabs oben + + + Tab-Anordnung + + + Wo die Tab-Liste im Hauptfenster sitzt. + + + Titelleiste anzeigen + + + Titelleiste für ausgeklappte Fenster anzeigen + + + Deckkraft im Hintergrund + + + Gilt, solange ein anderes Fenster den Fokus hat. + + + Verschieben erlauben + + + Größe ändern erlauben + + + Umschaltschwelle der Seitenleiste + + + Unterhalb dieser Breite klappt die Seitenleiste zu Tabs oben, in Pixeln. + + + Position der Vorschau + + + Vorschau nur beim Tippen zeigen + + + Gitea-Repository + + + Manifest des eigenen Repos + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index 8b0c41c..0c22869 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1216,4 +1216,151 @@ ανενεργό + + Συμπεριφορά + + + Συντομεύσεις + + + Ειδοποιήσεις + + + Λειτουργίες εμφάνισης + + + Ιστορικό + + + Βοήθεια εντολών + + + Γνωστοποίηση πρόσθετου + + + Λειτουργία διάταξης + + + Αδιαφάνεια + + + Συμπεριφορά μεγέθους + + + Αυτόματο άνοιγμα tell + + + Πλαϊνή μπάρα + + + Επωνυμία + + + Σύνδεσμοι + + + Ενσωματώσεις + + + Συντελεστές + + + Άδεια + + + Απενεργοποιεί τις μεταβάσεις θέματος, τα εφέ αιώρησης και τα σωματίδια. + + + Κάντε κλικ σε ένα κουμπί και μετά πατήστε τον συνδυασμό πλήκτρων. Το Esc καθαρίζει. + + + Μετάβαση στην επόμενη καρτέλα + + + Μετάβαση στην προηγούμενη καρτέλα + + + Η αλλαγή ξαναχτίζει τον άτλαντα γραμματοσειρών, οπότε η συνομιλία αδειάζει στιγμιαία. + + + Συμπαγής πυκνότητα + + + Αντικαθιστά τη διάταξη καρτών με μία γραμμή ανά μήνυμα. + + + 24ωρο ρολόι + + + Εμφάνιση ιστορικού από προηγούμενες συνεδρίες + + + Απενεργοποιημένο σημαίνει ότι το αρχείο ξεκινά άδειο σε κάθε εκκίνηση και γεμίζει μόνο με μηνύματα που έρχονται μετά. + + + Πλευρά βοήθειας εντολών + + + Σε ποια πλευρά εμφανίζεται η λίστα υποδείξεων ενώ πληκτρολογείτε. + + + Λειτουργία αυτόματου ανοίγματος + + + Πού ανοίγει ένα tell όταν φτάνει. + + + Μετάβαση στην καρτέλα σε κάθε tell + + + Διαφορετικά η καρτέλα ανοίγει στο παρασκήνιο μετά το πρώτο. + + + Πλαϊνή μπάρα + + + Καρτέλες πάνω + + + Τοποθέτηση καρτελών + + + Πού βρίσκεται η λίστα καρτελών στο κύριο παράθυρο. + + + Εμφάνιση γραμμής τίτλου + + + Εμφάνιση γραμμής τίτλου στα αποσπώμενα παράθυρα + + + Αδιαφάνεια όταν ανενεργό + + + Ισχύει όσο ένα άλλο παράθυρο έχει την εστίαση. + + + Να επιτρέπεται η μετακίνηση + + + Να επιτρέπεται η αλλαγή μεγέθους + + + Όριο αυτόματης εναλλαγής πλαϊνής μπάρας + + + Κάτω από αυτό το πλάτος η πλαϊνή μπάρα διπλώνει σε καρτέλες πάνω, σε pixel. + + + Θέση προεπισκόπησης + + + Εμφάνιση προεπισκόπησης μόνο κατά την πληκτρολόγηση + + + Αποθετήριο Gitea + + + Δήλωση προσαρμοσμένου αποθετηρίου + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index d3b323d..ef9dbd8 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1217,4 +1217,151 @@ desactivado + + Comportamiento + + + Atajos de teclado + + + Notificaciones + + + Modos de visualización + + + Historial + + + Ayuda de comandos + + + Aviso de complemento + + + Modo de disposición + + + Opacidad + + + Comportamiento de tamaño + + + Apertura automática de susurros + + + Barra lateral + + + Marca + + + Enlaces + + + Integraciones + + + Créditos + + + Licencia + + + Desactiva las transiciones del tema, los efectos al pasar el cursor y las partículas. + + + Haz clic en un botón y luego pulsa la combinación de teclas. Esc borra. + + + Cambiar a la pestaña siguiente + + + Cambiar a la pestaña anterior + + + Cambiarlo reconstruye el atlas de fuentes, así que el chat queda en blanco un momento. + + + Densidad compacta + + + Cambia la disposición de tarjetas por una línea por mensaje. + + + Reloj de 24 horas + + + Mostrar el historial de sesiones anteriores + + + Desactivado, el registro empieza vacío cada vez que se inicia el juego y solo se llena con los mensajes recibidos desde entonces. + + + Lado de la ayuda de comandos + + + En qué lado aparece la lista de sugerencias mientras escribes. + + + Modo de apertura automática + + + Dónde se abre un susurro cuando llega. + + + Cambiar a la pestaña con cada susurro + + + Si no, la pestaña se abre en segundo plano tras el primero. + + + Barra lateral + + + Pestañas superiores + + + Ubicación de las pestañas + + + Dónde se sitúa la lista de pestañas en la ventana principal. + + + Mostrar la barra de título + + + Mostrar la barra de título en las ventanas desacopladas + + + Opacidad inactiva + + + Se aplica mientras otra ventana tiene el foco. + + + Permitir mover + + + Permitir redimensionar + + + Umbral de cambio automático de la barra lateral + + + Por debajo de este ancho la barra lateral se pliega en pestañas superiores, en píxeles. + + + Posición de la vista previa + + + Mostrar la vista previa solo al escribir + + + Repositorio de Gitea + + + Manifiesto del repositorio personalizado + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index 0d88537..2a70e80 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1216,4 +1216,151 @@ pois + + Toiminta + + + Pikanäppäimet + + + Ilmoitukset + + + Näyttötilat + + + Historia + + + Komento-ohje + + + Lisäosailmoitus + + + Asettelutila + + + Läpinäkymättömyys + + + Koon muuttaminen + + + Kuiskausten automaattinen avaus + + + Sivupalkki + + + Tuotemerkki + + + Linkit + + + Integraatiot + + + Tekijät + + + Lisenssi + + + Poistaa teemaristihäivytykset, osoitinhäivytykset ja leijuvat hiukkaset käytöstä. + + + Napsauta painiketta ja paina sitten näppäinyhdistelmää. Esc tyhjentää. + + + Siirry seuraavaan välilehteen + + + Siirry edelliseen välilehteen + + + Vaihto rakentaa fonttiatlaksen uudelleen, joten chat on hetken tyhjä. + + + Tiivis tiheys + + + Vaihtaa korttiasettelun yhteen riviin per viesti. + + + 24 tunnin kello + + + Näytä aiempien istuntojen historia + + + Pois päältä loki alkaa tyhjänä joka käynnistyksellä ja täyttyy vain sen jälkeen saapuneilla viesteillä. + + + Komento-ohjeen puoli + + + Kummalla puolella komentovihjeluettelo näkyy kirjoitettaessa. + + + Automaattisen avauksen tila + + + Missä kuiskaus avautuu saapuessaan. + + + Vaihda välilehteen jokaisella kuiskauksella + + + Muuten välilehti avautuu taustalle ensimmäisen jälkeen. + + + Sivupalkki + + + Ylävälilehdet + + + Välilehtien sijainti + + + Missä välilehtiluettelo sijaitsee pääikkunassa. + + + Näytä otsikkopalkki + + + Näytä otsikkopalkki irrotetuissa ikkunoissa + + + Läpinäkymättömyys epäaktiivisena + + + Koskee, kun toinen ikkuna on kohdistettuna. + + + Salli siirtäminen + + + Salli koon muuttaminen + + + Sivupalkin automaattisen vaihdon raja + + + Tämän leveyden alapuolella sivupalkki taittuu ylävälilehdiksi, pikseleinä. + + + Esikatselun sijainti + + + Näytä esikatselu vain kirjoitettaessa + + + Gitea-tietovarasto + + + Mukautetun tietovaraston manifesti + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index ec917d2..8d4748f 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1217,4 +1217,151 @@ désactivé + + Comportement + + + Raccourcis clavier + + + Notifications + + + Modes d'affichage + + + Historique + + + Aide aux commandes + + + Divulgation du plugin + + + Mode de disposition + + + Opacité + + + Comportement de redimensionnement + + + Ouverture automatique des messages privés + + + Barre latérale + + + Marque + + + Liens + + + Intégrations + + + Crédits + + + Licence + + + Désactive les fondus de thème, les effets de survol et les particules flottantes. + + + Cliquez sur un bouton, puis appuyez sur la combinaison de touches. Échap efface. + + + Passer à l'onglet suivant + + + Passer à l'onglet précédent + + + Changer reconstruit l'atlas de polices, le chat est donc vide un instant. + + + Densité compacte + + + Remplace la disposition en cartes par une ligne par message. + + + Horloge 24 heures + + + Afficher l'historique des sessions précédentes + + + Désactivé, le journal démarre vide à chaque lancement et ne se remplit qu'avec les messages reçus ensuite. + + + Côté de l'aide aux commandes + + + De quel côté la liste de suggestions apparaît pendant la saisie. + + + Mode d'ouverture automatique + + + Où s'ouvre un message privé à son arrivée. + + + Basculer vers l'onglet à chaque message privé + + + Sinon l'onglet s'ouvre en arrière-plan après le premier. + + + Barre latérale + + + Onglets en haut + + + Emplacement des onglets + + + Où se place la liste d'onglets dans la fenêtre principale. + + + Afficher la barre de titre + + + Afficher la barre de titre des fenêtres détachées + + + Opacité inactive + + + S'applique quand une autre fenêtre a le focus. + + + Autoriser le déplacement + + + Autoriser le redimensionnement + + + Seuil de bascule automatique de la barre latérale + + + En dessous de cette largeur, la barre latérale se replie en onglets, en pixels. + + + Position de l'aperçu + + + Afficher l'aperçu uniquement pendant la saisie + + + Dépôt Gitea + + + Manifeste du dépôt personnalisé + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index 7884c81..074ac37 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1216,4 +1216,151 @@ kikapcsolva + + Viselkedés + + + Gyorsbillentyűk + + + Értesítések + + + Megjelenítési módok + + + Előzmények + + + Parancssúgó + + + Bővítmény jelzése + + + Elrendezési mód + + + Átlátszatlanság + + + Átméretezés + + + Suttogás automatikus megnyitása + + + Oldalsáv + + + Márka + + + Hivatkozások + + + Integrációk + + + Közreműködők + + + Licenc + + + Kikapcsolja a témaátmeneteket, a rámutatási elhalványulást és a lebegő részecskéket. + + + Kattints egy gombra, majd nyomd le a billentyűkombinációt. Az Esc törli. + + + Váltás a következő fülre + + + Váltás az előző fülre + + + A váltás újraépíti a betűatlaszt, ezért a csevegés egy pillanatra kiürül. + + + Kompakt sűrűség + + + A kártyás elrendezést üzenetenként egy sorra cseréli. + + + 24 órás óra + + + Korábbi munkamenetek előzményeinek megjelenítése + + + Kikapcsolva a napló minden indításkor üresen kezd, és csak az azóta érkezett üzenetekkel telik meg. + + + Parancssúgó oldala + + + Melyik oldalon jelenik meg a parancslista gépelés közben. + + + Automatikus megnyitás módja + + + Hol nyílik meg egy suttogás, amikor megérkezik. + + + Váltás a fülre minden suttogásnál + + + Egyébként a fül az első után a háttérben nyílik meg. + + + Oldalsáv + + + Felső fülek + + + Fülek elhelyezése + + + Hol helyezkedik el a füllista a főablakban. + + + Címsor megjelenítése + + + Címsor megjelenítése a kiemelt ablakoknál + + + Átlátszatlanság inaktívan + + + Akkor érvényes, ha másik ablak van fókuszban. + + + Mozgatás engedélyezése + + + Átméretezés engedélyezése + + + Oldalsáv automatikus váltásának küszöbe + + + E szélesség alatt az oldalsáv felső fülekké alakul, képpontban. + + + Előnézet helye + + + Előnézet csak gépelés közben + + + Gitea-tároló + + + Egyéni tároló manifesztje + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index ec73987..f9b843c 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1217,4 +1217,151 @@ disattivato + + Comportamento + + + Scorciatoie + + + Notifiche + + + Modalità di visualizzazione + + + Cronologia + + + Aiuto comandi + + + Avviso plugin + + + Modalità di layout + + + Opacità + + + Ridimensionamento + + + Apertura automatica dei sussurri + + + Barra laterale + + + Marchio + + + Collegamenti + + + Integrazioni + + + Riconoscimenti + + + Licenza + + + Disattiva le dissolvenze del tema, gli effetti al passaggio del mouse e le particelle. + + + Fai clic su un pulsante, poi premi la combinazione di tasti. Esc cancella. + + + Passa alla scheda successiva + + + Passa alla scheda precedente + + + Il cambio ricostruisce l'atlante dei caratteri, quindi la chat resta vuota per un attimo. + + + Densità compatta + + + Sostituisce il layout a schede con una riga per messaggio. + + + Orologio 24 ore + + + Mostra la cronologia delle sessioni precedenti + + + Disattivato, il registro parte vuoto a ogni avvio del gioco e si riempie solo con i messaggi ricevuti da quel momento. + + + Lato dell'aiuto comandi + + + Su quale lato compare l'elenco dei suggerimenti mentre scrivi. + + + Modalità di apertura automatica + + + Dove si apre un sussurro quando arriva. + + + Passa alla scheda a ogni sussurro + + + Altrimenti la scheda si apre in secondo piano dopo il primo. + + + Barra laterale + + + Schede in alto + + + Posizione delle schede + + + Dove si trova l'elenco delle schede nella finestra principale. + + + Mostra la barra del titolo + + + Mostra la barra del titolo per le finestre staccate + + + Opacità da inattiva + + + Si applica mentre un'altra finestra ha il focus. + + + Consenti lo spostamento + + + Consenti il ridimensionamento + + + Soglia di passaggio automatico della barra laterale + + + Sotto questa larghezza la barra laterale si ripiega in schede in alto, in pixel. + + + Posizione dell'anteprima + + + Mostra l'anteprima solo durante la digitazione + + + Repository Gitea + + + Manifest del repository personalizzato + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index 8a02c96..d95574f 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1217,4 +1217,151 @@ オフ + + 動作 + + + キー設定 + + + 通知 + + + 表示モード + + + 履歴 + + + コマンドヘルプ + + + プラグインの明示 + + + レイアウトモード + + + 不透明度 + + + サイズ変更の動作 + + + テルの自動オープン + + + サイドバー + + + ブランド + + + リンク + + + 連携 + + + クレジット + + + ライセンス + + + テーマのクロスフェード、ホバー時のフェード、漂う粒子を無効にします。 + + + ボタンをクリックしてからキーの組み合わせを押してください。Esc で解除します。 + + + 次のチャットタブへ + + + 前のチャットタブへ + + + 切り替えるとフォントアトラスが再構築されるため、チャットが一瞬空になります。 + + + コンパクト表示 + + + カード表示を 1 メッセージ 1 行に切り替えます。 + + + 24時間表示 + + + 以前のセッションの履歴を表示 + + + オフの場合、ログはゲーム起動のたびに空から始まり、それ以降に受信したメッセージだけが入ります。 + + + コマンドヘルプの表示位置 + + + 入力中にコマンド候補をどちら側に表示するか。 + + + 自動オープンの方式 + + + テルが届いたときにどこで開くか。 + + + テルのたびにタブへ切り替える + + + そうでない場合、最初の 1 通のあとタブは背面で開きます。 + + + サイドバー + + + 上部タブ + + + タブの配置 + + + メインウィンドウでタブ一覧をどこに置くか。 + + + タイトルバーを表示 + + + ポップアウトのタイトルバーを表示 + + + 非アクティブ時の不透明度 + + + 別のウィンドウがフォーカスされている間に適用されます。 + + + 移動を許可 + + + サイズ変更を許可 + + + サイドバー自動切り替えのしきい値 + + + この幅を下回るとサイドバーが上部タブに折りたたまれます(ピクセル)。 + + + プレビューの位置 + + + 入力中のみプレビューを表示 + + + Gitea リポジトリ + + + カスタムリポジトリのマニフェスト + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index c07c4bf..e69da4f 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1217,4 +1217,151 @@ 끔 + + 동작 + + + 단축키 + + + 알림 + + + 표시 모드 + + + 기록 + + + 명령어 도움말 + + + 플러그인 고지 + + + 레이아웃 모드 + + + 불투명도 + + + 크기 조절 동작 + + + 귓속말 자동 열기 + + + 사이드바 + + + 브랜드 + + + 링크 + + + 연동 + + + 제작 정보 + + + 라이선스 + + + 테마 전환 효과, 마우스 오버 페이드, 떠다니는 입자를 끕니다. + + + 버튼을 클릭한 뒤 키 조합을 누르세요. Esc로 지웁니다. + + + 다음 채팅 탭으로 + + + 이전 채팅 탭으로 + + + 전환하면 폰트 아틀라스를 다시 만들기 때문에 채팅이 잠시 비워집니다. + + + 좁게 표시 + + + 카드 배치를 메시지당 한 줄로 바꿉니다. + + + 24시간제 + + + 이전 세션의 기록 표시 + + + 끄면 게임을 실행할 때마다 기록이 비어 있는 상태로 시작하고, 이후 받은 메시지만 채워집니다. + + + 명령어 도움말 위치 + + + 입력 중에 명령어 힌트 목록을 어느 쪽에 표시할지. + + + 자동 열기 방식 + + + 귓속말이 도착했을 때 어디에서 열릴지. + + + 귓속말마다 탭으로 전환 + + + 그렇지 않으면 첫 통 이후 탭이 배경에서 열립니다. + + + 사이드바 + + + 상단 탭 + + + 탭 배치 + + + 메인 창에서 탭 목록의 위치. + + + 제목 표시줄 보이기 + + + 팝아웃 창의 제목 표시줄 보이기 + + + 비활성 상태 불투명도 + + + 다른 창이 포커스를 가진 동안 적용됩니다. + + + 이동 허용 + + + 크기 조절 허용 + + + 사이드바 자동 전환 임계값 + + + 이 너비 미만이면 사이드바가 상단 탭으로 접힙니다(픽셀). + + + 미리보기 위치 + + + 입력 중에만 미리보기 표시 + + + Gitea 저장소 + + + 커스텀 저장소 매니페스트 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index 43f0671..80aee5f 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1216,4 +1216,151 @@ av + + Oppførsel + + + Hurtigtaster + + + Varsler + + + Visningsmoduser + + + Historikk + + + Kommandohjelp + + + Plugin-varsel + + + Oppsettsmodus + + + Tetthet + + + Størrelsesendring + + + Automatisk åpning av tells + + + Sidepanel + + + Merke + + + Lenker + + + Integrasjoner + + + Bidragsytere + + + Lisens + + + Slår av temaovergang, hover-toning og de drivende partiklene. + + + Klikk på en knapp, og trykk deretter tastekombinasjonen. Esc tømmer. + + + Bytt til neste fane + + + Bytt til forrige fane + + + Bytte bygger fontatlaset på nytt, så chatten er tom et øyeblikk. + + + Kompakt tetthet + + + Bytter kortoppsettet med én linje per melding. + + + 24-timers klokke + + + Vis historikk fra tidligere økter + + + Av betyr at loggen starter tom hver gang spillet startes, og bare fylles med meldinger mottatt etterpå. + + + Side for kommandohjelp + + + Hvilken side listen med kommandotips vises på mens du skriver. + + + Modus for automatisk åpning + + + Hvor en tell åpnes når den kommer. + + + Bytt til fanen ved hver tell + + + Ellers åpnes fanen i bakgrunnen etter den første. + + + Sidepanel + + + Faner øverst + + + Plassering av faner + + + Hvor fanelisten sitter i hovedvinduet. + + + Vis tittellinje + + + Vis tittellinje for utbrutte vinduer + + + Tetthet når inaktiv + + + Gjelder mens et annet vindu har fokus. + + + Tillat flytting + + + Tillat størrelsesendring + + + Terskel for automatisk bytte av sidepanel + + + Under denne bredden brettes sidepanelet til faner øverst, i piksler. + + + Plassering av forhåndsvisning + + + Vis forhåndsvisning bare under skriving + + + Gitea-repositorium + + + Manifest for egendefinert repo + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 77f653c..7f99de1 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1217,4 +1217,151 @@ uit + + Gedrag + + + Sneltoetsen + + + Meldingen + + + Weergavemodi + + + Geschiedenis + + + Commandohulp + + + Plug-invermelding + + + Lay-outmodus + + + Dekking + + + Formaatgedrag + + + Tells automatisch openen + + + Zijbalk + + + Merk + + + Links + + + Integraties + + + Met dank aan + + + Licentie + + + Schakelt themaovergangen, hover-fades en de zwevende deeltjes uit. + + + Klik op een knop en druk daarna de toetsencombinatie. Esc wist. + + + Naar het volgende tabblad + + + Naar het vorige tabblad + + + Wisselen bouwt de lettertype-atlas opnieuw op, dus de chat is even leeg. + + + Compacte dichtheid + + + Vervangt de kaartweergave door één regel per bericht. + + + 24-uursklok + + + Geschiedenis van eerdere sessies tonen + + + Uit betekent dat het logboek bij elke start leeg begint en zich alleen vult met daarna ontvangen berichten. + + + Zijde van de commandohulp + + + Aan welke kant de lijst met commandotips verschijnt tijdens het typen. + + + Modus voor automatisch openen + + + Waar een tell opent wanneer die binnenkomt. + + + Bij elke tell naar het tabblad schakelen + + + Anders opent het tabblad na de eerste op de achtergrond. + + + Zijbalk + + + Tabbladen bovenaan + + + Tabbladpositie + + + Waar de tabbladlijst in het hoofdvenster staat. + + + Titelbalk tonen + + + Titelbalk tonen voor losgemaakte vensters + + + Dekking bij inactiviteit + + + Geldt terwijl een ander venster de focus heeft. + + + Verplaatsen toestaan + + + Formaat wijzigen toestaan + + + Drempel voor automatisch omschakelen van de zijbalk + + + Onder deze breedte vouwt de zijbalk samen tot tabbladen bovenaan, in pixels. + + + Positie van het voorbeeld + + + Voorbeeld alleen tijdens typen tonen + + + Gitea-repository + + + Manifest van eigen repository + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index 5fa9e63..ae1f725 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1216,4 +1216,151 @@ wyłączone + + Zachowanie + + + Skróty klawiszowe + + + Powiadomienia + + + Tryby wyświetlania + + + Historia + + + Pomoc do poleceń + + + Ujawnienie wtyczki + + + Tryb układu + + + Nieprzezroczystość + + + Zmiana rozmiaru + + + Automatyczne otwieranie szeptów + + + Panel boczny + + + Marka + + + Odnośniki + + + Integracje + + + Twórcy + + + Licencja + + + Wyłącza przenikanie motywów, wygaszanie przy najechaniu i unoszące się cząstki. + + + Kliknij przycisk, a następnie naciśnij kombinację klawiszy. Esc czyści. + + + Przejdź do następnej karty + + + Przejdź do poprzedniej karty + + + Zmiana przebudowuje atlas czcionek, więc czat na chwilę pustoszeje. + + + Kompaktowa gęstość + + + Zamienia układ kart na jedną linię na wiadomość. + + + Zegar 24-godzinny + + + Pokaż historię z poprzednich sesji + + + Wyłączone oznacza, że dziennik startuje pusty przy każdym uruchomieniu gry i wypełnia się tylko wiadomościami odebranymi od tego momentu. + + + Strona pomocy do poleceń + + + Po której stronie pojawia się lista podpowiedzi podczas pisania. + + + Tryb automatycznego otwierania + + + Gdzie otwiera się szept, gdy nadejdzie. + + + Przełączaj na kartę przy każdym szepcie + + + W przeciwnym razie karta po pierwszym otwiera się w tle. + + + Panel boczny + + + Karty u góry + + + Rozmieszczenie kart + + + Gdzie w oknie głównym znajduje się lista kart. + + + Pokaż pasek tytułu + + + Pokaż pasek tytułu w oknach odłączonych + + + Nieprzezroczystość gdy nieaktywne + + + Obowiązuje, gdy inne okno ma fokus. + + + Zezwól na przesuwanie + + + Zezwól na zmianę rozmiaru + + + Próg automatycznego przełączania panelu bocznego + + + Poniżej tej szerokości panel boczny zwija się w karty u góry, w pikselach. + + + Pozycja podglądu + + + Pokaż podgląd tylko podczas pisania + + + Repozytorium Gitea + + + Manifest własnego repozytorium + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index 8544c04..3c3c5e6 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1217,4 +1217,151 @@ desativado + + Comportamento + + + Atalhos de teclado + + + Notificações + + + Modos de exibição + + + Histórico + + + Ajuda de comandos + + + Aviso de plugin + + + Modo de layout + + + Opacidade + + + Comportamento de redimensionamento + + + Abertura automática de sussurros + + + Barra lateral + + + Marca + + + Links + + + Integrações + + + Créditos + + + Licença + + + Desativa as transições de tema, os efeitos ao passar o cursor e as partículas. + + + Clique em um botão e depois pressione a combinação de teclas. Esc limpa. + + + Ir para a próxima aba + + + Ir para a aba anterior + + + Trocar reconstrói o atlas de fontes, então o chat fica vazio por um instante. + + + Densidade compacta + + + Troca o layout de cartões por uma linha por mensagem. + + + Relógio de 24 horas + + + Mostrar histórico de sessões anteriores + + + Desligado, o registro começa vazio a cada início do jogo e só se enche com as mensagens recebidas a partir daí. + + + Lado da ajuda de comandos + + + De que lado a lista de sugestões aparece enquanto você digita. + + + Modo de abertura automática + + + Onde um sussurro abre quando chega. + + + Mudar para a aba a cada sussurro + + + Caso contrário, a aba abre em segundo plano após o primeiro. + + + Barra lateral + + + Abas no topo + + + Posição das abas + + + Onde a lista de abas fica na janela principal. + + + Mostrar barra de título + + + Mostrar barra de título nas janelas destacadas + + + Opacidade inativa + + + Aplica-se enquanto outra janela está em foco. + + + Permitir mover + + + Permitir redimensionar + + + Limite de troca automática da barra lateral + + + Abaixo desta largura a barra lateral vira abas no topo, em pixels. + + + Posição da pré-visualização + + + Mostrar a pré-visualização apenas ao digitar + + + Repositório Gitea + + + Manifesto do repositório personalizado + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 40c813b..9e3512a 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1216,4 +1216,151 @@ desativado + + Comportamento + + + Atalhos de teclado + + + Notificações + + + Modos de visualização + + + Histórico + + + Ajuda de comandos + + + Aviso de plugin + + + Modo de disposição + + + Opacidade + + + Comportamento de redimensionamento + + + Abertura automática de sussurros + + + Barra lateral + + + Marca + + + Ligações + + + Integrações + + + Créditos + + + Licença + + + Desativa as transições de tema, os efeitos ao passar o cursor e as partículas. + + + Clique num botão e depois prima a combinação de teclas. Esc limpa. + + + Ir para o separador seguinte + + + Ir para o separador anterior + + + Mudar reconstrói o atlas de tipos de letra, por isso o chat fica vazio por um instante. + + + Densidade compacta + + + Troca a disposição em cartões por uma linha por mensagem. + + + Relógio de 24 horas + + + Mostrar histórico de sessões anteriores + + + Desligado, o registo começa vazio a cada início do jogo e só se enche com as mensagens recebidas a partir daí. + + + Lado da ajuda de comandos + + + De que lado a lista de sugestões aparece enquanto escreve. + + + Modo de abertura automática + + + Onde um sussurro abre quando chega. + + + Mudar para o separador a cada sussurro + + + Caso contrário, o separador abre em segundo plano após o primeiro. + + + Barra lateral + + + Separadores no topo + + + Posição dos separadores + + + Onde a lista de separadores fica na janela principal. + + + Mostrar barra de título + + + Mostrar barra de título nas janelas destacadas + + + Opacidade inativa + + + Aplica-se enquanto outra janela está em foco. + + + Permitir mover + + + Permitir redimensionar + + + Limite de troca automática da barra lateral + + + Abaixo desta largura a barra lateral passa a separadores no topo, em píxeis. + + + Posição da pré-visualização + + + Mostrar a pré-visualização apenas ao escrever + + + Repositório Gitea + + + Manifesto do repositório personalizado + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index 9d91d8c..481500c 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1228,4 +1228,151 @@ off + + Behaviour + + + Keybinds + + + Notifications + + + Display modes + + + History + + + Command help + + + Plugin disclosure + + + Layout mode + + + Opacity + + + Resize behaviour + + + Tell auto-open + + + Sidebar + + + Brand + + + Links + + + Integrations + + + Credits + + + License + + + Turns off theme crossfades, hover fades and the drifting motes. + + + Click a button, then press the key combination. Esc clears. + + + Cycle to next chat tab + + + Cycle to previous chat tab + + + Switching rebuilds the font atlas, so the chat goes blank for a moment. + + + Compact density + + + Trades the card layout for one line per message. + + + 24-hour clock + + + Show history from previous sessions + + + Off means the log starts empty each time the game launches and only fills with messages received since. + + + Command help side + + + Which side the command hint list appears on while typing. + + + Tell auto-open mode + + + Where a tell opens when it arrives. + + + Switch to the tab on every tell + + + Otherwise the tab opens in the background after the first one. + + + Sidebar + + + Top tabs + + + Tab placement + + + Where the tab list sits in the main window. + + + Show title bar + + + Show title bar for pop-outs + + + Inactive opacity + + + Applies while another window has focus. + + + Allow movement + + + Allow resize + + + Sidebar auto-switch threshold + + + Below this width the sidebar folds into top tabs, in pixels. + + + Preview position + + + Only show preview when typing + + + Gitea repository + + + Custom repo manifest + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index 66138da..2674711 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1217,4 +1217,151 @@ dezactivat + + Comportament + + + Scurtături + + + Notificări + + + Moduri de afișare + + + Istoric + + + Ajutor pentru comenzi + + + Notificare extensie + + + Mod de aranjare + + + Opacitate + + + Comportament la redimensionare + + + Deschidere automată a mesajelor private + + + Bară laterală + + + Marcă + + + Linkuri + + + Integrări + + + Credite + + + Licență + + + Dezactivează tranzițiile temei, efectele la trecerea cursorului și particulele. + + + Fă clic pe un buton, apoi apasă combinația de taste. Esc șterge. + + + Treci la fila următoare + + + Treci la fila anterioară + + + Schimbarea reconstruiește atlasul de fonturi, deci chatul rămâne gol o clipă. + + + Densitate compactă + + + Înlocuiește aranjarea în carduri cu o linie per mesaj. + + + Ceas de 24 de ore + + + Afișează istoricul din sesiunile anterioare + + + Dezactivat, jurnalul pornește gol la fiecare lansare a jocului și se umple doar cu mesajele primite ulterior. + + + Partea ajutorului pentru comenzi + + + În ce parte apare lista de sugestii în timp ce scrii. + + + Mod de deschidere automată + + + Unde se deschide un mesaj privat când sosește. + + + Comută pe filă la fiecare mesaj privat + + + Altfel fila se deschide în fundal după primul. + + + Bară laterală + + + File sus + + + Poziția filelor + + + Unde stă lista de file în fereastra principală. + + + Afișează bara de titlu + + + Afișează bara de titlu la ferestrele desprinse + + + Opacitate când e inactiv + + + Se aplică cât timp altă fereastră are focusul. + + + Permite mutarea + + + Permite redimensionarea + + + Pragul de comutare automată a barei laterale + + + Sub această lățime bara laterală se pliază în file sus, în pixeli. + + + Poziția previzualizării + + + Afișează previzualizarea doar în timp ce scrii + + + Depozit Gitea + + + Manifest depozit personalizat + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index 097e003..f3ad66c 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1217,4 +1217,151 @@ выключено + + Поведение + + + Горячие клавиши + + + Уведомления + + + Режимы отображения + + + История + + + Справка по командам + + + Уведомление о плагине + + + Режим компоновки + + + Непрозрачность + + + Изменение размера + + + Автооткрытие шёпота + + + Боковая панель + + + Бренд + + + Ссылки + + + Интеграции + + + Благодарности + + + Лицензия + + + Отключает переходы темы, затухание при наведении и плавающие частицы. + + + Нажмите кнопку, затем нажмите сочетание клавиш. Esc очищает. + + + Перейти к следующей вкладке + + + Перейти к предыдущей вкладке + + + Переключение перестраивает атлас шрифтов, поэтому чат ненадолго пустеет. + + + Компактная плотность + + + Меняет карточную раскладку на одну строку на сообщение. + + + 24-часовой формат + + + Показывать историю прошлых сеансов + + + Выключено: журнал начинается пустым при каждом запуске игры и заполняется только сообщениями, полученными после. + + + Сторона справки по командам + + + С какой стороны появляется список подсказок при вводе. + + + Режим автооткрытия + + + Где открывается шёпот при получении. + + + Переключаться на вкладку при каждом шёпоте + + + Иначе вкладка после первого открывается в фоне. + + + Боковая панель + + + Вкладки сверху + + + Расположение вкладок + + + Где в главном окне находится список вкладок. + + + Показывать заголовок окна + + + Показывать заголовок в отдельных окнах + + + Непрозрачность в неактивном состоянии + + + Действует, пока фокус на другом окне. + + + Разрешить перемещение + + + Разрешить изменение размера + + + Порог автопереключения боковой панели + + + Ниже этой ширины боковая панель сворачивается во вкладки сверху, в пикселях. + + + Положение предпросмотра + + + Показывать предпросмотр только при вводе + + + Репозиторий Gitea + + + Манифест пользовательского репозитория + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index 7576dea..68f0bb8 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1217,4 +1217,151 @@ av + + Beteende + + + Kortkommandon + + + Aviseringar + + + Visningslägen + + + Historik + + + Kommandohjälp + + + Tilläggsupplysning + + + Layoutläge + + + Opacitet + + + Storleksändring + + + Automatisk öppning av viskningar + + + Sidofält + + + Varumärke + + + Länkar + + + Integrationer + + + Medverkande + + + Licens + + + Stänger av temaövertoningar, hovertoningar och de svävande partiklarna. + + + Klicka på en knapp och tryck sedan tangentkombinationen. Esc rensar. + + + Byt till nästa flik + + + Byt till föregående flik + + + Byte bygger om typsnittsatlasen, så chatten är tom ett ögonblick. + + + Kompakt täthet + + + Byter kortlayouten mot en rad per meddelande. + + + 24-timmarsklocka + + + Visa historik från tidigare sessioner + + + Av innebär att loggen börjar tom vid varje spelstart och bara fylls med meddelanden som tas emot därefter. + + + Sida för kommandohjälp + + + Vilken sida listan med kommandotips visas på medan du skriver. + + + Läge för automatisk öppning + + + Var en viskning öppnas när den kommer. + + + Byt till fliken vid varje viskning + + + Annars öppnas fliken i bakgrunden efter den första. + + + Sidofält + + + Flikar upptill + + + Flikarnas placering + + + Var fliklistan sitter i huvudfönstret. + + + Visa namnlist + + + Visa namnlist för utbrutna fönster + + + Opacitet vid inaktivitet + + + Gäller medan ett annat fönster har fokus. + + + Tillåt flytt + + + Tillåt storleksändring + + + Tröskel för automatiskt byte av sidofält + + + Under denna bredd viks sidofältet ihop till flikar upptill, i pixlar. + + + Förhandsgranskningens position + + + Visa förhandsgranskning endast vid skrivning + + + Gitea-arkiv + + + Manifest för eget arkiv + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index c8d2658..aca3983 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1216,4 +1216,151 @@ kapalı + + Davranış + + + Kısayol tuşları + + + Bildirimler + + + Görünüm modları + + + Geçmiş + + + Komut yardımı + + + Eklenti bildirimi + + + Yerleşim modu + + + Saydamlık + + + Boyutlandırma davranışı + + + Fısıltıları otomatik açma + + + Kenar çubuğu + + + Marka + + + Bağlantılar + + + Entegrasyonlar + + + Katkıda bulunanlar + + + Lisans + + + Tema geçişlerini, üzerine gelme solmalarını ve süzülen parçacıkları kapatır. + + + Bir düğmeye tıklayın, ardından tuş kombinasyonuna basın. Esc temizler. + + + Sonraki sekmeye geç + + + Önceki sekmeye geç + + + Değiştirmek yazı tipi atlasını yeniden oluşturur, bu yüzden sohbet bir an boşalır. + + + Sıkışık yoğunluk + + + Kart düzenini ileti başına tek satırla değiştirir. + + + 24 saat biçimi + + + Önceki oturumların geçmişini göster + + + Kapalıyken günlük her oyun açılışında boş başlar ve yalnızca o andan sonra gelen iletilerle dolar. + + + Komut yardımı tarafı + + + Yazarken komut ipucu listesinin hangi tarafta görüneceği. + + + Otomatik açma modu + + + Bir fısıltı geldiğinde nerede açılacağı. + + + Her fısıltıda sekmeye geç + + + Aksi hâlde sekme ilkinden sonra arka planda açılır. + + + Kenar çubuğu + + + Üst sekmeler + + + Sekme yerleşimi + + + Sekme listesinin ana pencerede nerede duracağı. + + + Başlık çubuğunu göster + + + Ayrık pencereler için başlık çubuğunu göster + + + Etkin değilken saydamlık + + + Başka bir pencere odaktayken geçerlidir. + + + Taşımaya izin ver + + + Yeniden boyutlandırmaya izin ver + + + Kenar çubuğu otomatik geçiş eşiği + + + Bu genişliğin altında kenar çubuğu üst sekmelere katlanır, piksel olarak. + + + Önizleme konumu + + + Önizlemeyi yalnızca yazarken göster + + + Gitea deposu + + + Özel depo bildirimi + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 8ac78e7..b92bf4b 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1216,4 +1216,151 @@ вимкнено + + Поведінка + + + Гарячі клавіші + + + Сповіщення + + + Режими відображення + + + Історія + + + Довідка команд + + + Повідомлення про плагін + + + Режим компонування + + + Непрозорість + + + Зміна розміру + + + Автовідкриття шепоту + + + Бічна панель + + + Бренд + + + Посилання + + + Інтеграції + + + Подяки + + + Ліцензія + + + Вимикає переходи теми, згасання при наведенні та плаваючі частинки. + + + Натисніть кнопку, потім натисніть комбінацію клавіш. Esc очищає. + + + Перейти до наступної вкладки + + + Перейти до попередньої вкладки + + + Перемикання перебудовує атлас шрифтів, тому чат на мить порожніє. + + + Компактна щільність + + + Замінює карткове компонування одним рядком на повідомлення. + + + 24-годинний формат + + + Показувати історію попередніх сеансів + + + Вимкнено: журнал починається порожнім за кожного запуску гри й заповнюється лише повідомленнями, отриманими після цього. + + + Сторона довідки команд + + + З якого боку з'являється список підказок під час введення. + + + Режим автовідкриття + + + Де відкривається шепіт, коли надходить. + + + Перемикатися на вкладку за кожного шепоту + + + Інакше вкладка після першого відкривається у фоні. + + + Бічна панель + + + Вкладки згори + + + Розташування вкладок + + + Де в головному вікні розташований список вкладок. + + + Показувати рядок заголовка + + + Показувати рядок заголовка у відокремлених вікнах + + + Непрозорість у неактивному стані + + + Діє, поки фокус на іншому вікні. + + + Дозволити переміщення + + + Дозволити зміну розміру + + + Поріг автоперемикання бічної панелі + + + Нижче цієї ширини бічна панель згортається у вкладки згори, у пікселях. + + + Розташування попереднього перегляду + + + Показувати попередній перегляд лише під час введення + + + Репозиторій Gitea + + + Маніфест власного репозиторію + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index 95890cd..895e2da 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1217,4 +1217,151 @@ 关闭 + + 行为 + + + 快捷键 + + + 通知 + + + 显示模式 + + + 历史记录 + + + 命令帮助 + + + 插件提示 + + + 布局模式 + + + 不透明度 + + + 调整大小行为 + + + 自动打开密语 + + + 侧边栏 + + + 品牌 + + + 链接 + + + 集成 + + + 制作人员 + + + 许可证 + + + 关闭主题淡入淡出、悬停渐变和漂浮粒子。 + + + 点击按钮后按下组合键。Esc 清除。 + + + 切换到下一个标签 + + + 切换到上一个标签 + + + 切换会重建字体图集,因此聊天会短暂空白。 + + + 紧凑密度 + + + 将卡片布局换成每条消息一行。 + + + 24 小时制 + + + 显示之前会话的历史记录 + + + 关闭时,日志在每次启动游戏时都是空的,只会填入此后收到的消息。 + + + 命令帮助的位置 + + + 输入时命令提示列表出现在哪一侧。 + + + 自动打开方式 + + + 密语到达时在何处打开。 + + + 每条密语都切换到标签 + + + 否则标签会在第一条之后于后台打开。 + + + 侧边栏 + + + 顶部标签 + + + 标签位置 + + + 标签列表在主窗口中的位置。 + + + 显示标题栏 + + + 显示弹出窗口的标题栏 + + + 非活动时的不透明度 + + + 当另一个窗口获得焦点时生效。 + + + 允许移动 + + + 允许调整大小 + + + 侧边栏自动切换阈值 + + + 低于此宽度时侧边栏会折叠为顶部标签,单位为像素。 + + + 预览位置 + + + 仅在输入时显示预览 + + + Gitea 仓库 + + + 自定义仓库清单 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index 4dbda0a..1409b26 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1217,4 +1217,151 @@ 關閉 + + 行為 + + + 快速鍵 + + + 通知 + + + 顯示模式 + + + 歷史紀錄 + + + 指令說明 + + + 外掛提示 + + + 版面模式 + + + 不透明度 + + + 調整大小行為 + + + 自動開啟密語 + + + 側邊欄 + + + 品牌 + + + 連結 + + + 整合 + + + 製作人員 + + + 授權 + + + 關閉主題淡入淡出、滑過漸變和漂浮粒子。 + + + 點擊按鈕後按下組合鍵。Esc 清除。 + + + 切換到下一個分頁 + + + 切換到上一個分頁 + + + 切換會重建字型圖集,因此聊天會短暫空白。 + + + 緊湊密度 + + + 將卡片版面換成每則訊息一行。 + + + 24 小時制 + + + 顯示先前工作階段的歷史紀錄 + + + 關閉時,日誌在每次啟動遊戲時都是空的,只會填入此後收到的訊息。 + + + 指令說明的位置 + + + 輸入時指令提示清單出現在哪一側。 + + + 自動開啟方式 + + + 密語到達時在何處開啟。 + + + 每則密語都切換到分頁 + + + 否則分頁會在第一則之後於背景開啟。 + + + 側邊欄 + + + 頂部分頁 + + + 分頁位置 + + + 分頁清單在主視窗中的位置。 + + + 顯示標題列 + + + 顯示彈出視窗的標題列 + + + 非作用中的不透明度 + + + 當另一個視窗取得焦點時生效。 + + + 允許移動 + + + 允許調整大小 + + + 側邊欄自動切換閾值 + + + 低於此寬度時側邊欄會摺疊為頂部分頁,單位為像素。 + + + 預覽位置 + + + 僅在輸入時顯示預覽 + + + Gitea 儲存庫 + + + 自訂儲存庫資訊清單 + \ No newline at end of file diff --git a/HellionChat/Ui/Components/Settings/Tabs/AboutTab.cs b/HellionChat/Ui/Components/Settings/Tabs/AboutTab.cs index cfce8ef..8d4e1b8 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/AboutTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/AboutTab.cs @@ -43,15 +43,15 @@ internal sealed class AboutTab // real render can never let the integrations-status SelfTest pass falsely. LastHonorificStatusKey = null; DrawPluginInfo(); - DrawSectionHeader("Brand"); + DrawSectionHeader(HellionStrings.Settings_Section_Brand); DrawBrand(); - DrawSectionHeader("Links"); + DrawSectionHeader(HellionStrings.Settings_Section_Links); DrawLinks(); - DrawSectionHeader("Integrations"); + DrawSectionHeader(HellionStrings.Settings_Section_Integrations); DrawIntegrations(); - DrawSectionHeader("Credits"); + DrawSectionHeader(HellionStrings.Settings_Section_Credits); DrawCredits(); - DrawSectionHeader("License"); + DrawSectionHeader(HellionStrings.Settings_Section_License); DrawLicense(); } @@ -94,8 +94,11 @@ internal sealed class AboutTab private void DrawLinks() { DrawLinkButton("Discord (Hellion Forge)", BrandingLinks.HellionForgeDiscordInvite); - DrawLinkButton("Gitea repository", BrandingLinks.HellionChatRepo); - DrawLinkButton("Custom repo manifest", BrandingLinks.HellionChatCustomRepoManifest); + DrawLinkButton(HellionStrings.Settings_About_GiteaRepo, BrandingLinks.HellionChatRepo); + DrawLinkButton( + HellionStrings.Settings_About_CustomRepo, + BrandingLinks.HellionChatCustomRepoManifest + ); } private void DrawIntegrations() diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs index 09119f8..a4deb90 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs @@ -77,26 +77,37 @@ internal sealed class ChannelsTab ImGui.TextWrapped(HellionStrings.ChatLog_AutoTellTabs_ConflictHint); } - if (_w.Section(ImGui.GetID("channels.autoopen"u8), "Tell auto-open")) + if ( + _w.Section( + ImGui.GetID("channels.autoopen"u8), + HellionStrings.Settings_Section_TellAutoOpen + ) + ) { _w.EnumComboRow( ImGui.GetID("channels.autoopen.mode"u8), - "Tell auto-open mode", - "Where a tell opens when it arrives.", + HellionStrings.Settings_Channels_TellAutoOpenMode_Name, + HellionStrings.Settings_Channels_TellAutoOpenMode_Description, () => Plugin.Config.TellAutoOpenMode, v => Plugin.Config.TellAutoOpenMode = v, v => v.Name() ); _w.ToggleRow( ImGui.GetID("channels.autoopen.switch"u8), - "Switch to the tab on every tell", - "Otherwise the tab opens in the background after the first one.", + HellionStrings.Settings_Channels_TellSwitchAlways_Name, + HellionStrings.Settings_Channels_TellSwitchAlways_Description, () => Plugin.Config.TellAutoOpenSwitchAlways, v => Plugin.Config.TellAutoOpenSwitchAlways = v ); } - if (_w.Section(ImGui.GetID("channels.sidebar"u8), "Sidebar", open: false)) + if ( + _w.Section( + ImGui.GetID("channels.sidebar"u8), + HellionStrings.Settings_Section_Sidebar, + open: false + ) + ) { // Bounds come from the constants rather than repeating the numbers, // so the slider cannot drift away from the clamp in Sidebar.GetWidth. diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs index 0116007..a259b47 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs @@ -23,18 +23,18 @@ internal sealed class ChatTab public void Draw() { - if (_w.Section(ImGui.GetID("chat.display"u8), "Display modes")) + if (_w.Section(ImGui.GetID("chat.display"u8), HellionStrings.Settings_Section_DisplayModes)) { _w.ToggleRow( ImGui.GetID("chat.display.density"u8), - "Compact density", - "Trades the card layout for one line per message.", + HellionStrings.Settings_Chat_CompactDensity_Name, + HellionStrings.Settings_Chat_CompactDensity_Description, () => Plugin.Config.UseCompactDensity, v => Plugin.Config.UseCompactDensity = v ); _w.ToggleRow( ImGui.GetID("chat.display.clock"u8), - "24-hour clock", + HellionStrings.Settings_Chat_Clock24_Name, null, () => Plugin.Config.Use24HourClock, v => Plugin.Config.Use24HourClock = v @@ -61,7 +61,7 @@ internal sealed class ChatTab ); } - if (_w.Section(ImGui.GetID("chat.history"u8), "History")) + if (_w.Section(ImGui.GetID("chat.history"u8), HellionStrings.Settings_Section_History)) { // The one setting that decides whether the chat log shows anything // from before this game session. It had no control at all: only the @@ -74,9 +74,8 @@ internal sealed class ChatTab // writes a field nothing reads. _w.ToggleRow( ImGui.GetID("chat.history.previoussessions"u8), - "Show history from previous sessions", - "Off means the log starts empty each time the game launches and " - + "only fills with messages received since.", + HellionStrings.Settings_Chat_PreviousSessions_Name, + HellionStrings.Settings_Chat_PreviousSessions_Description, () => Plugin.Config.FilterIncludePreviousSessions, v => { @@ -88,19 +87,31 @@ internal sealed class ChatTab ); } - if (_w.Section(ImGui.GetID("chat.commandhelp"u8), "Command help", open: false)) + if ( + _w.Section( + ImGui.GetID("chat.commandhelp"u8), + HellionStrings.Settings_Section_CommandHelp, + open: false + ) + ) { _w.EnumComboRow( ImGui.GetID("chat.commandhelp.side"u8), - "Command help side", - "Which side the command hint list appears on while typing.", + HellionStrings.Settings_Chat_CommandHelpSide_Name, + HellionStrings.Settings_Chat_CommandHelpSide_Description, () => Plugin.Config.CommandHelpSide, v => Plugin.Config.CommandHelpSide = v, v => v.Name() ); } - if (_w.Section(ImGui.GetID("chat.disclosure"u8), "Plugin disclosure", open: false)) + if ( + _w.Section( + ImGui.GetID("chat.disclosure"u8), + HellionStrings.Settings_Section_PluginDisclosure, + open: false + ) + ) { _w.ToggleRow( ImGui.GetID("chat.disclosure.notify"u8), diff --git a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs index 3bec045..f7e0c31 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs @@ -34,29 +34,34 @@ internal sealed class GeneralTab public void Draw() { - if (_w.Section(ImGui.GetID("general.behaviour"u8), "Behaviour")) + if ( + _w.Section( + ImGui.GetID("general.behaviour"u8), + HellionStrings.Settings_Section_Behaviour + ) + ) { _w.ToggleRow( ImGui.GetID("general.behaviour.reducemotion"u8), HellionStrings.Settings_ThemeAndLayout_ReduceMotion_Name, - "Turns off theme crossfades, hover fades and the drifting motes.", + HellionStrings.Settings_General_ReduceMotion_Description, () => Plugin.Config.ReduceMotion, v => Plugin.Config.ReduceMotion = v ); DrawLanguagePicker(); } - if (_w.Section(ImGui.GetID("general.keybinds"u8), "Keybinds")) + if (_w.Section(ImGui.GetID("general.keybinds"u8), HellionStrings.Settings_Section_Keybinds)) { - ImGui.TextDisabled("Click a button, then press the key combination. Esc clears."); + ImGui.TextDisabled(HellionStrings.Settings_Keybinds_Hint); DrawKeybind( - "Cycle to next chat tab", + HellionStrings.Settings_Keybinds_CycleNext, "ChatTabForwardKeybind", () => Plugin.Config.ChatTabForward, v => Plugin.Config.ChatTabForward = v ); DrawKeybind( - "Cycle to previous chat tab", + HellionStrings.Settings_Keybinds_CyclePrevious, "ChatTabBackwardKeybind", () => Plugin.Config.ChatTabBackward, v => Plugin.Config.ChatTabBackward = v @@ -64,7 +69,12 @@ internal sealed class GeneralTab DrawKeybindModePicker(); } - if (_w.Section(ImGui.GetID("general.notifications"u8), "Notifications")) + if ( + _w.Section( + ImGui.GetID("general.notifications"u8), + HellionStrings.Settings_Section_Notifications + ) + ) { // The game reports a failed tell in the log only, where a player who // is typing does not see it. On by default and never reachable. @@ -110,7 +120,7 @@ internal sealed class GeneralTab _w.Row( ImGui.GetID("general.behaviour.language"u8), Language.Options_Language_Name, - "Switching rebuilds the font atlas, so the chat goes blank for a moment.", + HellionStrings.Settings_General_Language_Description, ctx => { ImGui.SetNextItemWidth(ctx.ControlWidth); diff --git a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs index 4ca54ba..dd45dc6 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs @@ -18,7 +18,15 @@ internal sealed class WindowTab MainWindowLayoutMode.Sidebar, MainWindowLayoutMode.TopTabs, ]; - private static readonly string[] LayoutLabels = ["Sidebar", "Top tabs"]; + + // Built per call rather than cached: a runtime language switch has to reach + // these, and a static array would keep whichever language the plugin + // happened to start in. + private static string[] LayoutLabels => + [ + HellionStrings.Settings_Window_LayoutSidebar, + HellionStrings.Settings_Window_LayoutTopTabs, + ]; public WindowTab(Plugin plugin, TokenResolver resolver) { @@ -29,12 +37,12 @@ internal sealed class WindowTab { // ASCII literals, not the visible titles: the keys have to survive a // language change, and u8 literals cost no allocation. - if (_w.Section(ImGui.GetID("window.layout"u8), "Layout mode")) + if (_w.Section(ImGui.GetID("window.layout"u8), HellionStrings.Settings_Section_LayoutMode)) { _w.SegmentRow( ImGui.GetID("window.layout.mode"u8), - "Tab placement", - "Where the tab list sits in the main window.", + HellionStrings.Settings_Window_TabPlacement_Name, + HellionStrings.Settings_Window_TabPlacement_Description, LayoutValues, LayoutLabels, () => Plugin.Config.MainWindowLayoutMode, @@ -51,14 +59,14 @@ internal sealed class WindowTab { _w.ToggleRow( ImGui.GetID("window.style.titlebar"u8), - "Show title bar", + HellionStrings.Settings_Window_TitleBar_Name, null, () => Plugin.Config.ShowTitleBar, v => Plugin.Config.ShowTitleBar = v ); _w.ToggleRow( ImGui.GetID("window.style.popouttitlebar"u8), - "Show title bar for pop-outs", + HellionStrings.Settings_Window_PopoutTitleBar_Name, null, () => Plugin.Config.ShowPopOutTitleBar, v => Plugin.Config.ShowPopOutTitleBar = v @@ -72,7 +80,7 @@ internal sealed class WindowTab ); } - if (_w.Section(ImGui.GetID("window.opacity"u8), "Opacity")) + if (_w.Section(ImGui.GetID("window.opacity"u8), HellionStrings.Settings_Section_Opacity)) { _w.SliderFloatRow( ImGui.GetID("window.opacity.active"u8), @@ -85,8 +93,8 @@ internal sealed class WindowTab ); _w.SliderFloatRow( ImGui.GetID("window.opacity.inactive"u8), - "Inactive opacity", - "Applies while another window has focus.", + HellionStrings.Settings_Window_InactiveOpacity_Name, + HellionStrings.Settings_Window_InactiveOpacity_Description, () => Plugin.Config.WindowOpacityInactive, v => Plugin.Config.WindowOpacityInactive = v, 0.1f, @@ -94,26 +102,31 @@ internal sealed class WindowTab ); } - if (_w.Section(ImGui.GetID("window.resize"u8), "Resize behaviour")) + if ( + _w.Section( + ImGui.GetID("window.resize"u8), + HellionStrings.Settings_Section_ResizeBehaviour + ) + ) { _w.ToggleRow( ImGui.GetID("window.resize.move"u8), - "Allow movement", + HellionStrings.Settings_Window_AllowMove_Name, null, () => Plugin.Config.CanMove, v => Plugin.Config.CanMove = v ); _w.ToggleRow( ImGui.GetID("window.resize.resize"u8), - "Allow resize", + HellionStrings.Settings_Window_AllowResize_Name, null, () => Plugin.Config.CanResize, v => Plugin.Config.CanResize = v ); _w.SliderIntRow( ImGui.GetID("window.resize.threshold"u8), - "Sidebar auto-switch threshold", - "Below this width the sidebar folds into top tabs, in pixels.", + HellionStrings.Settings_Window_SidebarThreshold_Name, + HellionStrings.Settings_Window_SidebarThreshold_Description, () => Plugin.Config.SidebarAutoSwitchThresholdPx, v => Plugin.Config.SidebarAutoSwitchThresholdPx = v, 200, @@ -131,7 +144,7 @@ internal sealed class WindowTab { _w.EnumComboRow( ImGui.GetID("window.preview.position"u8), - "Preview position", + HellionStrings.Settings_Window_PreviewPosition_Name, null, () => Plugin.Config.PreviewPosition, v => Plugin.Config.PreviewPosition = v, @@ -139,7 +152,7 @@ internal sealed class WindowTab ); _w.ToggleRow( ImGui.GetID("window.preview.onlyif"u8), - "Only show preview when typing", + HellionStrings.Settings_Window_PreviewOnlyTyping_Name, null, () => Plugin.Config.OnlyPreviewIf, v => Plugin.Config.OnlyPreviewIf = v From b3c0ec73ca7c939b7cd308ff8cc6e52a68246d9c Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 00:24:28 +0200 Subject: [PATCH 121/129] i18n(settings): keep the game's own vocabulary, and stop duplicating a key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review pass found the translation commit had introduced a split rather than closed one. Every existing string in this plugin leaves "tell" untranslated -- it is what the game calls the thing, in twenty values across the German file alone. My new keys translated it, so the channels tab showed "Auto-Tell- Tabs" and, two rows below, "Flüstern automatisch öffnen". Thirteen of fourteen sampled languages had the same break. The four affected keys now use the established word, which also fixes a German line that had tells "aufgehen" like a door. Turkish said the opposite of what the control does: saydamlık is transparency, the slider is opacity, and 1.0 means fully opaque. Every other language uses the opacity word. Corrected, along with the inactive one beside it. Compact density had grown a second key for a field that already had one. Appearance_UseCompactDensity_Name and _Description were sitting there with no caller -- which is precisely what this cycle exists to fix -- so the chat tab uses those and my duplicate is gone again. The German wizard label for the same field said "Kompakter Density-Modus"; it says the same thing as the other two now. Also: Hungarian called a pop-out window "kiemelt" (highlighted), the project's word is "kiugró"; German called it "ausgeklappt", which is what a menu does; Italian and Polish had a clumsy inactive-opacity label. 435 keys, all 25 files, no gaps, no orphans in either direction. One claim in the previous commit was wrong and is worth stating plainly: it said only log lines, thread names, developer tools and brand strings remained English. The appearance tab is still English throughout -- the theme picker, the colour editor, the font section, the live preview -- and so are parts of the database viewer, which is a documented user command, not a developer tool. That is a separate block, not a footnote. --- .../Resources/HellionStrings.Designer.cs | 2 -- HellionChat/Resources/HellionStrings.ca.resx | 14 ++++---------- HellionChat/Resources/HellionStrings.cs.resx | 14 ++++---------- HellionChat/Resources/HellionStrings.da.resx | 12 +++--------- HellionChat/Resources/HellionStrings.de.resx | 18 ++++++------------ HellionChat/Resources/HellionStrings.el.resx | 8 +------- HellionChat/Resources/HellionStrings.es.resx | 14 ++++---------- HellionChat/Resources/HellionStrings.fi.resx | 14 ++++---------- HellionChat/Resources/HellionStrings.fr.resx | 14 ++++---------- HellionChat/Resources/HellionStrings.hu.resx | 16 +++++----------- HellionChat/Resources/HellionStrings.it.resx | 16 +++++----------- HellionChat/Resources/HellionStrings.ja.resx | 14 ++++---------- HellionChat/Resources/HellionStrings.ko.resx | 14 ++++---------- HellionChat/Resources/HellionStrings.nb.resx | 10 ++-------- HellionChat/Resources/HellionStrings.nl.resx | 8 +------- HellionChat/Resources/HellionStrings.pl.resx | 16 +++++----------- .../Resources/HellionStrings.pt-BR.resx | 14 ++++---------- .../Resources/HellionStrings.pt-PT.resx | 14 ++++---------- HellionChat/Resources/HellionStrings.resx | 6 ------ HellionChat/Resources/HellionStrings.ro.resx | 14 ++++---------- HellionChat/Resources/HellionStrings.ru.resx | 14 ++++---------- HellionChat/Resources/HellionStrings.sv.resx | 14 ++++---------- HellionChat/Resources/HellionStrings.tr.resx | 18 ++++++------------ HellionChat/Resources/HellionStrings.uk.resx | 14 ++++---------- .../Resources/HellionStrings.zh-Hans.resx | 14 ++++---------- .../Resources/HellionStrings.zh-Hant.resx | 14 ++++---------- .../Ui/Components/Settings/Tabs/ChatTab.cs | 4 ++-- 27 files changed, 96 insertions(+), 248 deletions(-) diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index 92b9bbb..8f1e99e 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -404,8 +404,6 @@ internal class HellionStrings internal static string Settings_Keybinds_CycleNext => Get(nameof(Settings_Keybinds_CycleNext)); internal static string Settings_Keybinds_CyclePrevious => Get(nameof(Settings_Keybinds_CyclePrevious)); internal static string Settings_General_Language_Description => Get(nameof(Settings_General_Language_Description)); - internal static string Settings_Chat_CompactDensity_Name => Get(nameof(Settings_Chat_CompactDensity_Name)); - internal static string Settings_Chat_CompactDensity_Description => Get(nameof(Settings_Chat_CompactDensity_Description)); internal static string Settings_Chat_Clock24_Name => Get(nameof(Settings_Chat_Clock24_Name)); internal static string Settings_Chat_PreviousSessions_Name => Get(nameof(Settings_Chat_PreviousSessions_Name)); internal static string Settings_Chat_PreviousSessions_Description => Get(nameof(Settings_Chat_PreviousSessions_Description)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index 1bc978d..3435bf6 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1248,7 +1248,7 @@ Comportament de mida - Obertura automàtica de xiuxiueigs + Obertura automàtica de tell Barra lateral @@ -1283,12 +1283,6 @@ Canviar-ho reconstrueix l'atles de fonts, així que el xat queda en blanc un moment. - - Densitat compacta - - - Canvia la disposició de targetes per una línia per missatge. - Rellotge de 24 hores @@ -1305,13 +1299,13 @@ A quin costat apareix la llista de suggeriments mentre escrius. - Mode d'obertura automàtica + Mode d'obertura automàtica de tell - On s'obre un xiuxiueig quan arriba. + On s'obre un tell quan arriba. - Canvia a la pestanya a cada xiuxiueig + Canvia a la pestanya a cada tell Altrament la pestanya s'obre en segon pla després de la primera. diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index 0d06a6b..2ecfee8 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1247,7 +1247,7 @@ Chování při změně velikosti - Automatické otevření šeptání + Automatické otevření tell Postranní panel @@ -1282,12 +1282,6 @@ Přepnutí přestaví atlas písem, takže chat na okamžik zmizí. - - Kompaktní hustota - - - Vymění rozvržení karet za jeden řádek na zprávu. - 24hodinový formát @@ -1304,13 +1298,13 @@ Na které straně se během psaní zobrazuje seznam nápověd. - Režim automatického otevření + Režim automatického otevření tell - Kde se šeptání otevře, když dorazí. + Kde se tell otevře, když dorazí. - Přepnout na záložku při každém šeptání + Přepnout na kartu při každém tell Jinak se záložka po první zprávě otevře na pozadí. diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index 524aaa9..94c9aa4 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1247,7 +1247,7 @@ Størrelsesændring - Automatisk åbning af tells + Automatisk åbning af tell Sidepanel @@ -1282,12 +1282,6 @@ Skift genopbygger fontatlasset, så chatten er tom et øjeblik. - - Kompakt tæthed - - - Bytter kortlayoutet ud med én linje pr. besked. - 24-timers ur @@ -1304,13 +1298,13 @@ Hvilken side listen med kommandotips vises på, mens du skriver. - Tilstand for automatisk åbning + Tilstand for automatisk åbning af tell Hvor en tell åbnes, når den ankommer. - Skift til fanebladet ved hver tell + Skift til fanen ved hver tell Ellers åbnes fanebladet i baggrunden efter den første. diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index bf9e99a..e988b28 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -283,7 +283,7 @@ N Tell-Messages beim Öffnen eines Auto-Tabs vorladen - Kompakter Density-Modus + Kompakte Dichte Schönere Timestamps (relative Zeit) @@ -1242,7 +1242,7 @@ Größenverhalten - Flüstern automatisch öffnen + Tell automatisch öffnen Seitenleiste @@ -1277,12 +1277,6 @@ Ein Wechsel baut den Schrift-Atlas neu auf, deshalb ist der Chat kurz leer. - - Kompakte Dichte - - - Tauscht das Karten-Layout gegen eine Zeile pro Nachricht. - 24-Stunden-Uhr @@ -1299,13 +1293,13 @@ Auf welcher Seite die Befehlsliste beim Tippen erscheint. - Modus für automatisches Öffnen + Modus für automatisches Tell-Öffnen - Wo ein Flüstern aufgeht, wenn es ankommt. + Wo ein Tell geöffnet wird, wenn er ankommt. - Bei jedem Flüstern zum Tab wechseln + Bei jedem Tell zum Tab wechseln Sonst öffnet der Tab nach dem ersten im Hintergrund. @@ -1326,7 +1320,7 @@ Titelleiste anzeigen - Titelleiste für ausgeklappte Fenster anzeigen + Titelleiste für Pop-out-Fenster anzeigen Deckkraft im Hintergrund diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index 0c22869..e7b9d30 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1282,12 +1282,6 @@ Η αλλαγή ξαναχτίζει τον άτλαντα γραμματοσειρών, οπότε η συνομιλία αδειάζει στιγμιαία. - - Συμπαγής πυκνότητα - - - Αντικαθιστά τη διάταξη καρτών με μία γραμμή ανά μήνυμα. - 24ωρο ρολόι @@ -1304,7 +1298,7 @@ Σε ποια πλευρά εμφανίζεται η λίστα υποδείξεων ενώ πληκτρολογείτε. - Λειτουργία αυτόματου ανοίγματος + Λειτουργία αυτόματου ανοίγματος tell Πού ανοίγει ένα tell όταν φτάνει. diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index ef9dbd8..a067c51 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1248,7 +1248,7 @@ Comportamiento de tamaño - Apertura automática de susurros + Apertura automática de tell Barra lateral @@ -1283,12 +1283,6 @@ Cambiarlo reconstruye el atlas de fuentes, así que el chat queda en blanco un momento. - - Densidad compacta - - - Cambia la disposición de tarjetas por una línea por mensaje. - Reloj de 24 horas @@ -1305,13 +1299,13 @@ En qué lado aparece la lista de sugerencias mientras escribes. - Modo de apertura automática + Modo de apertura automática de tell - Dónde se abre un susurro cuando llega. + Dónde se abre un tell cuando llega. - Cambiar a la pestaña con cada susurro + Cambiar a la pestaña con cada tell Si no, la pestaña se abre en segundo plano tras el primero. diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index 2a70e80..d5dd40b 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1247,7 +1247,7 @@ Koon muuttaminen - Kuiskausten automaattinen avaus + Tellin automaattinen avaus Sivupalkki @@ -1282,12 +1282,6 @@ Vaihto rakentaa fonttiatlaksen uudelleen, joten chat on hetken tyhjä. - - Tiivis tiheys - - - Vaihtaa korttiasettelun yhteen riviin per viesti. - 24 tunnin kello @@ -1304,13 +1298,13 @@ Kummalla puolella komentovihjeluettelo näkyy kirjoitettaessa. - Automaattisen avauksen tila + Tellin automaattisen avauksen tila - Missä kuiskaus avautuu saapuessaan. + Missä tell avautuu saapuessaan. - Vaihda välilehteen jokaisella kuiskauksella + Vaihda välilehteen jokaisella tellillä Muuten välilehti avautuu taustalle ensimmäisen jälkeen. diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index 8d4748f..0ee9d7f 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1248,7 +1248,7 @@ Comportement de redimensionnement - Ouverture automatique des messages privés + Ouverture automatique des tell Barre latérale @@ -1283,12 +1283,6 @@ Changer reconstruit l'atlas de polices, le chat est donc vide un instant. - - Densité compacte - - - Remplace la disposition en cartes par une ligne par message. - Horloge 24 heures @@ -1305,13 +1299,13 @@ De quel côté la liste de suggestions apparaît pendant la saisie. - Mode d'ouverture automatique + Mode d'ouverture automatique des tell - Où s'ouvre un message privé à son arrivée. + Où s'ouvre un tell à son arrivée. - Basculer vers l'onglet à chaque message privé + Basculer vers l'onglet à chaque tell Sinon l'onglet s'ouvre en arrière-plan après le premier. diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index 074ac37..548753d 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1247,7 +1247,7 @@ Átméretezés - Suttogás automatikus megnyitása + Tell automatikus megnyitása Oldalsáv @@ -1282,12 +1282,6 @@ A váltás újraépíti a betűatlaszt, ezért a csevegés egy pillanatra kiürül. - - Kompakt sűrűség - - - A kártyás elrendezést üzenetenként egy sorra cseréli. - 24 órás óra @@ -1304,13 +1298,13 @@ Melyik oldalon jelenik meg a parancslista gépelés közben. - Automatikus megnyitás módja + Tell automatikus megnyitásának módja - Hol nyílik meg egy suttogás, amikor megérkezik. + Hol nyílik meg egy tell, amikor megérkezik. - Váltás a fülre minden suttogásnál + Váltás a fülre minden tellnél Egyébként a fül az első után a háttérben nyílik meg. @@ -1331,7 +1325,7 @@ Címsor megjelenítése - Címsor megjelenítése a kiemelt ablakoknál + Címsor megjelenítése a kiugró ablakoknál Átlátszatlanság inaktívan diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index f9b843c..6536d2e 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1248,7 +1248,7 @@ Ridimensionamento - Apertura automatica dei sussurri + Apertura automatica dei tell Barra laterale @@ -1283,12 +1283,6 @@ Il cambio ricostruisce l'atlante dei caratteri, quindi la chat resta vuota per un attimo. - - Densità compatta - - - Sostituisce il layout a schede con una riga per messaggio. - Orologio 24 ore @@ -1305,13 +1299,13 @@ Su quale lato compare l'elenco dei suggerimenti mentre scrivi. - Modalità di apertura automatica + Modalità di apertura automatica dei tell - Dove si apre un sussurro quando arriva. + Dove si apre un tell quando arriva. - Passa alla scheda a ogni sussurro + Passa alla scheda a ogni tell Altrimenti la scheda si apre in secondo piano dopo il primo. @@ -1335,7 +1329,7 @@ Mostra la barra del titolo per le finestre staccate - Opacità da inattiva + Opacità finestra inattiva Si applica mentre un'altra finestra ha il focus. diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index d95574f..eeaa7a7 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1248,7 +1248,7 @@ サイズ変更の動作 - テルの自動オープン + Tellの自動オープン サイドバー @@ -1283,12 +1283,6 @@ 切り替えるとフォントアトラスが再構築されるため、チャットが一瞬空になります。 - - コンパクト表示 - - - カード表示を 1 メッセージ 1 行に切り替えます。 - 24時間表示 @@ -1305,13 +1299,13 @@ 入力中にコマンド候補をどちら側に表示するか。 - 自動オープンの方式 + Tell自動オープンの方式 - テルが届いたときにどこで開くか。 + Tellが届いたときにどこで開くか。 - テルのたびにタブへ切り替える + Tellのたびにタブへ切り替える そうでない場合、最初の 1 通のあとタブは背面で開きます。 diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index e69da4f..85b06a8 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1248,7 +1248,7 @@ 크기 조절 동작 - 귓속말 자동 열기 + 텔 자동 열기 사이드바 @@ -1283,12 +1283,6 @@ 전환하면 폰트 아틀라스를 다시 만들기 때문에 채팅이 잠시 비워집니다. - - 좁게 표시 - - - 카드 배치를 메시지당 한 줄로 바꿉니다. - 24시간제 @@ -1305,13 +1299,13 @@ 입력 중에 명령어 힌트 목록을 어느 쪽에 표시할지. - 자동 열기 방식 + 텔 자동 열기 방식 - 귓속말이 도착했을 때 어디에서 열릴지. + 텔이 도착했을 때 어디에서 열릴지. - 귓속말마다 탭으로 전환 + 텔마다 탭으로 전환 그렇지 않으면 첫 통 이후 탭이 배경에서 열립니다. diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index 80aee5f..cf08cd1 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1247,7 +1247,7 @@ Størrelsesendring - Automatisk åpning av tells + Automatisk åpning av tell Sidepanel @@ -1282,12 +1282,6 @@ Bytte bygger fontatlaset på nytt, så chatten er tom et øyeblikk. - - Kompakt tetthet - - - Bytter kortoppsettet med én linje per melding. - 24-timers klokke @@ -1304,7 +1298,7 @@ Hvilken side listen med kommandotips vises på mens du skriver. - Modus for automatisk åpning + Modus for automatisk åpning av tell Hvor en tell åpnes når den kommer. diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 7f99de1..53925b7 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1283,12 +1283,6 @@ Wisselen bouwt de lettertype-atlas opnieuw op, dus de chat is even leeg. - - Compacte dichtheid - - - Vervangt de kaartweergave door één regel per bericht. - 24-uursklok @@ -1305,7 +1299,7 @@ Aan welke kant de lijst met commandotips verschijnt tijdens het typen. - Modus voor automatisch openen + Modus voor automatisch openen van tells Waar een tell opent wanneer die binnenkomt. diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index ae1f725..1d05f0d 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1247,7 +1247,7 @@ Zmiana rozmiaru - Automatyczne otwieranie szeptów + Automatyczne otwieranie tell Panel boczny @@ -1282,12 +1282,6 @@ Zmiana przebudowuje atlas czcionek, więc czat na chwilę pustoszeje. - - Kompaktowa gęstość - - - Zamienia układ kart na jedną linię na wiadomość. - Zegar 24-godzinny @@ -1304,13 +1298,13 @@ Po której stronie pojawia się lista podpowiedzi podczas pisania. - Tryb automatycznego otwierania + Tryb automatycznego otwierania tell - Gdzie otwiera się szept, gdy nadejdzie. + Gdzie otwiera się tell, gdy nadejdzie. - Przełączaj na kartę przy każdym szepcie + Przełączaj na kartę przy każdym tell W przeciwnym razie karta po pierwszym otwiera się w tle. @@ -1334,7 +1328,7 @@ Pokaż pasek tytułu w oknach odłączonych - Nieprzezroczystość gdy nieaktywne + Nieprzezroczystość, gdy nieaktywne Obowiązuje, gdy inne okno ma fokus. diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index 3c3c5e6..7819581 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1248,7 +1248,7 @@ Comportamento de redimensionamento - Abertura automática de sussurros + Abertura automática de tell Barra lateral @@ -1283,12 +1283,6 @@ Trocar reconstrói o atlas de fontes, então o chat fica vazio por um instante. - - Densidade compacta - - - Troca o layout de cartões por uma linha por mensagem. - Relógio de 24 horas @@ -1305,13 +1299,13 @@ De que lado a lista de sugestões aparece enquanto você digita. - Modo de abertura automática + Modo de abertura automática de tell - Onde um sussurro abre quando chega. + Onde um tell abre quando chega. - Mudar para a aba a cada sussurro + Mudar para a aba a cada tell Caso contrário, a aba abre em segundo plano após o primeiro. diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 9e3512a..28dc7bb 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1247,7 +1247,7 @@ Comportamento de redimensionamento - Abertura automática de sussurros + Abertura automática de tell Barra lateral @@ -1282,12 +1282,6 @@ Mudar reconstrói o atlas de tipos de letra, por isso o chat fica vazio por um instante. - - Densidade compacta - - - Troca a disposição em cartões por uma linha por mensagem. - Relógio de 24 horas @@ -1304,13 +1298,13 @@ De que lado a lista de sugestões aparece enquanto escreve. - Modo de abertura automática + Modo de abertura automática de tell - Onde um sussurro abre quando chega. + Onde um tell abre quando chega. - Mudar para o separador a cada sussurro + Mudar para o separador a cada tell Caso contrário, o separador abre em segundo plano após o primeiro. diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index 481500c..874af81 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1294,12 +1294,6 @@ Switching rebuilds the font atlas, so the chat goes blank for a moment. - - Compact density - - - Trades the card layout for one line per message. - 24-hour clock diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index 2674711..32fdd50 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1248,7 +1248,7 @@ Comportament la redimensionare - Deschidere automată a mesajelor private + Deschidere automată a tell Bară laterală @@ -1283,12 +1283,6 @@ Schimbarea reconstruiește atlasul de fonturi, deci chatul rămâne gol o clipă. - - Densitate compactă - - - Înlocuiește aranjarea în carduri cu o linie per mesaj. - Ceas de 24 de ore @@ -1305,13 +1299,13 @@ În ce parte apare lista de sugestii în timp ce scrii. - Mod de deschidere automată + Mod de deschidere automată a tell - Unde se deschide un mesaj privat când sosește. + Unde se deschide un tell când sosește. - Comută pe filă la fiecare mesaj privat + Comută pe filă la fiecare tell Altfel fila se deschide în fundal după primul. diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index f3ad66c..1194d94 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1248,7 +1248,7 @@ Изменение размера - Автооткрытие шёпота + Автооткрытие tell Боковая панель @@ -1283,12 +1283,6 @@ Переключение перестраивает атлас шрифтов, поэтому чат ненадолго пустеет. - - Компактная плотность - - - Меняет карточную раскладку на одну строку на сообщение. - 24-часовой формат @@ -1305,13 +1299,13 @@ С какой стороны появляется список подсказок при вводе. - Режим автооткрытия + Режим автооткрытия tell - Где открывается шёпот при получении. + Где открывается tell при получении. - Переключаться на вкладку при каждом шёпоте + Переключаться на вкладку при каждом tell Иначе вкладка после первого открывается в фоне. diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index 68f0bb8..ca3966a 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1248,7 +1248,7 @@ Storleksändring - Automatisk öppning av viskningar + Automatisk öppning av tell Sidofält @@ -1283,12 +1283,6 @@ Byte bygger om typsnittsatlasen, så chatten är tom ett ögonblick. - - Kompakt täthet - - - Byter kortlayouten mot en rad per meddelande. - 24-timmarsklocka @@ -1305,13 +1299,13 @@ Vilken sida listan med kommandotips visas på medan du skriver. - Läge för automatisk öppning + Läge för automatisk öppning av tell - Var en viskning öppnas när den kommer. + Var en tell öppnas när den kommer. - Byt till fliken vid varje viskning + Byt till fliken vid varje tell Annars öppnas fliken i bakgrunden efter den första. diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index aca3983..e0a78e9 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1241,13 +1241,13 @@ Yerleşim modu - Saydamlık + Opaklık Boyutlandırma davranışı - Fısıltıları otomatik açma + Tell'leri otomatik açma Kenar çubuğu @@ -1282,12 +1282,6 @@ Değiştirmek yazı tipi atlasını yeniden oluşturur, bu yüzden sohbet bir an boşalır. - - Sıkışık yoğunluk - - - Kart düzenini ileti başına tek satırla değiştirir. - 24 saat biçimi @@ -1304,13 +1298,13 @@ Yazarken komut ipucu listesinin hangi tarafta görüneceği. - Otomatik açma modu + Tell otomatik açma modu - Bir fısıltı geldiğinde nerede açılacağı. + Bir tell geldiğinde nerede açılacağı. - Her fısıltıda sekmeye geç + Her tell'de sekmeye geç Aksi hâlde sekme ilkinden sonra arka planda açılır. @@ -1334,7 +1328,7 @@ Ayrık pencereler için başlık çubuğunu göster - Etkin değilken saydamlık + Etkin değilken opaklık Başka bir pencere odaktayken geçerlidir. diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index b92bf4b..b2ff516 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1247,7 +1247,7 @@ Зміна розміру - Автовідкриття шепоту + Автовідкриття tell Бічна панель @@ -1282,12 +1282,6 @@ Перемикання перебудовує атлас шрифтів, тому чат на мить порожніє. - - Компактна щільність - - - Замінює карткове компонування одним рядком на повідомлення. - 24-годинний формат @@ -1304,13 +1298,13 @@ З якого боку з'являється список підказок під час введення. - Режим автовідкриття + Режим автовідкриття tell - Де відкривається шепіт, коли надходить. + Де відкривається tell, коли надходить. - Перемикатися на вкладку за кожного шепоту + Перемикатися на вкладку за кожного tell Інакше вкладка після першого відкривається у фоні. diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index 895e2da..768f6a4 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1248,7 +1248,7 @@ 调整大小行为 - 自动打开密语 + 自动打开Tell 侧边栏 @@ -1283,12 +1283,6 @@ 切换会重建字体图集,因此聊天会短暂空白。 - - 紧凑密度 - - - 将卡片布局换成每条消息一行。 - 24 小时制 @@ -1305,13 +1299,13 @@ 输入时命令提示列表出现在哪一侧。 - 自动打开方式 + Tell自动打开方式 - 密语到达时在何处打开。 + Tell到达时在何处打开。 - 每条密语都切换到标签 + 每条Tell都切换到标签 否则标签会在第一条之后于后台打开。 diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index 1409b26..25d888c 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1248,7 +1248,7 @@ 調整大小行為 - 自動開啟密語 + 自動開啟Tell 側邊欄 @@ -1283,12 +1283,6 @@ 切換會重建字型圖集,因此聊天會短暫空白。 - - 緊湊密度 - - - 將卡片版面換成每則訊息一行。 - 24 小時制 @@ -1305,13 +1299,13 @@ 輸入時指令提示清單出現在哪一側。 - 自動開啟方式 + Tell自動開啟方式 - 密語到達時在何處開啟。 + Tell到達時在何處開啟。 - 每則密語都切換到分頁 + 每則Tell都切換到分頁 否則分頁會在第一則之後於背景開啟。 diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs index a259b47..46a47fc 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs @@ -27,8 +27,8 @@ internal sealed class ChatTab { _w.ToggleRow( ImGui.GetID("chat.display.density"u8), - HellionStrings.Settings_Chat_CompactDensity_Name, - HellionStrings.Settings_Chat_CompactDensity_Description, + HellionStrings.Appearance_UseCompactDensity_Name, + HellionStrings.Appearance_UseCompactDensity_Description, () => Plugin.Config.UseCompactDensity, v => Plugin.Config.UseCompactDensity = v ); From 5991f49c592a198923149fecb89f0a998b82eeb3 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 00:28:24 +0200 Subject: [PATCH 122/129] i18n: hold the new strings against the glossary the plugin already had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A language review over all 24 files found that the translation batch had been written as if the file were empty. It is not: eleven years of FFXIV vocabulary and several hundred existing values already decided how this plugin says things, and the new strings disagreed with them. Three said something wrong rather than something unusual: - Norwegian called opacity "tetthet", which is density -- and the same file uses that exact word for compact density, so one word stood for two different settings. - Danish called the brand section "Brand", which in Danish is fire. - Turkish called a manifest a notification, in a window that already has a notifications section. Then the glossary itself. "tab" is zakładka in Polish, tab in Romanian and Danish; "plugin" stays plugin in five languages that had translated it; "pop-out" is a loanword in six. The Shift key keeps its name in Swedish, Finnish and Italian, because that is what is printed on it. Fixing pop-out also shortened the three longest row labels in the batch past the point where SettingRow would have clipped them. Czech was the clearest outright error: the existing file addresses the reader informally throughout, and five new strings switched to the formal form. And a gap in my own previous fix: I had moved four of the five tell strings to the established loanword and missed the fifth pair, so the failed-tell warning still said "Flüstern" while its neighbours said "Tell". 435 keys, 25 files, no gaps, no orphans, no placeholder drift, and every one of the five tell keys now carries the same word in every language. Whether German and French should say Flüstern and message privé rather than tell is a real question -- both game clients do -- but that is a glossary decision for the whole file, not something to introduce through four new keys. --- HellionChat/Resources/HellionStrings.ca.resx | 6 ++--- HellionChat/Resources/HellionStrings.cs.resx | 16 ++++++------- HellionChat/Resources/HellionStrings.da.resx | 18 +++++++------- HellionChat/Resources/HellionStrings.de.resx | 8 +++---- HellionChat/Resources/HellionStrings.el.resx | 4 ++-- HellionChat/Resources/HellionStrings.es.resx | 10 ++++---- HellionChat/Resources/HellionStrings.fi.resx | 6 ++--- HellionChat/Resources/HellionStrings.fr.resx | 4 ++-- HellionChat/Resources/HellionStrings.hu.resx | 6 ++--- HellionChat/Resources/HellionStrings.it.resx | 10 ++++---- HellionChat/Resources/HellionStrings.ja.resx | 4 ++-- HellionChat/Resources/HellionStrings.ko.resx | 4 ++-- HellionChat/Resources/HellionStrings.nb.resx | 8 +++---- HellionChat/Resources/HellionStrings.nl.resx | 4 ++-- HellionChat/Resources/HellionStrings.pl.resx | 24 +++++++++---------- .../Resources/HellionStrings.pt-BR.resx | 4 ++-- .../Resources/HellionStrings.pt-PT.resx | 4 ++-- HellionChat/Resources/HellionStrings.ro.resx | 24 +++++++++---------- HellionChat/Resources/HellionStrings.ru.resx | 4 ++-- HellionChat/Resources/HellionStrings.sv.resx | 12 +++++----- HellionChat/Resources/HellionStrings.tr.resx | 12 +++++----- HellionChat/Resources/HellionStrings.uk.resx | 6 ++--- .../Resources/HellionStrings.zh-Hans.resx | 4 ++-- .../Resources/HellionStrings.zh-Hant.resx | 4 ++-- 24 files changed, 103 insertions(+), 103 deletions(-) diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index 3435bf6..a4a3cd2 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1200,10 +1200,10 @@ Bloqueja - Avisa quan un xiuxiueig no arriba + Avisa quan un tell no arriba - El joc només informa d'un xiuxiueig fallit al registre. Això mostra una notificació, perquè un missatge a algú desconnectat o d'un altre món no passi desapercebut. + El joc només informa d'un tell fallit al registre. Això mostra una notificació, perquè un missatge a algú desconnectat o en un altre món no passi desapercebut. Vés al missatge més recent @@ -1326,7 +1326,7 @@ Mostra la barra de títol - Mostra la barra de títol a les finestres desacoblades + Barra de títol als pop-outs Opacitat inactiva diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index 2ecfee8..dff3b8d 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1172,13 +1172,13 @@ mazání historie - Filtr soukromí je vypnutý, takže se ukládá každý kanál a nic v databázi neodporuje vašemu nastavení. Nejprve filtr zapněte a vyberte kanály. + Filtr soukromí je vypnutý, takže se ukládá každý kanál a nic v databázi neodporuje tvému nastavení. Nejdřív filtr zapni a vyber kanály. - Není vybrán žádný kanál, takže úklid by smazal celou historii. Vyberte kanály, které chcete zachovat, nebo použijte tlačítko pro vymazání, pokud opravdu chcete smazat vše. + Není vybraný žádný kanál, takže úklid by smazal celou historii. Vyber kanály, které chceš zachovat, nebo použij tlačítko pro vymazání, pokud opravdu chceš smazat vše. - Uloženo je {0:N0} zpráv. Pokud si chcete ponechat kopii, před vymazáním je exportujte. + Uloženo je {0:N0} zpráv. Pokud si chceš ponechat kopii, před vymazáním je exportuj. Ctrl+Shift: spustí úklid podle doby uchování hned, místo čekání na denní běh. Smaže zprávy starší než limity výše. @@ -1190,7 +1190,7 @@ Telemetrie - Nesbírá se žádná telemetrie. Plugin nikam neposílá nic o vás ani o vašem používání. + Nesbírá se žádná telemetrie. Plugin nikam neposílá nic o tobě ani o tvém používání. Automatický překlad @@ -1199,10 +1199,10 @@ Zablokovat - Upozornit, když šeptání nedorazí + Upozornit, když tell nedorazí - Hra hlásí neúspěšné šeptání jen v logu. Toto místo toho zobrazí upozornění, aby zpráva někomu offline nebo na jiném světě nezůstala bez povšimnutí. + Hra hlásí neúspěšný tell jen v logu. Toto místo toho zobrazí upozornění, aby zpráva někomu offline nebo na jiném světě nezůstala bez povšimnutí. Přejít na nejnovější zprávu @@ -1271,7 +1271,7 @@ Vypne prolínání motivů, přechody při najetí myší a poletující částice. - Klikněte na tlačítko a poté stiskněte kombinaci kláves. Esc smaže. + Klikni na tlačítko a poté stiskni kombinaci kláves. Esc smaže. Přepnout na další záložku @@ -1325,7 +1325,7 @@ Zobrazit záhlaví - Zobrazit záhlaví u vysunutých oken + Záhlaví u pop-out oken Neprůhlednost při neaktivitě diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index 94c9aa4..43bdec9 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1253,7 +1253,7 @@ Sidepanel - Brand + Varemærke Links @@ -1274,10 +1274,10 @@ Klik på en knap, og tryk derefter tastekombinationen. Esc rydder. - Skift til næste faneblad + Skift til næste tab - Skift til forrige faneblad + Skift til forrige tab Skift genopbygger fontatlasset, så chatten er tom et øjeblik. @@ -1304,22 +1304,22 @@ Hvor en tell åbnes, når den ankommer. - Skift til fanen ved hver tell + Skift til tab ved hver tell - Ellers åbnes fanebladet i baggrunden efter den første. + Ellers åbnes tab'en i baggrunden efter den første. Sidepanel - Faneblade øverst + Tabs øverst - Placering af faneblade + Placering af tabs - Hvor fanelisten sidder i hovedvinduet. + Hvor tab-listen sidder i hovedvinduet. Vis titellinje @@ -1343,7 +1343,7 @@ Grænse for automatisk skift af sidepanel - Under denne bredde folder sidepanelet sig til faneblade øverst, i pixels. + Under denne bredde folder sidepanelet sig til tabs øverst, i pixels. Placering af forhåndsvisning diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index e988b28..e291b28 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1194,10 +1194,10 @@ Sperren - Warnen, wenn ein Flüstern nicht ankommt + Warnen, wenn ein Tell nicht ankommt - Das Spiel meldet ein fehlgeschlagenes Flüstern nur im Protokoll. Das hier zeigt stattdessen eine Benachrichtigung, damit eine Nachricht an jemanden offline oder auf einer anderen Welt nicht untergeht. + Das Spiel meldet einen fehlgeschlagenen Tell nur im Protokoll. Das hier zeigt stattdessen eine Benachrichtigung, damit eine Nachricht an jemanden offline oder auf einer anderen Welt nicht untergeht. Zur neuesten Nachricht springen @@ -1263,7 +1263,7 @@ Lizenz - Schaltet Theme-Überblendungen, Hover-Verläufe und die schwebenden Partikel ab. + Schaltet Theme-Überblendungen, Hover-Überblendungen und die schwebenden Partikel ab. Auf einen Knopf klicken, dann die Tastenkombination drücken. Esc löscht. @@ -1320,7 +1320,7 @@ Titelleiste anzeigen - Titelleiste für Pop-out-Fenster anzeigen + Titelleiste für Pop-Outs Deckkraft im Hintergrund diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index e7b9d30..1e54808 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1337,7 +1337,7 @@ Να επιτρέπεται η μετακίνηση - Να επιτρέπεται η αλλαγή μεγέθους + Αλλαγή μεγέθους επιτρέπεται Όριο αυτόματης εναλλαγής πλαϊνής μπάρας @@ -1349,7 +1349,7 @@ Θέση προεπισκόπησης - Εμφάνιση προεπισκόπησης μόνο κατά την πληκτρολόγηση + Προεπισκόπηση μόνο κατά την πληκτρολόγηση Αποθετήριο Gitea diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index a067c51..76bf2b5 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1191,7 +1191,7 @@ Telemetría - No se recopila telemetría. El complemento no envía nada sobre ti ni sobre tu uso a ninguna parte. + No se recopila telemetría. El plugin no envía nada sobre ti ni sobre tu uso a ninguna parte. Traducción automática @@ -1200,10 +1200,10 @@ Bloquear - Avisar cuando un susurro no llega + Avisar cuando un tell no llega - El juego solo informa de un susurro fallido en el registro. Esto muestra una notificación, para que un mensaje a alguien desconectado o de otro mundo no pase desapercibido. + El juego solo informa de un tell fallido en el registro. Esto muestra una notificación, para que un mensaje a alguien desconectado o en otro mundo no pase desapercibido. Ir al mensaje más reciente @@ -1236,7 +1236,7 @@ Ayuda de comandos - Aviso de complemento + Aviso del plugin Modo de disposición @@ -1326,7 +1326,7 @@ Mostrar la barra de título - Mostrar la barra de título en las ventanas desacopladas + Barra de título en pop-outs Opacidad inactiva diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index d5dd40b..a837fef 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1181,7 +1181,7 @@ Tallennettuna on {0:N0} viestiä. Jos haluat säilyttää kopion, vie ne ennen tyhjennystä. - Ctrl+Vaihto: suorittaa säilytysajon heti sen sijaan, että odottaisi päivittäistä ajoa. Poistaa yllä olevia rajoja vanhemmat viestit. + Ctrl+Shift: suorittaa säilytysajon heti sen sijaan, että odottaisi päivittäistä ajoa. Poistaa yllä olevia rajoja vanhemmat viestit. Historian tyhjennys epäonnistui. Mitään ei poistettu, katso /xllog. @@ -1199,10 +1199,10 @@ Estä - Varoita, kun kuiskaus ei mene perille + Varoita, kun tell ei mene perille - Peli ilmoittaa epäonnistuneesta kuiskauksesta vain lokissa. Tämä näyttää sen sijaan ilmoituksen, jottei viesti offline-tilassa tai toisessa maailmassa olevalle jää huomaamatta. + Peli ilmoittaa epäonnistuneesta tellistä vain lokissa. Tämä näyttää sen sijaan ilmoituksen, jottei viesti offline-tilassa tai toisessa maailmassa olevalle jää huomaamatta. Siirry uusimpaan viestiin diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index 0ee9d7f..f149332 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1200,10 +1200,10 @@ Bloquer - Avertir quand un message privé n'arrive pas + Avertir quand un tell n'arrive pas - Le jeu ne signale un message privé échoué que dans le journal. Ceci affiche une notification à la place, pour qu'un message à quelqu'un hors ligne ou sur un autre monde ne passe pas inaperçu. + Le jeu ne signale un tell échoué que dans le journal. Ceci affiche une notification à la place, pour qu'un message à quelqu'un hors ligne ou sur un autre monde ne passe pas inaperçu. Aller au message le plus récent diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index 548753d..7cff664 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1199,10 +1199,10 @@ Letiltás - Figyelmeztetés, ha egy suttogás nem érkezik meg + Figyelmeztetés, ha egy tell nem érkezik meg - A játék csak a naplóban jelzi a sikertelen suttogást. Ez helyette értesítést jelenít meg, hogy egy offline vagy másik világon lévő játékosnak küldött üzenet ne maradjon észrevétlen. + A játék csak a naplóban jelzi a sikertelen tellt. Ez helyette értesítést jelenít meg, hogy egy offline vagy másik világon lévő játékosnak küldött üzenet ne maradjon észrevétlen. Ugrás a legutóbbi üzenetre @@ -1325,7 +1325,7 @@ Címsor megjelenítése - Címsor megjelenítése a kiugró ablakoknál + Címsor a pop-out ablakoknál Átlátszatlanság inaktívan diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index 6536d2e..da962c6 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1182,7 +1182,7 @@ Ci sono {0:N0} messaggi salvati. Se vuoi conservarne una copia, esportali prima di cancellare. - Ctrl+Maiusc: esegue subito la pulizia di conservazione invece di aspettare il passaggio giornaliero. Cancella i messaggi più vecchi dei limiti sopra. + Ctrl+Shift: esegue subito la pulizia di conservazione invece di aspettare il passaggio giornaliero. Cancella i messaggi più vecchi dei limiti sopra. La cancellazione della cronologia non è riuscita. Non è stato rimosso nulla, vedi /xllog. @@ -1200,10 +1200,10 @@ Blocca - Avvisa quando un sussurro non arriva + Avvisa quando un tell non arriva - Il gioco segnala un sussurro fallito solo nel registro. Questo mostra invece una notifica, così un messaggio a qualcuno offline o su un altro mondo non passa inosservato. + Il gioco segnala un tell fallito solo nel registro. Questo mostra invece una notifica, così un messaggio a qualcuno offline o in un altro mondo non passa inosservato. Vai al messaggio più recente @@ -1326,7 +1326,7 @@ Mostra la barra del titolo - Mostra la barra del titolo per le finestre staccate + Barra del titolo nei pop-out Opacità finestra inattiva @@ -1341,7 +1341,7 @@ Consenti il ridimensionamento - Soglia di passaggio automatico della barra laterale + Soglia di passaggio barra laterale Sotto questa larghezza la barra laterale si ripiega in schede in alto, in pixel. diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index eeaa7a7..31c8ef2 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1200,10 +1200,10 @@ ブロック - テルが届かなかったときに警告する + Tellが届かなかったときに警告する - ゲームはテルの失敗をログにしか表示しません。この設定では代わりに通知を出すので、オフラインや別ワールドの相手へのメッセージを見落としません。 + ゲームはTellの失敗をログにしか表示しません。この設定では代わりに通知を出すので、オフラインや別ワールドの相手へのメッセージを見落としません。 最新のメッセージへ移動 diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 85b06a8..a521299 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1200,10 +1200,10 @@ 차단 - 귓속말이 전달되지 않으면 알림 + 텔이 전달되지 않으면 알림 - 게임은 귓속말 실패를 로그에만 표시합니다. 이 설정은 대신 알림을 띄워, 오프라인이거나 다른 월드에 있는 상대에게 보낸 메시지를 놓치지 않게 합니다. + 게임은 텔 실패를 로그에만 표시합니다. 이 설정은 대신 알림을 띄워, 오프라인이거나 다른 월드에 있는 상대에게 보낸 메시지를 놓치지 않게 합니다. 최신 메시지로 이동 diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index cf08cd1..c254a8e 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1190,7 +1190,7 @@ Telemetri - Ingen telemetri samles inn. Programtillegget sender ingenting om deg eller bruken din noe sted. + Ingen telemetri samles inn. Dette pluginet sender ingenting om deg eller bruken din noe sted. Auto-oversettelse @@ -1241,7 +1241,7 @@ Oppsettsmodus - Tetthet + Dekkevne Størrelsesendring @@ -1325,10 +1325,10 @@ Vis tittellinje - Vis tittellinje for utbrutte vinduer + Tittellinje for pop-out-vinduer - Tetthet når inaktiv + Dekkevne når inaktiv Gjelder mens et annet vindu har fokus. diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 53925b7..e4bd097 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1326,7 +1326,7 @@ Titelbalk tonen - Titelbalk tonen voor losgemaakte vensters + Titelbalk voor pop-outvensters Dekking bij inactiviteit @@ -1341,7 +1341,7 @@ Formaat wijzigen toestaan - Drempel voor automatisch omschakelen van de zijbalk + Omschakeldrempel zijbalk Onder deze breedte vouwt de zijbalk samen tot tabbladen bovenaan, in pixels. diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index 1d05f0d..1f25e83 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1190,7 +1190,7 @@ Telemetria - Nie zbieramy żadnej telemetrii. Wtyczka nigdzie nie wysyła informacji o tobie ani o twoim użytkowaniu. + Nie zbieramy żadnej telemetrii. Plugin nigdzie nie wysyła informacji o tobie ani o twoim użytkowaniu. Autotłumaczenie @@ -1199,10 +1199,10 @@ Zablokuj - Ostrzegaj, gdy szept nie dotrze + Ostrzegaj, gdy tell nie dotrze - Gra zgłasza nieudany szept tylko w dzienniku. To zamiast tego wyświetla powiadomienie, żeby wiadomość do kogoś offline lub z innego świata nie umknęła. + Gra zgłasza nieudany tell tylko w dzienniku. To zamiast tego wyświetla powiadomienie, żeby wiadomość do kogoś offline lub na innym świecie nie umknęła. Przejdź do najnowszej wiadomości @@ -1235,7 +1235,7 @@ Pomoc do poleceń - Ujawnienie wtyczki + Informacja o pluginie Tryb układu @@ -1274,10 +1274,10 @@ Kliknij przycisk, a następnie naciśnij kombinację klawiszy. Esc czyści. - Przejdź do następnej karty + Przejdź do następnej zakładki - Przejdź do poprzedniej karty + Przejdź do poprzedniej zakładki Zmiana przebudowuje atlas czcionek, więc czat na chwilę pustoszeje. @@ -1304,22 +1304,22 @@ Gdzie otwiera się tell, gdy nadejdzie. - Przełączaj na kartę przy każdym tell + Przełączaj na zakładkę przy każdym tell - W przeciwnym razie karta po pierwszym otwiera się w tle. + W przeciwnym razie zakładka po pierwszym otwiera się w tle. Panel boczny - Karty u góry + Zakładki u góry - Rozmieszczenie kart + Rozmieszczenie zakładek - Gdzie w oknie głównym znajduje się lista kart. + Gdzie w oknie głównym znajduje się lista zakładek. Pokaż pasek tytułu @@ -1343,7 +1343,7 @@ Próg automatycznego przełączania panelu bocznego - Poniżej tej szerokości panel boczny zwija się w karty u góry, w pikselach. + Poniżej tej szerokości panel boczny zwija się w zakładki u góry, w pikselach. Pozycja podglądu diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index 7819581..a34a18a 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1200,10 +1200,10 @@ Bloquear - Avisar quando um sussurro não chega + Avisar quando um tell não chega - O jogo relata um sussurro que falhou apenas no registro. Isto mostra uma notificação, para que uma mensagem a alguém offline ou de outro mundo não passe despercebida. + O jogo relata um tell que falhou apenas no registro. Isto mostra uma notificação, para que uma mensagem a alguém offline ou em outro mundo não passe despercebida. Ir para a mensagem mais recente diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 28dc7bb..70c0010 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1199,10 +1199,10 @@ Bloquear - Avisar quando um sussurro não chega + Avisar quando um tell não chega - O jogo comunica um sussurro falhado apenas no registo. Isto mostra uma notificação, para que uma mensagem a alguém offline ou de outro mundo não passe despercebida. + O jogo comunica um tell falhado apenas no registo. Isto mostra uma notificação, para que uma mensagem a alguém offline ou noutro mundo não passe despercebida. Ir para a mensagem mais recente diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index 32fdd50..6d3b3bf 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1191,7 +1191,7 @@ Telemetrie - Nu se colectează telemetrie. Extensia nu trimite nimic despre tine sau despre utilizarea ta nicăieri. + Nu se colectează telemetrie. Pluginul nu trimite nimic despre tine sau despre utilizarea ta nicăieri. Traducere automată @@ -1200,10 +1200,10 @@ Blochează - Avertizează când un mesaj privat nu ajunge + Avertizează când un tell nu ajunge - Jocul raportează un mesaj privat eșuat doar în jurnal. Aceasta afișează în schimb o notificare, ca un mesaj către cineva offline sau de pe altă lume să nu treacă neobservat. + Jocul raportează un tell eșuat doar în jurnal. Aceasta afișează în schimb o notificare, ca un mesaj către cineva offline sau aflat pe altă lume să nu treacă neobservat. Salt la cel mai recent mesaj @@ -1236,7 +1236,7 @@ Ajutor pentru comenzi - Notificare extensie + Notificare privind pluginul Mod de aranjare @@ -1275,10 +1275,10 @@ Fă clic pe un buton, apoi apasă combinația de taste. Esc șterge. - Treci la fila următoare + Treci la tab-ul următor - Treci la fila anterioară + Treci la tab-ul anterior Schimbarea reconstruiește atlasul de fonturi, deci chatul rămâne gol o clipă. @@ -1305,22 +1305,22 @@ Unde se deschide un tell când sosește. - Comută pe filă la fiecare tell + Comută pe tab la fiecare tell - Altfel fila se deschide în fundal după primul. + Altfel tab-ul se deschide în fundal după primul. Bară laterală - File sus + Tab-uri sus - Poziția filelor + Poziția tab-urilor - Unde stă lista de file în fereastra principală. + Unde stă lista de tab-uri în fereastra principală. Afișează bara de titlu @@ -1344,7 +1344,7 @@ Pragul de comutare automată a barei laterale - Sub această lățime bara laterală se pliază în file sus, în pixeli. + Sub această lățime bara laterală se pliază în tab-uri sus, în pixeli. Poziția previzualizării diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index 1194d94..3e7a071 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1200,10 +1200,10 @@ Заблокировать - Предупреждать, когда шёпот не доходит + Предупреждать, когда tell не доходит - Игра сообщает о недоставленном шёпоте только в журнале. Здесь вместо этого показывается уведомление, чтобы сообщение офлайн-игроку или игроку с другого мира не осталось незамеченным. + Игра сообщает о недоставленном tell только в журнале. Здесь вместо этого показывается уведомление, чтобы сообщение офлайн-игроку или игроку с другого мира не осталось незамеченным. Перейти к последнему сообщению diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index ca3966a..7010fd8 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1182,7 +1182,7 @@ {0:N0} meddelanden är sparade. Vill du behålla en kopia, exportera dem innan du raderar. - Ctrl+Skift: kör gallringen direkt i stället för att vänta på den dagliga körningen. Raderar meddelanden äldre än gränserna ovan. + Ctrl+Shift: kör gallringen direkt i stället för att vänta på den dagliga körningen. Raderar meddelanden äldre än gränserna ovan. Raderingen av historiken misslyckades. Ingenting togs bort, se /xllog. @@ -1191,7 +1191,7 @@ Telemetri - Ingen telemetri samlas in. Tillägget skickar ingenting om dig eller din användning någonstans. + Ingen telemetri samlas in. Detta plugin skickar ingenting om dig eller din användning någonstans. Autoöversättning @@ -1200,10 +1200,10 @@ Blockera - Varna när ett viskmeddelande inte kommer fram + Varna när en tell inte kommer fram - Spelet rapporterar ett misslyckat viskmeddelande bara i loggen. Det här visar en avisering i stället, så att ett meddelande till någon offline eller på en annan värld inte missas. + Spelet rapporterar en misslyckad tell bara i loggen. Det här visar en avisering i stället, så att ett meddelande till någon offline eller på en annan värld inte missas. Hoppa till det senaste meddelandet @@ -1236,7 +1236,7 @@ Kommandohjälp - Tilläggsupplysning + Plugin-information Layoutläge @@ -1326,7 +1326,7 @@ Visa namnlist - Visa namnlist för utbrutna fönster + Namnlist för pop-out-fönster Opacitet vid inaktivitet diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index e0a78e9..ce3c23b 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1181,7 +1181,7 @@ {0:N0} ileti saklanıyor. Bir kopya tutmak istiyorsanız, silmeden önce dışa aktarın. - Ctrl+Shift: günlük taramayı beklemeden saklama temizliğini hemen çalıştırır. Yukarıdaki sınırlardan eski iletileri siler. + Ctrl+Shift: günlük taramayı beklemeden saklama temizliğini hemen çalıştırır. Yukarıdaki sınırlardan daha eski iletileri siler. Geçmiş silinemedi. Hiçbir şey kaldırılmadı, /xllog kaydına bakın. @@ -1199,10 +1199,10 @@ Engelle - Bir fısıltı ulaşmadığında uyar + Bir tell ulaşmadığında uyar - Oyun başarısız fısıltıyı yalnızca günlükte bildirir. Bu ayar bunun yerine bir bildirim gösterir, böylece çevrimdışı ya da başka dünyadaki birine gönderilen ileti gözden kaçmaz. + Oyun başarısız bir tell'i yalnızca kayıtta bildirir. Bu ayar bunun yerine bir bildirim gösterir, böylece çevrimdışı ya da başka bir dünyadaki birine gönderilen ileti gözden kaçmaz. En son iletiye git @@ -1289,7 +1289,7 @@ Önceki oturumların geçmişini göster - Kapalıyken günlük her oyun açılışında boş başlar ve yalnızca o andan sonra gelen iletilerle dolar. + Kapalıyken sohbet kaydı her oyun açılışında boş başlar ve yalnızca o andan sonra gelen iletilerle dolar. Komut yardımı tarafı @@ -1337,7 +1337,7 @@ Taşımaya izin ver - Yeniden boyutlandırmaya izin ver + Boyutlandırmaya izin ver Kenar çubuğu otomatik geçiş eşiği @@ -1355,6 +1355,6 @@ Gitea deposu - Özel depo bildirimi + Özel depo manifesti \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index b2ff516..491bc74 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1199,10 +1199,10 @@ Заблокувати - Попереджати, коли шепіт не доходить + Попереджати, коли tell не доходить - Гра повідомляє про недоставлений шепіт лише в журналі. Це натомість показує сповіщення, щоб повідомлення офлайн-гравцю або гравцю з іншого світу не залишилося непоміченим. + Гра повідомляє про недоставлений tell лише в журналі. Це натомість показує сповіщення, щоб повідомлення офлайн-гравцю або гравцю з іншого світу не залишилося непоміченим. Перейти до останнього повідомлення @@ -1349,7 +1349,7 @@ Розташування попереднього перегляду - Показувати попередній перегляд лише під час введення + Перегляд лише під час введення Репозиторій Gitea diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index 768f6a4..268bb73 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1200,10 +1200,10 @@ 屏蔽 - 密语未送达时提醒 + Tell未送达时提醒 - 游戏只在日志里提示密语发送失败。此设置改为弹出通知,让发给离线或异世界玩家的消息不会被忽略。 + 游戏只在日志里提示Tell发送失败。此设置改为弹出通知,让发给离线或其他世界玩家的消息不会被忽略。 跳到最新消息 diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index 25d888c..32ae002 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1200,10 +1200,10 @@ 封鎖 - 密語未送達時提醒 + Tell未送達時提醒 - 遊戲只在日誌裡提示密語傳送失敗。此設定改為彈出通知,讓發給離線或異世界玩家的訊息不會被忽略。 + 遊戲只在日誌裡提示Tell傳送失敗。此設定改為彈出通知,讓發給離線或其他世界玩家的訊息不會被忽略。 跳到最新訊息 From 3a1b863def5edbd1fd8cd4e2ba5d7bb85de89a80 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 05:11:05 +0200 Subject: [PATCH 123/129] i18n: adopt the client's word for tell, and translate the appearance tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two decisions, both yours. Where Square Enix ships a client in a language, that client's word wins. German says Flüstern, French message privé, Japanese テル, Korean 귓속말, Simplified Chinese 密语. Everywhere else there is no official client and the loanword is what players actually say, so it stays. That meant rewriting the whole corpus in those languages rather than just the new keys, which is the reason the split existed in the first place: forty German values and thirty-seven French ones carried the old word. /tell is untouched, because that is a command and not a noun. The mechanical pass left French with three agreement errors -- "des message privé", "ce onglet", "messages de message privé" -- which is what happens when you substring-replace a language with gender and number. Fixed by hand. Traditional Chinese keeps 悄悄話, which the file already used nineteen times; it has no official client of its own. The appearance tab is translated as well. Thirty keys: the colour editor's groups and buttons, the theme categories, fork and import and export, the font labels and the preview's input placeholder. Theme token names stay English -- WindowBg and TextPrimary are JSON keys, not prose -- and so do the brand strings. 465 keys, 25 files, no gaps, no orphans, no placeholder drift. Still English and not in this commit: the database viewer behind /hellionView, which is a documented user command rather than a developer tool, and the symbol picker. Both are their own block. --- .../Resources/HellionStrings.Designer.cs | 30 ++++ HellionChat/Resources/HellionStrings.ca.resx | 90 ++++++++++ HellionChat/Resources/HellionStrings.cs.resx | 90 ++++++++++ HellionChat/Resources/HellionStrings.da.resx | 90 ++++++++++ HellionChat/Resources/HellionStrings.de.resx | 156 ++++++++++++++---- HellionChat/Resources/HellionStrings.el.resx | 90 ++++++++++ HellionChat/Resources/HellionStrings.es.resx | 90 ++++++++++ HellionChat/Resources/HellionStrings.fi.resx | 90 ++++++++++ HellionChat/Resources/HellionStrings.fr.resx | 154 +++++++++++++---- HellionChat/Resources/HellionStrings.hu.resx | 90 ++++++++++ HellionChat/Resources/HellionStrings.it.resx | 90 ++++++++++ HellionChat/Resources/HellionStrings.ja.resx | 106 +++++++++++- HellionChat/Resources/HellionStrings.ko.resx | 106 +++++++++++- HellionChat/Resources/HellionStrings.nb.resx | 90 ++++++++++ HellionChat/Resources/HellionStrings.nl.resx | 90 ++++++++++ HellionChat/Resources/HellionStrings.pl.resx | 90 ++++++++++ .../Resources/HellionStrings.pt-BR.resx | 90 ++++++++++ .../Resources/HellionStrings.pt-PT.resx | 90 ++++++++++ HellionChat/Resources/HellionStrings.resx | 90 ++++++++++ HellionChat/Resources/HellionStrings.ro.resx | 90 ++++++++++ HellionChat/Resources/HellionStrings.ru.resx | 90 ++++++++++ HellionChat/Resources/HellionStrings.sv.resx | 90 ++++++++++ HellionChat/Resources/HellionStrings.tr.resx | 90 ++++++++++ HellionChat/Resources/HellionStrings.uk.resx | 90 ++++++++++ .../Resources/HellionStrings.zh-Hans.resx | 106 +++++++++++- .../Resources/HellionStrings.zh-Hant.resx | 118 +++++++++++-- .../Ui/Components/Settings/ColorPicker.cs | 31 ++-- .../Ui/Components/Settings/FontsSection.cs | 11 +- .../Components/Settings/LivePreviewPanel.cs | 3 +- .../Settings/ThemeImportExportRow.cs | 10 +- .../Ui/Components/Settings/ThemePicker.cs | 13 +- 31 files changed, 2419 insertions(+), 135 deletions(-) diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index 8f1e99e..d050c3a 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -382,6 +382,36 @@ internal class HellionStrings internal static string Settings_Section_Cleanup => Get(nameof(Settings_Section_Cleanup)); internal static string Settings_Section_Export => Get(nameof(Settings_Section_Export)); internal static string Settings_Section_Telemetry => Get(nameof(Settings_Section_Telemetry)); + internal static string Settings_Theme_ActiveTheme => Get(nameof(Settings_Theme_ActiveTheme)); + internal static string Settings_Theme_ForkAndEdit => Get(nameof(Settings_Theme_ForkAndEdit)); + internal static string Settings_Theme_ForkTooltip => Get(nameof(Settings_Theme_ForkTooltip)); + internal static string Settings_Theme_EditTheme => Get(nameof(Settings_Theme_EditTheme)); + internal static string Settings_Theme_Editing => Get(nameof(Settings_Theme_Editing)); + internal static string Settings_Theme_Group_Surfaces => Get(nameof(Settings_Theme_Group_Surfaces)); + internal static string Settings_Theme_Group_Borders => Get(nameof(Settings_Theme_Group_Borders)); + internal static string Settings_Theme_Group_Text => Get(nameof(Settings_Theme_Group_Text)); + internal static string Settings_Theme_Group_Identity => Get(nameof(Settings_Theme_Group_Identity)); + internal static string Settings_Theme_Group_Status => Get(nameof(Settings_Theme_Group_Status)); + internal static string Settings_Theme_Save => Get(nameof(Settings_Theme_Save)); + internal static string Settings_Theme_Cancel => Get(nameof(Settings_Theme_Cancel)); + internal static string Settings_Theme_ResetToSource => Get(nameof(Settings_Theme_ResetToSource)); + internal static string Settings_Theme_ResetUnavailable => Get(nameof(Settings_Theme_ResetUnavailable)); + internal static string Settings_Theme_LockedTooltip => Get(nameof(Settings_Theme_LockedTooltip)); + internal static string Settings_Theme_Custom => Get(nameof(Settings_Theme_Custom)); + internal static string Settings_Theme_ForkActive => Get(nameof(Settings_Theme_ForkActive)); + internal static string Settings_Theme_ImportFile => Get(nameof(Settings_Theme_ImportFile)); + internal static string Settings_Theme_ExportActive => Get(nameof(Settings_Theme_ExportActive)); + internal static string Settings_Theme_ImportPathHint => Get(nameof(Settings_Theme_ImportPathHint)); + internal static string Settings_Theme_ExportDialogTitle => Get(nameof(Settings_Theme_ExportDialogTitle)); + internal static string Settings_Theme_Category_Cool => Get(nameof(Settings_Theme_Category_Cool)); + internal static string Settings_Theme_Category_Natural => Get(nameof(Settings_Theme_Category_Natural)); + internal static string Settings_Theme_Category_Classic => Get(nameof(Settings_Theme_Category_Classic)); + internal static string Settings_Theme_Category_Retro => Get(nameof(Settings_Theme_Category_Retro)); + internal static string Settings_Fonts_Bundled => Get(nameof(Settings_Fonts_Bundled)); + internal static string Settings_Fonts_GameFont => Get(nameof(Settings_Fonts_GameFont)); + internal static string Settings_Fonts_Global => Get(nameof(Settings_Fonts_Global)); + internal static string Settings_Fonts_Active => Get(nameof(Settings_Fonts_Active)); + internal static string Settings_Preview_TypeAMessage => Get(nameof(Settings_Preview_TypeAMessage)); internal static string Settings_Section_Links => Get(nameof(Settings_Section_Links)); internal static string Settings_Section_Behaviour => Get(nameof(Settings_Section_Behaviour)); internal static string Settings_Section_Keybinds => Get(nameof(Settings_Section_Keybinds)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index a4a3cd2..292cb2b 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1358,4 +1358,94 @@ Manifest del repositori personalitzat + + Tema actiu: {0} + + + Bifurca i edita + + + Els temes integrats no es poden editar directament. La bifurcació en crea una còpia personalitzada que pots editar i desar. + + + Edita el tema + + + S'està editant: {0} + + + Superfícies + + + Vores + + + Text + + + Identitat + + + Estat + + + Desa + + + Cancel·la + + + Restableix a l'original + + + No es pot restablir mentre s'edita una bifurcació. Desa o cancel·la primer. + + + Desa o descarta primer els teus canvis + + + Personalitzats ({0}) + + + Bifurca el tema actiu + + + Importa un fitxer de tema… + + + Exporta el tema actiu… + + + Ruta al fitxer JSON (o arrossega'l a la carpeta) + + + Exporta el tema + + + Freds + + + Naturals + + + Clàssics + + + Retro + + + Hellion Inter (inclosa) + + + Tipus de lletra del joc + + + Global: {0} + + + Actiu: {0} + + + Escriu un missatge... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index dff3b8d..8e5a5bd 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1357,4 +1357,94 @@ Manifest vlastního repozitáře + + Aktivní motiv: {0} + + + Rozvětvit a upravit + + + Vestavěné motivy nelze upravovat přímo. Rozvětvení vytvoří vlastní kopii, kterou můžeš upravit a uložit. + + + Upravit motiv + + + Upravuje se: {0} + + + Plochy + + + Okraje + + + Text + + + Identita + + + Stav + + + Uložit + + + Zrušit + + + Obnovit podle zdroje + + + Při úpravě rozvětvení nelze obnovit. Nejdřív ulož nebo zruš. + + + Nejdřív ulož nebo zahoď své změny + + + Vlastní ({0}) + + + Rozvětvit aktivní motiv + + + Importovat soubor motivu… + + + Exportovat aktivní motiv… + + + Cesta k souboru JSON (nebo přetáhni do složky) + + + Exportovat motiv + + + Chladné + + + Přírodní + + + Klasické + + + Retro + + + Hellion Inter (v balíčku) + + + Herní písmo + + + Globální: {0} + + + Aktivní: {0} + + + Napiš zprávu... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index 43bdec9..ed30752 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1357,4 +1357,94 @@ Manifest for brugerdefineret repo + + Aktivt tema: {0} + + + Forgren og rediger + + + Indbyggede temaer kan ikke redigeres direkte. Forgrening laver en brugerdefineret kopi, du kan redigere og gemme. + + + Rediger tema + + + Redigerer: {0} + + + Flader + + + Kanter + + + Tekst + + + Identitet + + + Status + + + Gem + + + Annuller + + + Nulstil til kilden + + + Nulstilling er ikke muligt, mens du redigerer en forgrening. Gem eller annuller først. + + + Gem eller kassér dine ændringer først + + + Brugerdefinerede ({0}) + + + Forgren aktivt tema + + + Importér temafil… + + + Eksportér aktivt tema… + + + Sti til JSON-fil (eller træk-og-slip i mappen) + + + Eksportér tema + + + Kølige + + + Naturlige + + + Klassiske + + + Retro + + + Hellion Inter (medfølger) + + + Spillets skrifttype + + + Global: {0} + + + Aktiv: {0} + + + Skriv en besked... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index e291b28..6129f72 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -187,7 +187,7 @@ Datensparsamkeit (empfohlen) - Es werden nur deine eigenen Konversationen gespeichert: Tells, Gruppe, FC, Linkshells, Cross-World-Linkshells, Allianz und ExtraChat. Öffentlicher Chat, NPC-Dialoge und System-Spam werden auf der Storage-Ebene verworfen. Aufbewahrung nach Spec-Defaults (Tells 365 Tage, eigene Konversations-Kanäle 90 Tage). + Es werden nur deine eigenen Konversationen gespeichert: Flüsternachrichten, Gruppe, FC, Linkshells, Cross-World-Linkshells, Allianz und ExtraChat. Öffentlicher Chat, NPC-Dialoge und System-Spam werden auf der Storage-Ebene verworfen. Aufbewahrung nach Spec-Defaults (Flüsternachrichten 365 Tage, eigene Konversations-Kanäle 90 Tage). Datensparsamkeit übernehmen @@ -268,7 +268,7 @@ Verlauf - Tell-Tabs + Flüster-Tabs Optik @@ -280,7 +280,7 @@ Filter auch auf alte Messages anwenden - N Tell-Messages beim Öffnen eines Auto-Tabs vorladen + N Flüsternachrichten beim Öffnen eines Auto-Tabs vorladen Kompakte Dichte @@ -304,7 +304,7 @@ Verlauf: {0} - Tell-Tabs: {0} Messages vorladen + Flüster-Tabs: {0} Messages vorladen Optik: {0} @@ -459,7 +459,7 @@ - Aktive Tells + Aktive Flüsternachrichten — Frühere Unterhaltungen — @@ -483,16 +483,16 @@ In Standard-Tab umwandeln - Wandelt den TempTell in einen regulären Tab um. Die Tell-Bindung an die Person geht verloren, der Tab fängt dann Nachrichten anhand der Channel-Filter ein. Für „Tab überlebt Relog" stattdessen „Tab anpinnen" wählen. + Wandelt den temporären Flüster-Tab in einen regulären Tab um. Die Flüster-Bindung an die Person geht verloren, der Tab fängt dann Nachrichten anhand der Channel-Filter ein. Für „Tab überlebt Relog" stattdessen „Tab anpinnen" wählen. - Maximal {0} angepinnte Tell-Tabs erreicht. Erst einen lösen oder dauerhaft behalten. + Maximal {0} angepinnte Flüster-Tabs erreicht. Erst einen lösen oder dauerhaft behalten. Angepinnt: überlebt Relog. - Angepinnte Tabs überleben Relog und behalten die Bindung an die Tell-Person. + Angepinnte Tabs überleben Relog und behalten die Bindung an die Flüster-Partner. Angepinnt @@ -501,12 +501,12 @@ Sidebar-Breite - Breite der Tab-Sidebar in Pixeln. Default (44 px) ist Icon-only; breiter machen damit Sektion-Header wie „Aktive Tells (3)" nicht abgeschnitten werden. + Breite der Tab-Sidebar in Pixeln. Default (44 px) ist Icon-only; breiter machen damit Sektion-Header wie „Aktive Flüsternachrichten (3)" nicht abgeschnitten werden. - Auto-Tell-Tabs + Auto-Flüster-Tabs Bei jedem /tell automatisch einen Tab pro Gesprächspartner öffnen @@ -515,22 +515,22 @@ Sobald du einen /tell empfängst oder sendest, wird automatisch ein temporärer Tab für diesen Spieler geöffnet. Die Tabs verschwinden beim Logout. - Maximale Anzahl der Auto-Tell-Tabs + Maximale Anzahl der Auto-Flüster-Tabs - Beim Erreichen werden begrüßte Tabs mit der ältesten Aktivität zuerst geschlossen. Änderungen greifen beim nächsten /tell. Diese Grenze gilt nur für den automatisch verwalteten Pool. Angepinnte Tell-Tabs (Rechtsklick → Tab anpinnen) leben in einem separaten Pool von bis zu 5 Tabs und überleben Relog. + Beim Erreichen werden begrüßte Tabs mit der ältesten Aktivität zuerst geschlossen. Änderungen greifen beim nächsten /tell. Diese Grenze gilt nur für den automatisch verwalteten Pool. Angepinnte Flüster-Tabs (Rechtsklick → Tab anpinnen) leben in einem separaten Pool von bis zu 5 Tabs und überleben Relog. Kompakte Anzeige - Zeigt nur einen dünnen Separator zwischen normalen Tabs und Auto-Tell-Tabs, ohne Sektions-Header. + Zeigt nur einen dünnen Separator zwischen normalen Tabs und Auto-Flüster-Tabs, ohne Sektions-Header. „Als begrüßt markieren"-Button anzeigen - Fügt neben jedem Auto-Tell-Tab einen Klick-Button hinzu, um einen Gesprächspartner als bereits begrüßt zu markieren: der Tab-Name wird dann gedimmt. Nützlich für Club-Greeter, die parallel viele Konversationen führen. Standardmäßig aus. + Fügt neben jedem Auto-Flüster-Tab einen Klick-Button hinzu, um einen Gesprächspartner als bereits begrüßt zu markieren: der Tab-Name wird dann gedimmt. Nützlich für Club-Greeter, die parallel viele Konversationen führen. Standardmäßig aus. Neue /tell-Tabs direkt als Pop-Out öffnen @@ -539,24 +539,24 @@ Wenn aktiv, wird jeder neu angelegte /tell-Tab sofort als eigenes Fenster geöffnet. Beim Schließen des Fensters kehrt der Tab in die Seitenleiste zurück. - Die Anzahl der vorgeladenen Tells lässt sich im Datenschutz-Tab einstellen. + Die Anzahl der vorgeladenen Flüsternachrichten lässt sich im Datenschutz-Tab einstellen. - Hinweis: Falls XIV Messanger oder ein ähnliches Plugin Tells unterdrückt, dort die Option „Suppress DMs" deaktivieren, damit Hellion Chat Tells empfangen und die Auto-Tabs öffnen kann. + Hinweis: Falls XIV Messanger oder ein ähnliches Plugin Flüsternachrichten unterdrückt, dort die Option „Suppress DMs" deaktivieren, damit Hellion Chat Flüsternachrichten empfangen und die Auto-Tabs öffnen kann. - Tell-Verlauf in Auto-Tabs + Flüster-Verlauf in Auto-Tabs - Anzahl der vorgeladenen Tells + Anzahl der vorgeladenen Flüsternachrichten - Wie viele frühere Tell-Nachrichten beim Öffnen eines Auto-Tell-Tabs aus der Datenbank geladen werden. 0 deaktiviert die Vorladung. + Wie viele frühere Flüsternachrichten beim Öffnen eines Auto-Flüster-Tabs aus der Datenbank geladen werden. 0 deaktiviert die Vorladung. - Greift nur, wenn Auto-Tell-Tabs im Chat-Tab aktiviert sind. + Greift nur, wenn Auto-Flüster-Tabs im Chat-Tab aktiviert sind. @@ -692,7 +692,7 @@ Eingabe in Pop-Outs aktivieren - Master-Switch: erlaubt direktes Tippen und Absenden in jedem Pop-Out-Fenster (inkl. Auto-Tell-Tabs). Channel-Wechsel im Pop-Out wirkt global wie im Hauptfenster; Text-Buffer und History-Cursor sind pro Pop-Out unabhängig. + Master-Switch: erlaubt direktes Tippen und Absenden in jedem Pop-Out-Fenster (inkl. Auto-Flüster-Tabs). Channel-Wechsel im Pop-Out wirkt global wie im Hauptfenster; Text-Buffer und History-Cursor sind pro Pop-Out unabhängig. Fenster-Position zurücksetzen @@ -755,7 +755,7 @@ Chat - Tells, Vorschau, Nachrichten-Verhalten und Emotes. + Flüsternachrichten, Vorschau, Nachrichten-Verhalten und Emotes. Tabs @@ -991,7 +991,7 @@ Eingabe & Vorschau - Auto-Tell-Tabs + Auto-Flüster-Tabs Emotes @@ -1063,10 +1063,10 @@ Änderungsprotokoll - Benachrichtigung bei fehlgeschlagenem Tell + Benachrichtigung bei fehlgeschlagenem Flüstern - Zeigt eine Toast-Meldung an, wenn ein von dir gesendeter Tell nicht zugestellt werden konnte (Empfänger offline, in einer Instanz oder hat dich blockiert). + Zeigt eine Toast-Meldung an, wenn ein von dir gesendeter Flüstern nicht zugestellt werden konnte (Empfänger offline, in einer Instanz oder hat dich blockiert). Warnung vor dem Senden plugin-exklusiver Symbole @@ -1132,10 +1132,10 @@ Hellion-Sound - Ein Tell konnte nicht zugestellt werden. + Ein Flüstern konnte nicht zugestellt werden. - Tell an {0} konnte nicht zugestellt werden. + Flüstern an {0} konnte nicht zugestellt werden. Diese Nachricht enthält plugin-exklusive Symbole, die andere Spieler als leere Kästchen sehen könnten. Drücke Enter erneut, um sie trotzdem zu senden. @@ -1194,10 +1194,10 @@ Sperren - Warnen, wenn ein Tell nicht ankommt + Warnen, wenn ein Flüstern nicht ankommt - Das Spiel meldet einen fehlgeschlagenen Tell nur im Protokoll. Das hier zeigt stattdessen eine Benachrichtigung, damit eine Nachricht an jemanden offline oder auf einer anderen Welt nicht untergeht. + Das Spiel meldet einen fehlgeschlagenen Flüstern nur im Protokoll. Das hier zeigt stattdessen eine Benachrichtigung, damit eine Nachricht an jemanden offline oder auf einer anderen Welt nicht untergeht. Zur neuesten Nachricht springen @@ -1242,7 +1242,7 @@ Größenverhalten - Tell automatisch öffnen + Flüstern automatisch öffnen Seitenleiste @@ -1293,13 +1293,13 @@ Auf welcher Seite die Befehlsliste beim Tippen erscheint. - Modus für automatisches Tell-Öffnen + Modus für automatisches Flüster-Öffnen - Wo ein Tell geöffnet wird, wenn er ankommt. + Wo ein Flüstern geöffnet wird, wenn er ankommt. - Bei jedem Tell zum Tab wechseln + Bei jedem Flüstern zum Tab wechseln Sonst öffnet der Tab nach dem ersten im Hintergrund. @@ -1352,4 +1352,94 @@ Manifest des eigenen Repos + + Aktives Theme: {0} + + + Abzweigen und bearbeiten + + + Eingebaute Themes lassen sich nicht direkt bearbeiten. Abzweigen legt eine eigene Kopie an, die du ändern und speichern kannst. + + + Theme bearbeiten + + + In Bearbeitung: {0} + + + Flächen + + + Rahmen + + + Text + + + Identität + + + Status + + + Speichern + + + Abbrechen + + + Auf das Original zurücksetzen + + + Zurücksetzen geht nicht, solange eine Abzweigung bearbeitet wird. Erst speichern oder abbrechen. + + + Erst deine Änderungen speichern oder verwerfen + + + Eigene ({0}) + + + Aktives Theme abzweigen + + + Theme-Datei importieren… + + + Aktives Theme exportieren… + + + Pfad zur JSON-Datei (oder in den Ordner ziehen) + + + Theme exportieren + + + Kühl + + + Natürlich + + + Klassisch + + + Retro + + + Hellion Inter (mitgeliefert) + + + Spielschrift + + + Global: {0} + + + Aktiv: {0} + + + Nachricht schreiben... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index 1e54808..d0d4ee7 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1357,4 +1357,94 @@ Δήλωση προσαρμοσμένου αποθετηρίου + + Ενεργό θέμα: {0} + + + Διακλάδωση και επεξεργασία + + + Τα ενσωματωμένα θέματα δεν επεξεργάζονται απευθείας. Η διακλάδωση δημιουργεί ένα προσαρμοσμένο αντίγραφο που μπορείτε να επεξεργαστείτε και να αποθηκεύσετε. + + + Επεξεργασία θέματος + + + Επεξεργασία: {0} + + + Επιφάνειες + + + Περιγράμματα + + + Κείμενο + + + Ταυτότητα + + + Κατάσταση + + + Αποθήκευση + + + Άκυρο + + + Επαναφορά στην πηγή + + + Η επαναφορά δεν είναι διαθέσιμη κατά την επεξεργασία διακλάδωσης. Αποθηκεύστε ή ακυρώστε πρώτα. + + + Αποθηκεύστε ή απορρίψτε πρώτα τις αλλαγές σας + + + Προσαρμοσμένα ({0}) + + + Διακλάδωση ενεργού θέματος + + + Εισαγωγή αρχείου θέματος… + + + Εξαγωγή ενεργού θέματος… + + + Διαδρομή προς αρχείο JSON (ή σύρετε στον φάκελο) + + + Εξαγωγή θέματος + + + Ψυχρά + + + Φυσικά + + + Κλασικά + + + Ρετρό + + + Hellion Inter (ενσωματωμένη) + + + Γραμματοσειρά παιχνιδιού + + + Καθολική: {0} + + + Ενεργή: {0} + + + Γράψτε ένα μήνυμα... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index 76bf2b5..dd558b0 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1358,4 +1358,94 @@ Manifiesto del repositorio personalizado + + Tema activo: {0} + + + Bifurcar y editar + + + Los temas integrados no se pueden editar directamente. Bifurcar crea una copia personalizada que puedes editar y guardar. + + + Editar tema + + + Editando: {0} + + + Superficies + + + Bordes + + + Texto + + + Identidad + + + Estado + + + Guardar + + + Cancelar + + + Restablecer al original + + + No se puede restablecer mientras editas una bifurcación. Guarda o cancela primero. + + + Guarda o descarta tus cambios primero + + + Personalizados ({0}) + + + Bifurcar el tema activo + + + Importar archivo de tema… + + + Exportar el tema activo… + + + Ruta al archivo JSON (o arrástralo a la carpeta) + + + Exportar tema + + + Fríos + + + Naturales + + + Clásicos + + + Retro + + + Hellion Inter (incluida) + + + Fuente del juego + + + Global: {0} + + + Activa: {0} + + + Escribe un mensaje... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index a837fef..f9229a8 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1357,4 +1357,94 @@ Mukautetun tietovaraston manifesti + + Aktiivinen teema: {0} + + + Haaroita ja muokkaa + + + Sisäänrakennettuja teemoja ei voi muokata suoraan. Haaroitus luo mukautetun kopion, jota voit muokata ja tallentaa. + + + Muokkaa teemaa + + + Muokataan: {0} + + + Pinnat + + + Reunat + + + Teksti + + + Identiteetti + + + Tila + + + Tallenna + + + Peruuta + + + Palauta lähteeseen + + + Palautus ei ole käytettävissä haaraa muokattaessa. Tallenna tai peruuta ensin. + + + Tallenna tai hylkää muutoksesi ensin + + + Mukautetut ({0}) + + + Haaroita aktiivinen teema + + + Tuo teematiedosto… + + + Vie aktiivinen teema… + + + JSON-tiedoston polku (tai vedä kansioon) + + + Vie teema + + + Viileät + + + Luonnolliset + + + Klassiset + + + Retro + + + Hellion Inter (mukana) + + + Pelin fontti + + + Yleinen: {0} + + + Aktiivinen: {0} + + + Kirjoita viesti... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index f149332..2b27927 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -187,7 +187,7 @@ Minimisation des données (recommandé) - Seules vos propres conversations sont enregistrées : tells, équipe, CL, linkshells, linkshells inter-mondes, alliance et ExtraChat. Le chat public, les dialogues PNJ et le spam système sont écartés au niveau du stockage. La conservation suit les valeurs par défaut de la spécification (tells 365 jours, vos canaux de conversation 90 jours). + Seules vos propres conversations sont enregistrées : messages privés, équipe, CL, linkshells, linkshells inter-mondes, alliance et ExtraChat. Le chat public, les dialogues PNJ et le spam système sont écartés au niveau du stockage. La conservation suit les valeurs par défaut de la spécification (messages privés 365 jours, vos canaux de conversation 90 jours). Appliquer la minimisation des données @@ -268,7 +268,7 @@ Historique - Onglets de tell + Onglets de message privé Visuel @@ -280,7 +280,7 @@ Appliquer les filtres aux messages des sessions précédentes - Précharger N messages de tell à l'ouverture d'un onglet automatique + Précharger N messages privés à l'ouverture d'un onglet automatique Densité compacte @@ -304,7 +304,7 @@ Historique : {0} - Onglets de tell : préchargement de {0} messages + Onglets de message privé : préchargement de {0} messages Visuel : {0} @@ -459,7 +459,7 @@ - Tells actifs + Messages privés actifs — Conversations précédentes — @@ -483,7 +483,7 @@ Promouvoir en permanent - Transforme ce TempTell en onglet régulier. Le lien du tell avec le partenaire est rompu. L'onglet capturera désormais les messages selon ses filtres de canaux. Pour « onglet qui survit à la reconnexion en restant lié à ce partenaire », utilisez Épingler l'onglet à la place. + Transforme cet onglet MP temporaire en onglet régulier. Le lien du message privé avec le partenaire est rompu. L'onglet capturera désormais les messages selon ses filtres de canaux. Pour « onglet qui survit à la reconnexion en restant lié à ce partenaire », utilisez Épingler l'onglet à la place. Les onglets épinglés survivent à la reconnexion et restent liés à ce partenaire de conversation. @@ -495,10 +495,10 @@ Largeur de la barre latérale - Largeur de la barre latérale d'onglets en pixels. La valeur par défaut (44 px) n'affiche que les icônes ; élargissez-la pour permettre l'affichage complet d'en-têtes de section comme « Tells actifs (3) » sans troncature. + Largeur de la barre latérale d'onglets en pixels. La valeur par défaut (44 px) n'affiche que les icônes ; élargissez-la pour permettre l'affichage complet d'en-têtes de section comme « Messages privés actifs (3) » sans troncature. - Maximum de {0} onglets de tell épinglés atteint. Désépinglez-en un d'abord, ou utilisez Promouvoir en permanent. + Maximum de {0} onglets de message privé épinglés atteint. Désépinglez-en un d'abord, ou utilisez Promouvoir en permanent. Épinglé : survit à la reconnexion. @@ -506,7 +506,7 @@ - Onglets de tell automatiques + Onglets de message privé automatiques Ouvrir automatiquement un onglet par partenaire de conversation pour chaque /tell @@ -515,22 +515,22 @@ Dès que vous recevez ou envoyez un /tell, un onglet temporaire est automatiquement ouvert pour ce joueur. Les onglets sont supprimés à la déconnexion. - Nombre maximum d'onglets de tell automatiques + Nombre maximum d'onglets de message privé automatiques - Lorsque la limite est atteinte, les onglets salués avec l'activité la plus ancienne sont fermés en premier. Les changements prennent effet au prochain /tell. Cette limite s'applique au pool géré automatiquement. Les onglets de tell épinglés (clic droit → Épingler l'onglet) vivent dans un pool distinct pouvant contenir jusqu'à 5 éléments et survivent à la reconnexion. + Lorsque la limite est atteinte, les onglets salués avec l'activité la plus ancienne sont fermés en premier. Les changements prennent effet au prochain /tell. Cette limite s'applique au pool géré automatiquement. Les onglets de message privé épinglés (clic droit → Épingler l'onglet) vivent dans un pool distinct pouvant contenir jusqu'à 5 éléments et survivent à la reconnexion. Affichage compact - Affiche uniquement un fin séparateur entre les onglets réguliers et les onglets de tell automatiques, sans en-tête de section. + Affiche uniquement un fin séparateur entre les onglets réguliers et les onglets de message privé automatiques, sans en-tête de section. Afficher le bouton « Marquer comme salué » - Ajoute un bouton à côté de chaque onglet de tell automatique pour marquer un partenaire de conversation comme déjà salué. Le nom de l'onglet est alors atténué. Utile pour les hôtes de club qui gèrent de nombreuses conversations en parallèle. Désactivé par défaut. + Ajoute un bouton à côté de chaque onglet de message privé automatique pour marquer un partenaire de conversation comme déjà salué. Le nom de l'onglet est alors atténué. Utile pour les hôtes de club qui gèrent de nombreuses conversations en parallèle. Désactivé par défaut. Ouvrir les nouveaux onglets /tell directement comme fenêtres détachées @@ -539,24 +539,24 @@ Quand cette option est active, chaque onglet /tell nouvellement créé est immédiatement ouvert dans sa propre fenêtre. Fermer la fenêtre renvoie l'onglet dans la barre latérale. - Le nombre de tells préchargés peut être configuré dans l'onglet Confidentialité. + Le nombre de messages privés préchargés peut être configuré dans l'onglet Confidentialité. - Remarque : si XIV Messenger ou un plugin similaire supprime les tells, désactivez l'option « Suppress DMs » à cet endroit afin que Hellion Chat puisse recevoir les tells et ouvrir les onglets automatiques. + Remarque : si XIV Messenger ou un plugin similaire supprime les messages privés, désactivez l'option « Suppress DMs » à cet endroit afin que Hellion Chat puisse recevoir les messages privés et ouvrir les onglets automatiques. - Historique des tells dans les onglets automatiques + Historique des messages privés dans les onglets automatiques - Nombre de tells préchargés + Nombre de messages privés préchargés - Combien de messages de tell antérieurs sont chargés depuis la base de données à l'ouverture d'un onglet de tell automatique. 0 désactive le préchargement. + Combien de messages privés antérieurs sont chargés depuis la base de données à l'ouverture d'un onglet de message privé automatique. 0 désactive le préchargement. - N'a d'effet que lorsque les onglets de tell automatiques sont activés dans l'onglet Chat. + N'a d'effet que lorsque les onglets de message privé automatiques sont activés dans l'onglet Chat. @@ -692,7 +692,7 @@ Activer la saisie dans les fenêtres détachées - Interrupteur principal : permet de taper et d'envoyer directement dans n'importe quelle fenêtre détachée (y compris les onglets de tell automatiques). Le changement de canal dans une fenêtre détachée agit globalement comme dans la fenêtre principale ; le tampon de texte et le curseur d'historique sont indépendants pour chaque fenêtre détachée. + Interrupteur principal : permet de taper et d'envoyer directement dans n'importe quelle fenêtre détachée (y compris les onglets de message privé automatiques). Le changement de canal dans une fenêtre détachée agit globalement comme dans la fenêtre principale ; le tampon de texte et le curseur d'historique sont indépendants pour chaque fenêtre détachée. Réinitialiser la position de la fenêtre @@ -755,7 +755,7 @@ Chat - Tells, aperçu, comportement des messages et emotes. + Messages privés, aperçu, comportement des messages et emotes. Onglets @@ -997,7 +997,7 @@ Saisie et aperçu - Onglets tell automatiques + Onglets de message privé automatiques Emotes @@ -1069,10 +1069,10 @@ Journal des modifications - Notifier en cas de Tell échoué + Notifier en cas de Message privé échoué - Affiche une notification lorsqu'un Tell que vous avez envoyé n'a pas pu être remis (destinataire hors ligne, dans une instance ou vous bloquant). + Affiche une notification lorsqu'un Message privé que vous avez envoyé n'a pas pu être remis (destinataire hors ligne, dans une instance ou vous bloquant). Avertir avant d'envoyer des symboles réservés au plugin @@ -1138,10 +1138,10 @@ Son Hellion - Un Tell n'a pas pu être remis. + Un Message privé n'a pas pu être remis. - Le Tell à {0} n'a pas pu être remis. + Le Message privé à {0} n'a pas pu être remis. Ce message contient des symboles réservés au plugin que d'autres joueurs pourraient voir comme des cases vides. Appuyez à nouveau sur Entrée pour envoyer quand même. @@ -1200,10 +1200,10 @@ Bloquer - Avertir quand un tell n'arrive pas + Avertir quand un message privé n'arrive pas - Le jeu ne signale un tell échoué que dans le journal. Ceci affiche une notification à la place, pour qu'un message à quelqu'un hors ligne ou sur un autre monde ne passe pas inaperçu. + Le jeu ne signale un message privé échoué que dans le journal. Ceci affiche une notification à la place, pour qu'un message à quelqu'un hors ligne ou sur un autre monde ne passe pas inaperçu. Aller au message le plus récent @@ -1248,7 +1248,7 @@ Comportement de redimensionnement - Ouverture automatique des tell + Ouverture automatique des messages privés Barre latérale @@ -1299,13 +1299,13 @@ De quel côté la liste de suggestions apparaît pendant la saisie. - Mode d'ouverture automatique des tell + Mode d'ouverture automatique des messages privés - Où s'ouvre un tell à son arrivée. + Où s'ouvre un message privé à son arrivée. - Basculer vers l'onglet à chaque tell + Basculer vers l'onglet à chaque message privé Sinon l'onglet s'ouvre en arrière-plan après le premier. @@ -1358,4 +1358,94 @@ Manifeste du dépôt personnalisé + + Thème actif : {0} + + + Dupliquer et modifier + + + Les thèmes intégrés ne peuvent pas être modifiés directement. Dupliquer crée une copie personnalisée que vous pouvez modifier et enregistrer. + + + Modifier le thème + + + Modification : {0} + + + Surfaces + + + Bordures + + + Texte + + + Identité + + + État + + + Enregistrer + + + Annuler + + + Réinitialiser à la source + + + La réinitialisation est indisponible pendant la modification d'une copie. Enregistrez ou annulez d'abord. + + + Enregistrez ou annulez vos modifications d'abord + + + Personnalisés ({0}) + + + Dupliquer le thème actif + + + Importer un fichier de thème… + + + Exporter le thème actif… + + + Chemin du fichier JSON (ou glissez-déposez dans le dossier) + + + Exporter le thème + + + Froids + + + Naturels + + + Classiques + + + Rétro + + + Hellion Inter (incluse) + + + Police du jeu + + + Globale : {0} + + + Actif : {0} + + + Écrivez un message... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index 7cff664..c6c366e 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1357,4 +1357,94 @@ Egyéni tároló manifesztje + + Aktív téma: {0} + + + Elágaztatás és szerkesztés + + + A beépített témák nem szerkeszthetők közvetlenül. Az elágaztatás egyéni másolatot hoz létre, amit szerkeszthetsz és menthetsz. + + + Téma szerkesztése + + + Szerkesztés alatt: {0} + + + Felületek + + + Szegélyek + + + Szöveg + + + Identitás + + + Állapot + + + Mentés + + + Mégse + + + Visszaállítás az eredetire + + + Visszaállítás nem érhető el elágazás szerkesztése közben. Előbb ments vagy szakítsd meg. + + + Előbb mentsd vagy vesd el a módosításaidat + + + Egyéni ({0}) + + + Aktív téma elágaztatása + + + Témafájl importálása… + + + Aktív téma exportálása… + + + A JSON-fájl útvonala (vagy húzd a mappába) + + + Téma exportálása + + + Hűvös + + + Természetes + + + Klasszikus + + + Retró + + + Hellion Inter (mellékelt) + + + A játék betűtípusa + + + Globális: {0} + + + Aktív: {0} + + + Írj egy üzenetet... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index da962c6..4e23358 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1358,4 +1358,94 @@ Manifest del repository personalizzato + + Tema attivo: {0} + + + Duplica e modifica + + + I temi integrati non si possono modificare direttamente. La duplicazione crea una copia personalizzata che puoi modificare e salvare. + + + Modifica tema + + + Modifica di: {0} + + + Superfici + + + Bordi + + + Testo + + + Identità + + + Stato + + + Salva + + + Annulla + + + Ripristina all'originale + + + Il ripristino non è disponibile durante la modifica di una copia. Salva o annulla prima. + + + Salva o annulla prima le tue modifiche + + + Personalizzati ({0}) + + + Duplica il tema attivo + + + Importa file tema… + + + Esporta il tema attivo… + + + Percorso del file JSON (o trascinalo nella cartella) + + + Esporta tema + + + Freddi + + + Naturali + + + Classici + + + Retro + + + Hellion Inter (inclusa) + + + Carattere del gioco + + + Globale: {0} + + + Attivo: {0} + + + Scrivi un messaggio... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index 31c8ef2..d807837 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -506,7 +506,7 @@ - Auto-Tell-Tabs + オートテルタブ /tell ごとに会話相手ごとのタブを自動で開く @@ -997,7 +997,7 @@ 入力とプレビュー - オートTellタブ + オートテルタブ エモート @@ -1200,10 +1200,10 @@ ブロック - Tellが届かなかったときに警告する + テルが届かなかったときに警告する - ゲームはTellの失敗をログにしか表示しません。この設定では代わりに通知を出すので、オフラインや別ワールドの相手へのメッセージを見落としません。 + ゲームはテルの失敗をログにしか表示しません。この設定では代わりに通知を出すので、オフラインや別ワールドの相手へのメッセージを見落としません。 最新のメッセージへ移動 @@ -1248,7 +1248,7 @@ サイズ変更の動作 - Tellの自動オープン + テルの自動オープン サイドバー @@ -1299,13 +1299,13 @@ 入力中にコマンド候補をどちら側に表示するか。 - Tell自動オープンの方式 + テル自動オープンの方式 - Tellが届いたときにどこで開くか。 + テルが届いたときにどこで開くか。 - Tellのたびにタブへ切り替える + テルのたびにタブへ切り替える そうでない場合、最初の 1 通のあとタブは背面で開きます。 @@ -1358,4 +1358,94 @@ カスタムリポジトリのマニフェスト + + 現在のテーマ: {0} + + + 複製して編集 + + + 組み込みテーマは直接編集できません。複製すると、編集して保存できる独自のコピーが作られます。 + + + テーマを編集 + + + 編集中: {0} + + + 面 + + + 枠線 + + + テキスト + + + アイデンティティ + + + ステータス + + + 保存 + + + キャンセル + + + 元に戻す + + + 複製を編集中はリセットできません。先に保存またはキャンセルしてください。 + + + 先に変更を保存または破棄してください + + + カスタム ({0}) + + + 現在のテーマを複製 + + + テーマファイルをインポート… + + + 現在のテーマをエクスポート… + + + JSONファイルのパス(またはフォルダーにドラッグ&ドロップ) + + + テーマをエクスポート + + + クール + + + ナチュラル + + + クラシック + + + レトロ + + + Hellion Inter(同梱) + + + ゲーム内フォント + + + 全体: {0} + + + 使用中: {0} + + + メッセージを入力... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index a521299..59b8f6a 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -506,7 +506,7 @@ - Auto-Tell-Tabs + 자동 귓속말 탭 모든 /tell에 대해 대화 상대별 탭 자동 열기 @@ -997,7 +997,7 @@ 입력 및 미리 보기 - 자동 텔 탭 + 자동 귓속말 탭 감정 표현 @@ -1200,10 +1200,10 @@ 차단 - 텔이 전달되지 않으면 알림 + 귓속말이 전달되지 않으면 알림 - 게임은 텔 실패를 로그에만 표시합니다. 이 설정은 대신 알림을 띄워, 오프라인이거나 다른 월드에 있는 상대에게 보낸 메시지를 놓치지 않게 합니다. + 게임은 귓속말 실패를 로그에만 표시합니다. 이 설정은 대신 알림을 띄워, 오프라인이거나 다른 월드에 있는 상대에게 보낸 메시지를 놓치지 않게 합니다. 최신 메시지로 이동 @@ -1248,7 +1248,7 @@ 크기 조절 동작 - 텔 자동 열기 + 귓속말 자동 열기 사이드바 @@ -1299,13 +1299,13 @@ 입력 중에 명령어 힌트 목록을 어느 쪽에 표시할지. - 텔 자동 열기 방식 + 귓속말 자동 열기 방식 - 텔이 도착했을 때 어디에서 열릴지. + 귓속말이 도착했을 때 어디에서 열릴지. - 텔마다 탭으로 전환 + 귓속말마다 탭으로 전환 그렇지 않으면 첫 통 이후 탭이 배경에서 열립니다. @@ -1358,4 +1358,94 @@ 커스텀 저장소 매니페스트 + + 현재 테마: {0} + + + 복제 후 편집 + + + 기본 제공 테마는 직접 편집할 수 없습니다. 복제하면 편집하고 저장할 수 있는 사용자 테마가 만들어집니다. + + + 테마 편집 + + + 편집 중: {0} + + + 표면 + + + 테두리 + + + 텍스트 + + + 아이덴티티 + + + 상태 + + + 저장 + + + 취소 + + + 원본으로 되돌리기 + + + 복제본을 편집하는 동안에는 되돌릴 수 없습니다. 먼저 저장하거나 취소하세요. + + + 먼저 변경 사항을 저장하거나 취소하세요 + + + 사용자 지정 ({0}) + + + 현재 테마 복제 + + + 테마 파일 가져오기… + + + 현재 테마 내보내기… + + + JSON 파일 경로 (또는 폴더로 끌어다 놓기) + + + 테마 내보내기 + + + 쿨 + + + 내추럴 + + + 클래식 + + + 레트로 + + + Hellion Inter (기본 포함) + + + 게임 글꼴 + + + 전역: {0} + + + 사용 중: {0} + + + 메시지를 입력하세요... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index c254a8e..ccde0d7 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1357,4 +1357,94 @@ Manifest for egendefinert repo + + Aktivt tema: {0} + + + Forgrein og rediger + + + Innebygde temaer kan ikke redigeres direkte. Forgrening lager en egen kopi du kan redigere og lagre. + + + Rediger tema + + + Redigerer: {0} + + + Flater + + + Kanter + + + Tekst + + + Identitet + + + Status + + + Lagre + + + Avbryt + + + Tilbakestill til kilden + + + Tilbakestilling er ikke tilgjengelig mens du redigerer en forgrening. Lagre eller avbryt først. + + + Lagre eller forkast endringene dine først + + + Egendefinerte ({0}) + + + Forgrein aktivt tema + + + Importer temafil… + + + Eksporter aktivt tema… + + + Sti til JSON-fil (eller dra og slipp i mappen) + + + Eksporter tema + + + Kjølige + + + Naturlige + + + Klassiske + + + Retro + + + Hellion Inter (medfølger) + + + Spillets skrift + + + Global: {0} + + + Aktiv: {0} + + + Skriv en melding... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index e4bd097..81b2bb3 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1358,4 +1358,94 @@ Manifest van eigen repository + + Actief thema: {0} + + + Aftakken en bewerken + + + Ingebouwde thema's kun je niet rechtstreeks bewerken. Aftakken maakt een eigen kopie die je kunt bewerken en opslaan. + + + Thema bewerken + + + Bezig met bewerken: {0} + + + Vlakken + + + Randen + + + Tekst + + + Identiteit + + + Status + + + Opslaan + + + Annuleren + + + Terugzetten naar bron + + + Terugzetten kan niet tijdens het bewerken van een aftakking. Sla eerst op of annuleer. + + + Sla je wijzigingen eerst op of verwerp ze + + + Eigen ({0}) + + + Actief thema aftakken + + + Themabestand importeren… + + + Actief thema exporteren… + + + Pad naar JSON-bestand (of sleep het naar de map) + + + Thema exporteren + + + Koel + + + Natuurlijk + + + Klassiek + + + Retro + + + Hellion Inter (meegeleverd) + + + Speltypografie + + + Globaal: {0} + + + Actief: {0} + + + Typ een bericht... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index 1f25e83..51a1933 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1357,4 +1357,94 @@ Manifest własnego repozytorium + + Aktywny motyw: {0} + + + Rozgałęź i edytuj + + + Wbudowanych motywów nie można edytować bezpośrednio. Rozgałęzienie tworzy własną kopię, którą możesz edytować i zapisać. + + + Edytuj motyw + + + Edytowanie: {0} + + + Powierzchnie + + + Obramowania + + + Tekst + + + Tożsamość + + + Status + + + Zapisz + + + Anuluj + + + Przywróć do źródła + + + Przywracanie jest niedostępne podczas edycji rozgałęzienia. Najpierw zapisz lub anuluj. + + + Najpierw zapisz lub odrzuć swoje zmiany + + + Własne ({0}) + + + Rozgałęź aktywny motyw + + + Importuj plik motywu… + + + Eksportuj aktywny motyw… + + + Ścieżka do pliku JSON (lub przeciągnij do folderu) + + + Eksportuj motyw + + + Chłodne + + + Naturalne + + + Klasyczne + + + Retro + + + Hellion Inter (w zestawie) + + + Czcionka z gry + + + Globalna: {0} + + + Aktywna: {0} + + + Napisz wiadomość... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index a34a18a..820e5d3 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1358,4 +1358,94 @@ Manifesto do repositório personalizado + + Tema ativo: {0} + + + Bifurcar e editar + + + Temas integrados não podem ser editados diretamente. Bifurcar cria uma cópia personalizada que você pode editar e salvar. + + + Editar tema + + + Editando: {0} + + + Superfícies + + + Bordas + + + Texto + + + Identidade + + + Status + + + Salvar + + + Cancelar + + + Restaurar para o original + + + Não é possível restaurar enquanto edita uma bifurcação. Salve ou cancele primeiro. + + + Salve ou descarte suas alterações primeiro + + + Personalizados ({0}) + + + Bifurcar o tema ativo + + + Importar arquivo de tema… + + + Exportar o tema ativo… + + + Caminho do arquivo JSON (ou arraste para a pasta) + + + Exportar tema + + + Frios + + + Naturais + + + Clássicos + + + Retrô + + + Hellion Inter (incluída) + + + Fonte do jogo + + + Global: {0} + + + Ativa: {0} + + + Digite uma mensagem... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 70c0010..a3ea6c0 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1357,4 +1357,94 @@ Manifesto do repositório personalizado + + Tema ativo: {0} + + + Bifurcar e editar + + + Os temas integrados não podem ser editados diretamente. Bifurcar cria uma cópia personalizada que pode editar e guardar. + + + Editar tema + + + A editar: {0} + + + Superfícies + + + Contornos + + + Texto + + + Identidade + + + Estado + + + Guardar + + + Cancelar + + + Repor para o original + + + Não é possível repor enquanto edita uma bifurcação. Guarde ou cancele primeiro. + + + Guarde ou descarte primeiro as suas alterações + + + Personalizados ({0}) + + + Bifurcar o tema ativo + + + Importar ficheiro de tema… + + + Exportar o tema ativo… + + + Caminho do ficheiro JSON (ou arraste para a pasta) + + + Exportar tema + + + Frios + + + Naturais + + + Clássicos + + + Retro + + + Hellion Inter (incluída) + + + Tipo de letra do jogo + + + Global: {0} + + + Ativo: {0} + + + Escreva uma mensagem... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index 874af81..71e085a 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1369,4 +1369,94 @@ Custom repo manifest + + Active theme: {0} + + + Fork & Edit + + + Built-in themes cannot be edited in place. Fork creates a custom copy you can edit and save. + + + Edit theme + + + Editing: {0} + + + Surfaces + + + Borders + + + Text + + + Identity + + + Status + + + Save + + + Cancel + + + Reset to source + + + Reset is unavailable while editing a fork. Save or Cancel first. + + + Save or discard your edits first + + + Custom ({0}) + + + Fork active theme + + + Import theme file… + + + Export active theme… + + + Path to JSON file (or drag-and-drop into the folder) + + + Export theme + + + Cool + + + Natural + + + Classic + + + Retro + + + Hellion Inter (bundled) + + + FFXIV game font + + + Global: {0} + + + Active: {0} + + + Type a message... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index 6d3b3bf..0503d7f 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1358,4 +1358,94 @@ Manifest depozit personalizat + + Tema activă: {0} + + + Ramifică și editează + + + Temele integrate nu pot fi editate direct. Ramificarea creează o copie personalizată pe care o poți edita și salva. + + + Editează tema + + + Se editează: {0} + + + Suprafețe + + + Margini + + + Text + + + Identitate + + + Stare + + + Salvează + + + Anulează + + + Resetează la sursă + + + Resetarea nu este disponibilă în timpul editării unei ramificații. Salvează sau anulează mai întâi. + + + Salvează sau renunță mai întâi la modificări + + + Personalizate ({0}) + + + Ramifică tema activă + + + Importă fișier de temă… + + + Exportă tema activă… + + + Calea către fișierul JSON (sau trage-l în folder) + + + Exportă tema + + + Reci + + + Naturale + + + Clasice + + + Retro + + + Hellion Inter (inclusă) + + + Fontul jocului + + + Global: {0} + + + Activ: {0} + + + Scrie un mesaj... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index 3e7a071..35d9b9e 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1358,4 +1358,94 @@ Манифест пользовательского репозитория + + Активная тема: {0} + + + Ответвить и изменить + + + Встроенные темы нельзя изменить напрямую. Ответвление создаёт собственную копию, которую можно изменить и сохранить. + + + Изменить тему + + + Изменяется: {0} + + + Поверхности + + + Границы + + + Текст + + + Идентичность + + + Состояние + + + Сохранить + + + Отмена + + + Вернуть к исходной + + + Сброс недоступен во время правки ответвления. Сначала сохраните или отмените. + + + Сначала сохраните или отмените изменения + + + Свои ({0}) + + + Ответвить активную тему + + + Импортировать файл темы… + + + Экспортировать активную тему… + + + Путь к файлу JSON (или перетащите его в папку) + + + Экспорт темы + + + Холодные + + + Природные + + + Классические + + + Ретро + + + Hellion Inter (в комплекте) + + + Игровой шрифт + + + Общий: {0} + + + Активный: {0} + + + Введите сообщение... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index 7010fd8..9ebf96b 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1358,4 +1358,94 @@ Manifest för eget arkiv + + Aktivt tema: {0} + + + Förgrena och redigera + + + Inbyggda teman kan inte redigeras direkt. Förgrening skapar en egen kopia som du kan redigera och spara. + + + Redigera tema + + + Redigerar: {0} + + + Ytor + + + Kanter + + + Text + + + Identitet + + + Status + + + Spara + + + Avbryt + + + Återställ till källan + + + Återställning är inte tillgänglig medan du redigerar en förgrening. Spara eller avbryt först. + + + Spara eller kasta dina ändringar först + + + Egna ({0}) + + + Förgrena aktivt tema + + + Importera temafil… + + + Exportera aktivt tema… + + + Sökväg till JSON-fil (eller dra och släpp i mappen) + + + Exportera tema + + + Svala + + + Naturliga + + + Klassiska + + + Retro + + + Hellion Inter (medföljer) + + + Spelets typsnitt + + + Global: {0} + + + Aktiv: {0} + + + Skriv ett meddelande... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index ce3c23b..e867403 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1357,4 +1357,94 @@ Özel depo manifesti + + Etkin tema: {0} + + + Çatalla ve düzenle + + + Yerleşik temalar doğrudan düzenlenemez. Çatallama, düzenleyip kaydedebileceğiniz özel bir kopya oluşturur. + + + Temayı düzenle + + + Düzenleniyor: {0} + + + Yüzeyler + + + Kenarlıklar + + + Metin + + + Kimlik + + + Durum + + + Kaydet + + + İptal + + + Kaynağa sıfırla + + + Bir çatal düzenlenirken sıfırlama kullanılamaz. Önce kaydedin veya iptal edin. + + + Önce değişikliklerinizi kaydedin veya atın + + + Özel ({0}) + + + Etkin temayı çatalla + + + Tema dosyası içe aktar… + + + Etkin temayı dışa aktar… + + + JSON dosyasının yolu (veya klasöre sürükleyip bırakın) + + + Temayı dışa aktar + + + Soğuk + + + Doğal + + + Klasik + + + Retro + + + Hellion Inter (birlikte gelir) + + + Oyun yazı tipi + + + Genel: {0} + + + Etkin: {0} + + + Bir ileti yazın... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 491bc74..16d6a65 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1357,4 +1357,94 @@ Маніфест власного репозиторію + + Активна тема: {0} + + + Відгалузити та редагувати + + + Вбудовані теми не можна редагувати напряму. Відгалуження створює власну копію, яку можна редагувати та зберегти. + + + Редагувати тему + + + Редагується: {0} + + + Поверхні + + + Межі + + + Текст + + + Ідентичність + + + Стан + + + Зберегти + + + Скасувати + + + Скинути до джерела + + + Скидання недоступне під час редагування відгалуження. Спершу збережіть або скасуйте. + + + Спершу збережіть або скасуйте зміни + + + Власні ({0}) + + + Відгалузити активну тему + + + Імпортувати файл теми… + + + Експортувати активну тему… + + + Шлях до файлу JSON (або перетягніть у теку) + + + Експорт теми + + + Холодні + + + Природні + + + Класичні + + + Ретро + + + Hellion Inter (у комплекті) + + + Ігровий шрифт + + + Загальний: {0} + + + Активний: {0} + + + Введіть повідомлення... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index 268bb73..66d8f71 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -506,7 +506,7 @@ - Auto-Tell-Tabs + 自动密语标签页 为每条 /tell 自动为对话伙伴开启专属标签页 @@ -997,7 +997,7 @@ 输入与预览 - 自动Tell标签页 + 自动密语标签页 表情动作 @@ -1200,10 +1200,10 @@ 屏蔽 - Tell未送达时提醒 + 密语未送达时提醒 - 游戏只在日志里提示Tell发送失败。此设置改为弹出通知,让发给离线或其他世界玩家的消息不会被忽略。 + 游戏只在日志里提示密语发送失败。此设置改为弹出通知,让发给离线或其他世界玩家的消息不会被忽略。 跳到最新消息 @@ -1248,7 +1248,7 @@ 调整大小行为 - 自动打开Tell + 自动打开密语 侧边栏 @@ -1299,13 +1299,13 @@ 输入时命令提示列表出现在哪一侧。 - Tell自动打开方式 + 密语自动打开方式 - Tell到达时在何处打开。 + 密语到达时在何处打开。 - 每条Tell都切换到标签 + 每条密语都切换到标签 否则标签会在第一条之后于后台打开。 @@ -1358,4 +1358,94 @@ 自定义仓库清单 + + 当前主题:{0} + + + 复制并编辑 + + + 内置主题无法直接编辑。复制会创建一份可编辑并保存的自定义副本。 + + + 编辑主题 + + + 正在编辑:{0} + + + 表面 + + + 边框 + + + 文本 + + + 身份 + + + 状态 + + + 保存 + + + 取消 + + + 重置为原始主题 + + + 编辑副本时无法重置。请先保存或取消。 + + + 请先保存或放弃你的更改 + + + 自定义 ({0}) + + + 复制当前主题 + + + 导入主题文件… + + + 导出当前主题… + + + JSON 文件路径(或拖放到文件夹) + + + 导出主题 + + + 冷色 + + + 自然 + + + 经典 + + + 复古 + + + Hellion Inter(内置) + + + 游戏字体 + + + 全局:{0} + + + 使用中:{0} + + + 输入消息... + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index 32ae002..e8ccac9 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -506,7 +506,7 @@ - Auto-Tell-Tabs + 自動悄悄話標籤頁 每收發一則 /tell 自動為對話對象開啟標籤頁 @@ -515,7 +515,7 @@ 一旦收到或傳送 /tell,就會自動為該玩家開啟暫時標籤頁。登出時標籤頁將被移除。 - Auto-Tell 標籤頁的最大數量 + Auto-悄悄話 標籤頁的最大數量 達到上限時,活動最舊的已問候標籤頁會優先關閉。變更在下次 /tell 時生效。此限制僅適用於自動管理的標籤頁池。釘選的悄悄話標籤頁(右鍵 → 釘選標籤頁)存在於獨立的最多 5 個標籤頁池中,並在重新登入後保留。 @@ -524,13 +524,13 @@ 緊湊顯示 - 在一般標籤頁和 Auto-Tell 標籤頁之間只顯示一條細分隔線,不顯示區段標題。 + 在一般標籤頁和 Auto-悄悄話 標籤頁之間只顯示一條細分隔線,不顯示區段標題。 顯示「標記為已問候」按鈕 - 在每個 Auto-Tell 標籤頁旁新增一個點擊按鈕,用於將對話對象標記為已問候,標籤頁名稱將隨之變暗。適合同時管理多個對話的接待人員使用。預設為關閉。 + 在每個 Auto-悄悄話 標籤頁旁新增一個點擊按鈕,用於將對話對象標記為已問候,標籤頁名稱將隨之變暗。適合同時管理多個對話的接待人員使用。預設為關閉。 直接以彈出視窗開啟新的 /tell 標籤頁 @@ -553,10 +553,10 @@ 預載悄悄話的數量 - 開啟 Auto-Tell 標籤頁時從資料庫載入的先前悄悄話訊息數量。設為 0 則停用預載。 + 開啟 Auto-悄悄話 標籤頁時從資料庫載入的先前悄悄話訊息數量。設為 0 則停用預載。 - 只有在聊天標籤頁中啟用 Auto-Tell 標籤頁後才會生效。 + 只有在聊天標籤頁中啟用 Auto-悄悄話 標籤頁後才會生效。 @@ -692,7 +692,7 @@ 啟用彈出視窗中的輸入功能 - 主開關:允許在任何彈出視窗(包括 Auto-Tell 標籤頁)中直接輸入和傳送訊息。彈出視窗中的頻道切換會像主視窗一樣全域生效;文字緩衝區和歷史游標則各彈出視窗獨立。 + 主開關:允許在任何彈出視窗(包括 Auto-悄悄話 標籤頁)中直接輸入和傳送訊息。彈出視窗中的頻道切換會像主視窗一樣全域生效;文字緩衝區和歷史游標則各彈出視窗獨立。 重設視窗位置 @@ -997,7 +997,7 @@ 輸入與預覽 - 自動Tell標籤頁 + 自動悄悄話標籤頁 情感動作 @@ -1200,10 +1200,10 @@ 封鎖 - Tell未送達時提醒 + 悄悄話未送達時提醒 - 遊戲只在日誌裡提示Tell傳送失敗。此設定改為彈出通知,讓發給離線或其他世界玩家的訊息不會被忽略。 + 遊戲只在日誌裡提示悄悄話傳送失敗。此設定改為彈出通知,讓發給離線或其他世界玩家的訊息不會被忽略。 跳到最新訊息 @@ -1248,7 +1248,7 @@ 調整大小行為 - 自動開啟Tell + 自動開啟悄悄話 側邊欄 @@ -1299,13 +1299,13 @@ 輸入時指令提示清單出現在哪一側。 - Tell自動開啟方式 + 悄悄話自動開啟方式 - Tell到達時在何處開啟。 + 悄悄話到達時在何處開啟。 - 每則Tell都切換到分頁 + 每則悄悄話都切換到分頁 否則分頁會在第一則之後於背景開啟。 @@ -1358,4 +1358,94 @@ 自訂儲存庫資訊清單 + + 目前主題:{0} + + + 複製並編輯 + + + 內建主題無法直接編輯。複製會建立一份可編輯並儲存的自訂副本。 + + + 編輯主題 + + + 正在編輯:{0} + + + 表面 + + + 邊框 + + + 文字 + + + 身分 + + + 狀態 + + + 儲存 + + + 取消 + + + 重設為原始主題 + + + 編輯副本時無法重設。請先儲存或取消。 + + + 請先儲存或捨棄你的變更 + + + 自訂 ({0}) + + + 複製目前主題 + + + 匯入主題檔案… + + + 匯出目前主題… + + + JSON 檔案路徑(或拖放到資料夾) + + + 匯出主題 + + + 冷色 + + + 自然 + + + 經典 + + + 復古 + + + Hellion Inter(內建) + + + 遊戲字型 + + + 全域:{0} + + + 使用中:{0} + + + 輸入訊息... + \ No newline at end of file diff --git a/HellionChat/Ui/Components/Settings/ColorPicker.cs b/HellionChat/Ui/Components/Settings/ColorPicker.cs index bda8c08..c38691b 100644 --- a/HellionChat/Ui/Components/Settings/ColorPicker.cs +++ b/HellionChat/Ui/Components/Settings/ColorPicker.cs @@ -1,6 +1,7 @@ using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Interface.Utility.Raii; +using HellionChat.Resources; using HellionChat.Themes; using HellionChat.Util; @@ -31,26 +32,24 @@ internal sealed class ColorPicker private void DrawIdleState() { var active = _themes.Active; - ImGui.TextDisabled($"Active theme: {active.Name}"); + ImGui.TextDisabled(string.Format(HellionStrings.Settings_Theme_ActiveTheme, active.Name)); // Fork built-ins before editing: Switch() prefers built-in slugs over custom // files with the same slug, so an in-place edit would silently no-op. if (active.IsBuiltIn) { - if (ImGui.Button("Fork & Edit")) + if (ImGui.Button(HellionStrings.Settings_Theme_ForkAndEdit)) { ForkAndBeginEditing(active); } if (ImGui.IsItemHovered()) { - ImGui.SetTooltip( - "Built-in themes cannot be edited in place. Fork creates a custom copy you can edit and save." - ); + ImGui.SetTooltip(HellionStrings.Settings_Theme_ForkTooltip); } } else { - if (ImGui.Button("Edit theme")) + if (ImGui.Button(HellionStrings.Settings_Theme_EditTheme)) { _themes.BeginEditing(active); } @@ -87,12 +86,12 @@ internal sealed class ColorPicker private void DrawEditState(Theme buffer) { - ImGui.TextUnformatted($"Editing: {buffer.Name}"); + ImGui.TextUnformatted(string.Format(HellionStrings.Settings_Theme_Editing, buffer.Name)); ImGui.Separator(); DrawSection( ImGui.GetID("colors.surfaces"u8), - "Surfaces", + HellionStrings.Settings_Theme_Group_Surfaces, buffer, c => new[] @@ -116,7 +115,7 @@ internal sealed class ColorPicker DrawSection( ImGui.GetID("colors.borders"u8), - "Borders", + HellionStrings.Settings_Theme_Group_Borders, buffer, c => new[] { ("Border", c.Border) }, (c, edits) => c with { Border = edits[0].color } @@ -124,7 +123,7 @@ internal sealed class ColorPicker DrawSection( ImGui.GetID("colors.text"u8), - "Text", + HellionStrings.Settings_Theme_Group_Text, buffer, c => new[] @@ -186,7 +185,7 @@ internal sealed class ColorPicker DrawSection( ImGui.GetID("colors.identity"u8), - "Identity", + HellionStrings.Settings_Theme_Group_Identity, buffer, c => new[] { ("Identity", c.Identity) }, (c, edits) => c with { Identity = edits[0].color } @@ -194,7 +193,7 @@ internal sealed class ColorPicker DrawSection( ImGui.GetID("colors.status"u8), - "Status", + HellionStrings.Settings_Theme_Group_Status, buffer, c => new[] @@ -264,12 +263,12 @@ internal sealed class ColorPicker private void DrawActionButtons(Theme buffer) { - if (ImGui.Button("Save")) + if (ImGui.Button(HellionStrings.Settings_Theme_Save)) { _themes.SaveEditingBuffer(out _); } ImGui.SameLine(); - if (ImGui.Button("Cancel")) + if (ImGui.Button(HellionStrings.Settings_Theme_Cancel)) { _themes.DiscardEditingBuffer(); } @@ -282,14 +281,14 @@ internal sealed class ColorPicker var isForkBuffer = !buffer.IsBuiltIn && _themes.Active.Slug != buffer.Slug; using (ImRaii.Disabled(isForkBuffer)) { - if (ImGui.Button("Reset to source")) + if (ImGui.Button(HellionStrings.Settings_Theme_ResetToSource)) { _themes.BeginEditing(_themes.Active); } } if (isForkBuffer && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) { - ImGui.SetTooltip("Reset is unavailable while editing a fork. Save or Cancel first."); + ImGui.SetTooltip(HellionStrings.Settings_Theme_ResetUnavailable); } } } diff --git a/HellionChat/Ui/Components/Settings/FontsSection.cs b/HellionChat/Ui/Components/Settings/FontsSection.cs index 279af05..eb8e1ec 100644 --- a/HellionChat/Ui/Components/Settings/FontsSection.cs +++ b/HellionChat/Ui/Components/Settings/FontsSection.cs @@ -43,11 +43,14 @@ internal sealed class FontsSection // Readout so the user can see which font is actually active. var active = - Plugin.Config.UseHellionFont ? "Hellion Inter (bundled)" + Plugin.Config.UseHellionFont ? HellionStrings.Settings_Fonts_Bundled : Plugin.Config.FontsEnabled - ? $"Global: {Plugin.Config.GlobalFontV2.FontId.Family.EnglishName}" - : "FFXIV game font"; - ImGui.TextDisabled($"Active: {active}"); + ? string.Format( + HellionStrings.Settings_Fonts_Global, + Plugin.Config.GlobalFontV2.FontId.Family.EnglishName + ) + : HellionStrings.Settings_Fonts_GameFont; + ImGui.TextDisabled(string.Format(HellionStrings.Settings_Fonts_Active, active)); ImGui.Spacing(); if ( diff --git a/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs b/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs index 3bf96c1..acf9984 100644 --- a/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs +++ b/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs @@ -3,6 +3,7 @@ using System.Threading; using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility.Raii; +using HellionChat.Resources; using HellionChat.Themes; using HellionChat.Ui.StyleEngine; using HellionChat.Util; @@ -307,7 +308,7 @@ internal sealed class LivePreviewPanel : IDisposable ); draw.AddText(pillTextPos, ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary), pillLabel); - var placeholder = "Type a message..."; + var placeholder = HellionStrings.Settings_Preview_TypeAMessage; var phSize = ImGui.CalcTextSize(placeholder); var phPos = new Vector2(pillMax.X + 6f, origin.Y + (height - phSize.Y) * 0.5f); draw.AddText(phPos, ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary), placeholder); diff --git a/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs b/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs index 4ae2f24..1656a08 100644 --- a/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs +++ b/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs @@ -21,13 +21,13 @@ internal sealed class ThemeImportExportRow public void Draw() { - if (ImGui.Button("Fork active theme")) + if (ImGui.Button(HellionStrings.Settings_Theme_ForkActive)) { ForkActive(); } ImGui.SameLine(); - if (ImGui.Button("Import theme file…")) + if (ImGui.Button(HellionStrings.Settings_Theme_ImportFile)) { ImportFromPath(_importPath); } @@ -39,7 +39,7 @@ internal sealed class ThemeImportExportRow } ImGui.SameLine(); - if (ImGui.Button("Export active theme…")) + if (ImGui.Button(HellionStrings.Settings_Theme_ExportActive)) { ExportActive(); } @@ -47,7 +47,7 @@ internal sealed class ThemeImportExportRow ImGui.SetNextItemWidth(-1); ImGui.InputTextWithHint( "##theme-import-path", - "Path to JSON file (or drag-and-drop into the folder)", + HellionStrings.Settings_Theme_ImportPathHint, ref _importPath, 512 ); @@ -309,7 +309,7 @@ internal sealed class ThemeImportExportRow var defaultName = $"{theme.Slug}.json"; Plugin.FileDialogManager.SaveFileDialog( - "Export theme", + HellionStrings.Settings_Theme_ExportDialogTitle, ".json", defaultName, ".json", diff --git a/HellionChat/Ui/Components/Settings/ThemePicker.cs b/HellionChat/Ui/Components/Settings/ThemePicker.cs index e9a392c..c18bee8 100644 --- a/HellionChat/Ui/Components/Settings/ThemePicker.cs +++ b/HellionChat/Ui/Components/Settings/ThemePicker.cs @@ -1,6 +1,7 @@ using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Interface.Utility.Raii; +using HellionChat.Resources; using HellionChat.Themes; using HellionChat.Util; @@ -16,13 +17,13 @@ internal sealed class ThemePicker true ), ( - "Cool", + HellionStrings.Settings_Theme_Category_Cool, new[] { "night-blue", "event-horizon", "indigo-violet", "crystal-nocturne" }, false ), - ("Natural", new[] { "mint-grove" }, false), - ("Classic", new[] { "chat2-classic" }, false), - ("Retro", new[] { "synthwave-sunset" }, false), + (HellionStrings.Settings_Theme_Category_Natural, new[] { "mint-grove" }, false), + (HellionStrings.Settings_Theme_Category_Classic, new[] { "chat2-classic" }, false), + (HellionStrings.Settings_Theme_Category_Retro, new[] { "synthwave-sunset" }, false), }; // T2 ThemePickerCategoryStep diffs this against ThemeRegistry.BuiltinSlugs @@ -78,7 +79,7 @@ internal sealed class ThemePicker if ( _sections.Draw( ImGui.GetID("theme.category.custom"u8), - $"Custom ({customs.Count})", + string.Format(HellionStrings.Settings_Theme_Custom, customs.Count), disabled: locked ) ) @@ -90,7 +91,7 @@ internal sealed class ThemePicker if (locked && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) { - ImGui.SetTooltip("Save or discard your edits first"); + ImGui.SetTooltip(HellionStrings.Settings_Theme_LockedTooltip); } } From bbfb9fc630902fc75cddf9989d53eb6a94cae6bc Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 06:50:23 +0200 Subject: [PATCH 124/129] feat(tabs): the tab editor is back There has been no way to create a tab, delete one, reorder them, or choose what any of them collects since the settings window was rebuilt in May. The five tabs a config happened to have were all a user could ever have. Every label for this was sitting in the resources, translated into 25 languages, with no caller. An accordion, not the list-and-detail pane the plan sketched. The settings column is narrow, every other tab in this window is a stack of collapsible sections, and a split pane inside one of them would be the only thing here that reads differently without buying anything. Channels go through the matrix that already existed, unchanged: it knows the groups, the sub-matrices and the ExtraChat channels, and it is localised. What it does not know is copy-on-write -- it mutates the dictionary it is handed -- so it never gets the tab's own. Edits land in a working copy and are published as one reference swap when the user leaves the tab, and only if something actually changed. Saving is deferred behind a dirty flag with a short idle, not IsItemDeactivatedAfterEdit. That idiom defers for sliders and text fields, which stay active across frames; a checkbox activates and deactivates inside one click, so it would fire exactly as often as the return value and write the config file once per box. Deleting closes the pop-out first, or the pool keeps a slot bound to a tab that no longer exists. The last editable tab cannot be deleted at all: the message list has no empty state. Temp tabs are not editable here -- their name is a conversation partner and the auto-tell service owns their lifetime -- so they are skipped entirely. Also E2: Tab.AddMessage stamps LastActivity for every message now. The condition that used to gate it filtered on InactivityHideChannels, a setting belonging to hide-when-inactive, and that feature lost its reader in cf4705e. Which tell tab the pool drops first -- the only thing that reads the stamp -- was hanging on a setting for something that does not happen. The three channel fields behind it are gone. --- HellionChat/Configuration.cs | 22 +- .../Ui/Components/Settings/TabEditor.cs | 419 ++++++++++++++++++ .../Components/Settings/Tabs/ChannelsTab.cs | 7 + 3 files changed, 434 insertions(+), 14 deletions(-) create mode 100644 HellionChat/Ui/Components/Settings/TabEditor.cs diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index 4d83a50..731355f 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -185,12 +185,6 @@ public class Configuration : IPluginConfiguration public bool HideInNewGamePlusMenu = true; public bool HideWhenInactive; - [Obsolete("Use InactivityHideChannelsV2 instead")] - public Dictionary InactivityHideChannels = []; - - public Dictionary InactivityHideChannelsV2 = []; - public bool InactivityHideExtraChatAll = true; - public HashSet InactivityHideExtraChatChannels = []; public bool ShowHideButton = true; public bool NativeItemTooltips = true; public bool ScreenshotMode; @@ -520,14 +514,14 @@ public class Tab return; Unread += 1; - if ( - message.Matches( - Plugin.Config.InactivityHideChannelsV2, - Plugin.Config.InactivityHideExtraChatAll, - Plugin.Config.InactivityHideExtraChatChannels - ) - ) - LastActivity = Environment.TickCount64; + + // Stamped for every message now. The condition that used to sit here + // filtered on InactivityHideChannels, a setting for the hide-when- + // inactive feature -- and that feature lost its reader in cf4705e. So + // which tell tab the auto-tell pool drops first, which is the only + // thing that reads this stamp, hung on a setting for something that + // does not happen. + LastActivity = Environment.TickCount64; } public void Clear() => Messages.Clear(); diff --git a/HellionChat/Ui/Components/Settings/TabEditor.cs b/HellionChat/Ui/Components/Settings/TabEditor.cs new file mode 100644 index 0000000..739ed1a --- /dev/null +++ b/HellionChat/Ui/Components/Settings/TabEditor.cs @@ -0,0 +1,419 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using HellionChat.Code; +using HellionChat.Resources; +using HellionChat.Util; + +namespace HellionChat.Ui.Components.Settings; + +// The only way to create a tab, delete one, reorder them, or choose what a tab +// collects. All of it went out with the settings window in May; the labels +// stayed, translated, in all 25 languages. +// +// An accordion rather than the list-and-detail pane the plan sketched. The +// settings content column is narrow, every other tab in this window is a stack +// of collapsible sections, and a split pane inside one of them would be the only +// thing here that reads differently for no gain. +internal sealed class TabEditor +{ + private readonly Plugin _plugin; + + // The channel matrix mutates the dictionary it is handed, so it never gets + // the tab's own. Edits land here and are published as one reference swap + // when the user leaves the tab -- see Tab.ReplaceChannelFilter for why a + // half-mutated dictionary is worse than a stale one. + private Guid _editing; + private Dictionary? _workingChannels; + private HashSet? _workingExtraChat; + private bool _workingExtraChatAll; + + // Saving on every checkbox would write the config file sixty-odd times per + // matrix. IsItemDeactivatedAfterEdit does not help here: it defers for + // sliders and text fields, which stay active across frames, but a checkbox + // activates and deactivates inside one click, so the event fires exactly as + // often as the return value. + private bool _dirty; + private long _dirtyAt; + + private const long SaveIdleMs = 600; + + public TabEditor(Plugin plugin) => _plugin = plugin; + + public void Draw() + { + List tabs; + lock (Plugin.Instance.TabsListLock) + tabs = Plugin.Config.Tabs.ToList(); + + DrawToolbar(tabs); + ImGui.Spacing(); + + for (var i = 0; i < tabs.Count; i++) + DrawTabNode(tabs, i); + + FlushIfIdle(); + } + + private void DrawToolbar(List tabs) + { + if (ImGuiUtil.IconButton(FontAwesomeIcon.Plus, tooltip: Language.Options_Tabs_Add)) + ImGui.OpenPopup("##hc-add-tab"); + + using var popup = ImRaii.Popup("##hc-add-tab"); + if (!popup.Success) + return; + + if (ImGui.Selectable(Language.Options_Tabs_NewTab)) + Insert(new Tab()); + + ImGui.Separator(); + + // Templates that have sat in TabsUtil without a caller. A new tab with + // no channels selected collects nothing, so an empty one is the worst + // possible starting point for anybody who has not read the matrix yet. + foreach (var (label, factory) in Presets) + { + if (ImGui.Selectable(string.Format(Language.Options_Tabs_Preset, label()))) + Insert(factory()); + } + } + + private static readonly (Func Label, Func Factory)[] Presets = + [ + (() => HellionStrings.Tabs_Presets_Party, () => TabsUtil.HellionParty), + (() => HellionStrings.Tabs_Presets_FreeCompany, () => TabsUtil.HellionFreeCompany), + (() => HellionStrings.Tabs_Presets_Linkshell, () => TabsUtil.HellionLinkshell), + (() => HellionStrings.Tabs_Presets_System, () => TabsUtil.HellionSystem), + (() => HellionStrings.Tabs_Presets_Beginner, () => TabsUtil.HellionBeginner), + ]; + + private void Insert(Tab tab) + { + lock (Plugin.Instance.TabsListLock) + Plugin.Config.Tabs.Insert( + TabLifecycleHelpers.InsertIndexForNewTab(Plugin.Config.Tabs), + tab + ); + + _plugin.SaveConfig(); + RequestRefilter(); + } + + private void DrawTabNode(List tabs, int index) + { + var tab = tabs[index]; + + // Temp tabs share the list but not the editor: their name is a + // conversation partner, the auto-tell service owns their lifetime, and + // deleting one here would mean deleting a conversation. Pinning is the + // gesture they accept, and that lives in the context menu. + if (!TabLifecycleHelpers.IsEditable(tab)) + return; + + using var id = ImRaii.PushId(tab.Identifier.ToString()); + + // ### keeps the node's identity while its label follows the name field. + using var node = ImRaii.TreeNode($"{tab.Name}###hc-tab-node"); + if (!node.Success) + { + // Collapsing is a leave: publish whatever was edited in here. + if (_editing == tab.Identifier) + CommitChannels(tab); + return; + } + + DrawRowButtons(tabs, index, tab); + ImGui.Spacing(); + + var name = tab.Name; + ImGui.SetNextItemWidth(240f * ImGuiHelpers.GlobalScale); + if (ImGui.InputText(Language.Options_Tabs_Name, ref name, 512)) + { + tab.Name = name; + MarkDirty(); + } + + DrawIconPicker(tab); + DrawDisplay(tab); + DrawChannels(tab); + } + + private void DrawRowButtons(List tabs, int index, Tab tab) + { + var canDelete = TabLifecycleHelpers.CanDelete(tabs, index); + using (ImRaii.Disabled(!canDelete)) + { + if ( + ImGuiUtil.IconButton( + FontAwesomeIcon.TrashAlt, + tooltip: Language.Options_Tabs_Delete + ) && canDelete + ) + { + Delete(tab, tabs, index); + return; + } + } + + ImGui.SameLine(); + if (ImGuiUtil.IconButton(FontAwesomeIcon.ArrowUp, tooltip: Language.Options_Tabs_MoveUp)) + Move(index, -1); + + ImGui.SameLine(); + if ( + ImGuiUtil.IconButton(FontAwesomeIcon.ArrowDown, tooltip: Language.Options_Tabs_MoveDown) + ) + Move(index, +1); + + // Duplicating is the cheapest way to build a variant of a tab that + // already has its sixty channels picked, and Tab.Clone has been ready + // for it since v1.8.0. + ImGui.SameLine(); + if (ImGuiUtil.IconButton(FontAwesomeIcon.Copy, tooltip: Language.Options_Tabs_Add)) + { + var copy = tab.Clone(); + copy.Identifier = Guid.NewGuid(); + Insert(copy); + } + } + + private void Delete(Tab tab, List tabs, int index) + { + // The pool binds windows by identifier; without this the slot stays + // taken by a tab that no longer exists. + _plugin.ChannelPopoutPool.TryClose(tab.Identifier); + + lock (Plugin.Instance.TabsListLock) + Plugin.Config.Tabs.RemoveAll(t => t.Identifier == tab.Identifier); + + if (_editing == tab.Identifier) + ClearWorking(); + + _ = TabLifecycleHelpers.SelectionAfterDelete(tabs, index); + _plugin.SaveConfig(); + RequestRefilter(); + } + + private void Move(int index, int delta) + { + lock (Plugin.Instance.TabsListLock) + { + var list = Plugin.Config.Tabs; + var target = TabLifecycleHelpers.MoveIndex(list, index, delta); + if (target == index) + return; + + var tab = list[index]; + list.RemoveAt(index); + list.Insert(target, tab); + } + + _plugin.SaveConfig(); + } + + private void DrawIconPicker(Tab tab) + { + var current = tab.Icon ?? HellionStrings.Tabs_Icon_DefaultOption; + using (var combo = ImGuiUtil.BeginComboVertical(HellionStrings.Tabs_Icon_Label, current)) + { + if (combo.Success) + { + if (ImGui.Selectable(HellionStrings.Tabs_Icon_DefaultOption, tab.Icon is null)) + { + tab.Icon = null; + MarkDirty(); + } + + foreach (var glyph in IconNames) + { + if (!ImGui.Selectable(glyph, tab.Icon == glyph)) + continue; + + tab.Icon = glyph; + MarkDirty(); + } + } + } + + ImGuiUtil.HelpMarker(HellionStrings.Tabs_Icon_HelpMarker); + } + + // Same set the sidebar resolves; a name outside it falls back to the + // channel-derived glyph rather than showing nothing. + private static readonly string[] IconNames = + [ + "comment", + "comments", + "cog", + "users", + "user-friends", + "link", + "envelope", + "clock", + "hashtag", + "star", + "heart", + "bell", + "bookmark", + "flag", + "fire", + ]; + + private void DrawDisplay(Tab tab) + { + using var node = ImRaii.TreeNode( + $"{HellionStrings.Settings_Section_Tab_Display}###hc-tab-display" + ); + if (!node.Success) + return; + + using var indent = ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false); + + if (ImGui.Checkbox(Language.Options_Tabs_ShowTimestamps, ref tab.DisplayTimestamp)) + MarkDirty(); + + using ( + var combo = ImGuiUtil.BeginComboVertical( + Language.Options_Tabs_UnreadMode, + tab.UnreadMode.Name() + ) + ) + { + if (combo.Success) + { + foreach (var mode in EnumValues.All) + { + if (ImGui.Selectable(mode.Name(), tab.UnreadMode == mode)) + { + tab.UnreadMode = mode; + MarkDirty(); + } + + if (mode.Tooltip() is { } tooltip && ImGui.IsItemHovered()) + ImGuiUtil.Tooltip(tooltip); + } + } + } + + if (ImGui.Checkbox(Language.Options_Tabs_SenderMessages, ref tab.AllSenderMessages)) + MarkDirty(); + } + + private void DrawChannels(Tab tab) + { + using var node = ImRaii.TreeNode( + $"{HellionStrings.Settings_Section_Tab_Channels}###hc-tab-channels" + ); + if (!node.Success) + { + if (_editing == tab.Identifier) + CommitChannels(tab); + return; + } + + // Switching tabs without collapsing the previous one still has to + // publish it, or the edits sit in a working copy nobody reads again. + if (_editing != tab.Identifier) + { + CommitPending(); + SeedWorking(tab); + } + + using var indent = ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false); + ImGuiUtil.ChannelSelector(Language.Options_Tabs_Channels, _workingChannels!); + ImGuiUtil.ExtraChatSelector( + Language.Options_Tabs_ExtraChatChannels, + ref _workingExtraChatAll, + _workingExtraChat! + ); + } + + private void SeedWorking(Tab tab) + { + _editing = tab.Identifier; + _workingChannels = new Dictionary(tab.SelectedChannels); + _workingExtraChat = new HashSet(tab.ExtraChatChannels); + _workingExtraChatAll = tab.ExtraChatAll; + } + + private void CommitPending() + { + if (_editing == Guid.Empty) + return; + + Tab? tab; + lock (Plugin.Instance.TabsListLock) + tab = Plugin.Config.Tabs.FirstOrDefault(t => t.Identifier == _editing); + + if (tab is not null) + CommitChannels(tab); + else + ClearWorking(); + } + + private void CommitChannels(Tab tab) + { + if (_workingChannels is null || _workingExtraChat is null) + { + ClearWorking(); + return; + } + + var changed = + _workingExtraChatAll != tab.ExtraChatAll + || !_workingExtraChat.SetEquals(tab.ExtraChatChannels) + || _workingChannels.Count != tab.SelectedChannels.Count + || _workingChannels.Any(p => + !tab.SelectedChannels.TryGetValue(p.Key, out var v) || v != p.Value + ); + + if (changed) + { + tab.ReplaceChannelFilter(_workingChannels, _workingExtraChatAll, _workingExtraChat); + _plugin.SaveConfig(); + + // Deselecting a channel needs a rebuild, not a filter pass: + // AddSortPrune deduplicates and never removes, so the messages that + // no longer match are already in the list. + RequestRefilter(); + } + + ClearWorking(); + } + + private void ClearWorking() + { + _editing = Guid.Empty; + _workingChannels = null; + _workingExtraChat = null; + _workingExtraChatAll = false; + } + + private void MarkDirty() + { + _dirty = true; + _dirtyAt = Environment.TickCount64; + } + + private void FlushIfIdle() + { + if (!_dirty || _dirtyAt + SaveIdleMs > Environment.TickCount64) + return; + + _dirty = false; + _plugin.SaveConfig(); + } + + // RunOnTick does not offload: it runs on the framework thread, just at the + // start of a later tick rather than inside this draw. That is the point -- + // clearing every tab mid-frame is where the hitch would come from. The + // refilter itself then goes to the thread pool. + private void RequestRefilter() => + Plugin.Framework.RunOnTick(() => + { + _plugin.MessageManager.ClearAllTabs(); + _plugin.MessageManager.FilterAllTabsAsync(); + }); +} diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs index a4deb90..8b1965f 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs @@ -7,14 +7,21 @@ namespace HellionChat.Ui.Components.Settings.Tabs; internal sealed class ChannelsTab { private readonly SettingsWidgets _w; + private readonly TabEditor _editor; public ChannelsTab(Plugin plugin, TokenResolver resolver) { _w = new SettingsWidgets(plugin, new SettingsPalette(resolver)); + _editor = new TabEditor(plugin); } public void Draw() { + // First, because it answers the question the rest of this tab assumes + // is already settled: which tabs exist and what do they collect. + if (_w.Section(ImGui.GetID("channels.tabs"u8), Language.Options_Tabs_Tab)) + _editor.Draw(); + if ( _w.Section( ImGui.GetID("channels.autotell"u8), From e7b76fb21b34a3cc28e0f52a4ad6b356aa5832d6 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 06:51:56 +0200 Subject: [PATCH 125/129] chore(i18n): remove the keys whose features were removed on purpose Fifty-four keys across 25 files, and a script that can find the rest. The script reports rather than deletes, because a key with no caller is a question and not a verdict: this cycle has twice found one that only described a feature whose button was torn out, and deleting it would have made the restoration cost 25 files of re-translation. The answer lives in the git history of the deletion, not in a grep. So only the ones where that history says the feature went on purpose: the web interface, the settings card overview the sidebar replaced, the save-and-discard model this window does not have, the LiteDB migration dialog, and three one-time announcements for versions long past. Plus nine this cycle superseded itself, including the three notes telling the reader to press Save first. 251 keys still have no caller. That is not a to-do list -- it is the inventory the search pass turned up: timestamp layouts, collapse duplicate messages, the About tab's prose, the honorific glow, the novice network button. Every one of them describes something that used to work. They stay until each has been decided one way or the other, which is the whole premise of this cycle. --- .../Resources/HellionStrings.Designer.cs | 35 ------ HellionChat/Resources/HellionStrings.ca.resx | 105 ------------------ HellionChat/Resources/HellionStrings.cs.resx | 105 ------------------ HellionChat/Resources/HellionStrings.da.resx | 105 ------------------ HellionChat/Resources/HellionStrings.de.resx | 105 ------------------ HellionChat/Resources/HellionStrings.el.resx | 105 ------------------ HellionChat/Resources/HellionStrings.es.resx | 105 ------------------ HellionChat/Resources/HellionStrings.fi.resx | 105 ------------------ HellionChat/Resources/HellionStrings.fr.resx | 105 ------------------ HellionChat/Resources/HellionStrings.hu.resx | 105 ------------------ HellionChat/Resources/HellionStrings.it.resx | 105 ------------------ HellionChat/Resources/HellionStrings.ja.resx | 105 ------------------ HellionChat/Resources/HellionStrings.ko.resx | 105 ------------------ HellionChat/Resources/HellionStrings.nb.resx | 105 ------------------ HellionChat/Resources/HellionStrings.nl.resx | 105 ------------------ HellionChat/Resources/HellionStrings.pl.resx | 105 ------------------ .../Resources/HellionStrings.pt-BR.resx | 105 ------------------ .../Resources/HellionStrings.pt-PT.resx | 105 ------------------ HellionChat/Resources/HellionStrings.resx | 105 ------------------ HellionChat/Resources/HellionStrings.ro.resx | 105 ------------------ HellionChat/Resources/HellionStrings.ru.resx | 105 ------------------ HellionChat/Resources/HellionStrings.sv.resx | 105 ------------------ HellionChat/Resources/HellionStrings.tr.resx | 105 ------------------ HellionChat/Resources/HellionStrings.uk.resx | 105 ------------------ .../Resources/HellionStrings.zh-Hans.resx | 105 ------------------ .../Resources/HellionStrings.zh-Hant.resx | 105 ------------------ scripts/find-orphan-strings.sh | 35 ++++++ 27 files changed, 35 insertions(+), 2660 deletions(-) create mode 100755 scripts/find-orphan-strings.sh diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index d050c3a..6510312 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -59,13 +59,10 @@ internal class HellionStrings internal static string Privacy_PersistUnknown_Name => Get(nameof(Privacy_PersistUnknown_Name)); internal static string Privacy_PersistUnknown_Description => Get(nameof(Privacy_PersistUnknown_Description)); - internal static string Cleanup_Heading => Get(nameof(Cleanup_Heading)); internal static string Cleanup_Unavailable_FilterOff => Get(nameof(Cleanup_Unavailable_FilterOff)); internal static string Cleanup_Unavailable_NothingListed => Get(nameof(Cleanup_Unavailable_NothingListed)); internal static string Cleanup_Help_Intro => Get(nameof(Cleanup_Help_Intro)); - internal static string Cleanup_Help_SavedNote => Get(nameof(Cleanup_Help_SavedNote)); internal static string Cleanup_Preview_Stale => Get(nameof(Cleanup_Preview_Stale)); - internal static string Retention_Help_SavedNote => Get(nameof(Retention_Help_SavedNote)); internal static string Cleanup_RefreshPreview => Get(nameof(Cleanup_RefreshPreview)); internal static string Cleanup_NoPreview => Get(nameof(Cleanup_NoPreview)); internal static string Cleanup_TotalStored => Get(nameof(Cleanup_TotalStored)); @@ -94,7 +91,6 @@ internal class HellionStrings internal static string Retention_Tag_Global => Get(nameof(Retention_Tag_Global)); internal static string Retention_Reset_Button => Get(nameof(Retention_Reset_Button)); internal static string Retention_Apply_Label => Get(nameof(Retention_Apply_Label)); - internal static string Retention_Apply_Tooltip => Get(nameof(Retention_Apply_Tooltip)); internal static string Retention_Running => Get(nameof(Retention_Running)); internal static string Retention_RunNow_Tooltip => Get(nameof(Retention_RunNow_Tooltip)); internal static string Retention_LastRun_Never => Get(nameof(Retention_LastRun_Never)); @@ -134,7 +130,6 @@ internal class HellionStrings internal static string Wizard_Step3_Section_History => Get(nameof(Wizard_Step3_Section_History)); internal static string Wizard_Step3_Section_TellTabs => Get(nameof(Wizard_Step3_Section_TellTabs)); internal static string Wizard_Step3_Section_Visual => Get(nameof(Wizard_Step3_Section_Visual)); - internal static string Wizard_Step3_LoadPreviousSession_Label => Get(nameof(Wizard_Step3_LoadPreviousSession_Label)); internal static string Wizard_Step3_FilterIncludePreviousSessions_Label => Get(nameof(Wizard_Step3_FilterIncludePreviousSessions_Label)); internal static string Wizard_Step3_AutoTellTabsHistoryPreload_Label => Get(nameof(Wizard_Step3_AutoTellTabsHistoryPreload_Label)); internal static string Wizard_Step3_UseCompactDensity_Label => Get(nameof(Wizard_Step3_UseCompactDensity_Label)); @@ -151,7 +146,6 @@ internal class HellionStrings internal static string Wizard_Step4_TestHint => Get(nameof(Wizard_Step4_TestHint)); internal static string Wizard_Step4_SettingsHint => Get(nameof(Wizard_Step4_SettingsHint)); - internal static string Export_Heading => Get(nameof(Export_Heading)); internal static string Export_Help => Get(nameof(Export_Help)); internal static string Export_Range_Label => Get(nameof(Export_Range_Label)); internal static string Export_Sender_Label => Get(nameof(Export_Sender_Label)); @@ -237,8 +231,6 @@ internal class HellionStrings internal static string Privacy_AutoTellTabs_Preload_Hint => Get(nameof(Privacy_AutoTellTabs_Preload_Hint)); // Hellion Chat — Settings UX Polish v10 wipe migration - internal static string SettingsRefactor_Migration_Title => Get(nameof(SettingsRefactor_Migration_Title)); - internal static string SettingsRefactor_Migration_Content => Get(nameof(SettingsRefactor_Migration_Content)); // Hellion Chat — Settings UX Polish 8-tab structure internal static string Settings_Tab_General => Get(nameof(Settings_Tab_General)); @@ -250,22 +242,6 @@ internal class HellionStrings internal static string Settings_Tab_Information => Get(nameof(Settings_Tab_Information)); // v1.1.0 — Settings card-grid overview - internal static string Settings_Card_General_Title => Get(nameof(Settings_Card_General_Title)); - internal static string Settings_Card_General_Subtext => Get(nameof(Settings_Card_General_Subtext)); - internal static string Settings_Card_Appearance_Title => Get(nameof(Settings_Card_Appearance_Title)); - internal static string Settings_Card_Appearance_Subtext => Get(nameof(Settings_Card_Appearance_Subtext)); - internal static string Settings_Card_Themes_Title => Get(nameof(Settings_Card_Themes_Title)); - internal static string Settings_Card_Themes_Subtext => Get(nameof(Settings_Card_Themes_Subtext)); - internal static string Settings_Card_Window_Title => Get(nameof(Settings_Card_Window_Title)); - internal static string Settings_Card_Window_Subtext => Get(nameof(Settings_Card_Window_Subtext)); - internal static string Settings_Card_Chat_Title => Get(nameof(Settings_Card_Chat_Title)); - internal static string Settings_Card_Chat_Subtext => Get(nameof(Settings_Card_Chat_Subtext)); - internal static string Settings_Card_Tabs_Title => Get(nameof(Settings_Card_Tabs_Title)); - internal static string Settings_Card_Tabs_Subtext => Get(nameof(Settings_Card_Tabs_Subtext)); - internal static string Settings_Card_Database_Title => Get(nameof(Settings_Card_Database_Title)); - internal static string Settings_Card_Database_Subtext => Get(nameof(Settings_Card_Database_Subtext)); - internal static string Settings_Card_Information_Title => Get(nameof(Settings_Card_Information_Title)); - internal static string Settings_Card_Information_Subtext => Get(nameof(Settings_Card_Information_Subtext)); // v1.1.0 — Themes-Settings-Tab internal static string Settings_Tab_Themes => Get(nameof(Settings_Tab_Themes)); @@ -294,9 +270,6 @@ internal class HellionStrings internal static string Settings_Chat_SymbolPicker_Enable_Description => Get(nameof(Settings_Chat_SymbolPicker_Enable_Description)); // Hellion Chat — Database-Tab section headings - internal static string Settings_Database_Storage_Heading => Get(nameof(Settings_Database_Storage_Heading)); - internal static string Settings_Database_Viewer_Heading => Get(nameof(Settings_Database_Viewer_Heading)); - internal static string Settings_Database_Stats_Heading => Get(nameof(Settings_Database_Stats_Heading)); internal static string Settings_Database_Busy => Get(nameof(Settings_Database_Busy)); internal static string Settings_Database_ClearHint => Get(nameof(Settings_Database_ClearHint)); internal static string Settings_Database_ClearError => Get(nameof(Settings_Database_ClearError)); @@ -339,14 +312,8 @@ internal class HellionStrings internal static string Settings_Window_ResetPosition_Description => Get(nameof(Settings_Window_ResetPosition_Description)); // Hellion Chat — v0.6.0 one-time hint banner shown inside pop-outs - internal static string Popout_v060_HintText => Get(nameof(Popout_v060_HintText)); - internal static string Popout_v060_HintAck => Get(nameof(Popout_v060_HintAck)); - internal static string Popout_v060_HintOpenSettings => Get(nameof(Popout_v060_HintOpenSettings)); // Hellion Chat — v0.6.1 pop-out header hint banner (discoverability) - internal static string Hint_v061_PopOutHeader_Body => Get(nameof(Hint_v061_PopOutHeader_Body)); - internal static string Hint_v061_PopOutHeader_Ack => Get(nameof(Hint_v061_PopOutHeader_Ack)); - internal static string Hint_v061_PopOutHeader_OpenSettings => Get(nameof(Hint_v061_PopOutHeader_OpenSettings)); // Hellion Chat — v1.0.0 Chat 2 parallel-load conflict detection internal static string ChatTwoConflictTitle => Get(nameof(ChatTwoConflictTitle)); @@ -363,7 +330,6 @@ internal class HellionStrings // Hellion Chat — v1.2.1 Settings Cleanup: new card titles + subtexts internal static string Settings_Card_DataManagement_Title => Get(nameof(Settings_Card_DataManagement_Title)); - internal static string Settings_Card_DataManagement_Subtext => Get(nameof(Settings_Card_DataManagement_Subtext)); // Hellion Chat — v1.2.1 Theme & Layout tab section headings + WindowOpacity slider internal static string Settings_ThemeAndLayout_Theme_Heading => Get(nameof(Settings_ThemeAndLayout_Theme_Heading)); @@ -467,7 +433,6 @@ internal class HellionStrings internal static string Settings_Section_Database => Get(nameof(Settings_Section_Database)); // Hellion Chat — v1.2.1 Migration v15 → v16 toast - internal static string Migration_v16_OverrideStyle_Toast => Get(nameof(Migration_v16_OverrideStyle_Toast)); // Hellion Chat — v1.3.0 Integrations (Honorific + Coming-Soon roadmap) — now in About tab internal static string Settings_Integrations_Intro => Get(nameof(Settings_Integrations_Intro)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index 292cb2b..18dba14 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -63,18 +63,9 @@ Xarxa de seguretat per als ChatTypes que afegiran futures actualitzacions de FFXIV que el plugin encara no coneix. Per defecte és DESACTIVAT (minimització de dades). Activa-ho si vols que els canals futurs també es registrin completament. - - Aplica el filtre a la base de dades existent - El filtre de privadesa només afecta els missatges nous. La neteja de sota et permet eliminar retroactivament els missatges ja emmagatzemats que no coincideixen amb la teva llista blanca desada. - - La neteja utilitza la teva llista blanca DESADA (Plugin.Config), no els canvis sense desar de dalt. Fes clic a Desa primer si vols que s'apliquin els canvis actuals. - - - L'execució manual utilitza la teva política de retenció DESADA, no els valors dels controls de dalt. Fes clic a Desa primer si vols que l'execució apliqui els canvis actuals. - La previsualització ha quedat obsoleta: la teva llista blanca ha canviat des de l'última actualització. Fes clic a Actualitza per recalcular. @@ -159,9 +150,6 @@ Aplica la retenció ara - - Ctrl+Maj: Executa la neteja de retenció immediatament amb la política DESADA. Desa els canvis primer. - La neteja de retenció s'està executant en segon pla… @@ -273,9 +261,6 @@ Visual - - Carrega la sessió anterior en iniciar - Aplica els filtres als missatges de sessions anteriors @@ -318,9 +303,6 @@ Configuració → Hellion Chat per ajustar-ho més tard - - Exportació (RGPD Art. 15 — Dret d'accés) - Exporta els missatges emmagatzemats en format Markdown, JSON o CSV. Això et permet respondre una sol·licitud d'accés d'una persona els missatges de la qual has guardat, o bé endur-te el teu propi historial. @@ -560,12 +542,6 @@ - - Configuració reestructurada - - - Hellion Chat 0.5.0 ha reestructurat la configuració en pestanyes temàtiques. La teva base de dades de xat i l'historial de missatges no han canviat. La configuració s'ha restablert als valors per defecte. Si vols tornar a seleccionar el teu perfil de privadesa, el botó Reobre es troba a la pestanya Privadesa. Una còpia de seguretat de la configuració anterior es troba a HellionChat.json.pre-v10-backup al costat del fitxer de configuració actiu. - @@ -622,15 +598,6 @@ - - Emmagatzematge - - - Visió general - - - Manteniment - @@ -700,24 +667,6 @@ Mou la finestra del xat i totes les finestres emergents actives de tornada a la cantonada superior esquerra del monitor principal. Útil quan una finestra ha acabat fora de l'àrea visible després d'un canvi de disposició de pantalla (monitor desconnectat, resolució canviada). El plugin també fa una comprovació automàtica dels límits un cop per sessió; aquest botó és la sortida manual d'emergència si alguna cosa queda inaccessible de totes maneres. - - Novetat a v0.6.0: ara pots escriure directament a les finestres emergents. Activa el commutador principal a la configuració de Finestra. - - - Entesos - - - Obre la configuració de finestra - - - Pots obrir qualsevol pestanya del xat com a finestra pròpia. Fes clic a la icona de finestra a la part superior dreta o fes clic dret sobre la pestanya. Novetat a v0.6.1: l'entrada a les finestres emergents està activa per defecte (es pot desactivar a Configuració → Finestra). - - - Entesos - - - Obre la configuració - Hellion Chat no pot iniciar-se mentre Chat 2 estigui carregat. @@ -727,54 +676,6 @@ Desactiva Chat 2 a /xlplugins i torna a activar Hellion Chat. - - General - - - Idioma, entrada, àudio i rendiment. - - - Aparença - - - Opacitat de la finestra, fonts, moviment - - - Themes - - - Tria un theme o importa el teu propi - - - Finestra - - - Quan la finestra és visible i si es pot moure. - - - Xat - - - Tells, previsualització, comportament dels missatges i emotes. - - - Pestanyes - - - Crea i configura pestanyes de xat personalitzades. - - - Base de dades - - - Emmagatzematge, migració, neteja de dades antigues - - - Quant a - - - Extensions, versió, informació del projecte, traductors i changelog. - Themes @@ -817,9 +718,6 @@ Dades i privadesa - - Filtre de privadesa, retenció, neteja, exportació i estadístiques de la base de dades. - Theme @@ -838,9 +736,6 @@ Avançat (Maj+clic per obrir) - - Hellion Chat 1.2.1 ha reorganitzat el menú de configuració i ha eliminat l'antiga opció "Substitueix l'estil" (substituïda pel sistema de themes des de la versió 1.1.0). La resta de la configuració no ha canviat. La transparència de la finestra s'ha migrat a "Theme & Layout". Una còpia de seguretat de la configuració anterior es troba a pluginConfigs/HellionChat.json.pre-v16-backup al costat del HellionChat.json actiu. - Les integracions de plugins permeten que HellionChat funcioni conjuntament amb altres plugins de Dalamud instal·lats. Cada integració detecta automàticament el seu objectiu i es desactiva silenciosament quan el plugin objectiu no és present. diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index 8e5a5bd..d2943ec 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -63,18 +63,9 @@ Záchranná síť pro ChatTypes přidané budoucími patchemi FFXIV, které plugin ještě nezná. Výchozí stav je VYPNUTO (minimalizace dat). Zapni, pokud chceš, aby se budoucí kanály také plně logovaly. - - Použít filtr na existující databázi - Filtr soukromí ovlivňuje jen nové zprávy. Čištění níže ti umožní zpětně odstranit již uložené zprávy, které neodpovídají tvé uložené whitelistě. - - Čištění používá tvoji ULOŽENOU whitelistu (Plugin.Config), ne neuložené změny výše. Klikni nejdřív na Uložit, chceš-li použít aktuální změny. - - - Ruční spuštění používá tvoji ULOŽENOU zásadu uchovávání, ne hodnoty posuvníků výše. Klikni nejdřív na Uložit, chceš-li použít aktuální změny. - Náhled je zastaralý: tvoje whitelist se od posledního obnovení změnila. Klikni na Obnovit a přepočítej. @@ -159,9 +150,6 @@ Použít uchovávání nyní - - Ctrl+Shift: Spustí čištění uchovávání okamžitě podle ULOŽENÉ zásady. Nejdřív ulož změny. - Čištění uchovávání běží na pozadí… @@ -273,9 +261,6 @@ Vzhled - - Načíst předchozí relaci při spuštění - Použít filtry na zprávy z předchozích relací @@ -318,9 +303,6 @@ Nastavení → Hellion Chat pro pozdější doladění - - Export (GDPR Art. 15 — Právo na přístup) - Exportuj uložené zprávy jako Markdown, JSON nebo CSV. Umožňuje ti splnit žádost o přístup od osoby, jejíž zprávy uchováváš, nebo vzít s sebou vlastní historii. @@ -560,12 +542,6 @@ - - Nastavení bylo přestrukturováno - - - Hellion Chat 0.5.0 přestrukturoval nastavení do tematických záložek. Tvoje databáze chatu a historie zpráv zůstávají beze změny. Nastavení bylo resetováno na výchozí hodnoty. Chceš-li znovu vybrat profil soukromí, tlačítko Znovu otevřít je v záložce Soukromí. Záloha předchozí konfigurace se nachází v HellionChat.json.pre-v10-backup vedle aktivního konfiguračního souboru. - @@ -622,15 +598,6 @@ - - Úložiště - - - Přehled - - - Údržba - @@ -700,24 +667,6 @@ Přesune okno chatu a všechna aktivní pop-out okna zpět do levého horního rohu primárního monitoru. Užitečné, když se okno po změně rozvržení displeje ocitlo mimo viditelnou oblast (monitor odpojen, změněné rozlišení). Plugin také jednou za relaci provádí automatickou kontrolu hranic. Toto tlačítko je ruční záchranný východ, pokud přesto něco zůstane nedostupné. - - Novinka ve v0.6.0: Teď můžeš psát přímo v pop-outech. Zapni hlavní přepínač v nastavení okna. - - - Rozumím - - - Otevřít nastavení okna - - - Libovolnou chat záložku můžeš otevřít jako vlastní okno. Klikni na ikonu okna vpravo nahoře nebo pravým tlačítkem klikni na záložku. Novinka ve v0.6.1: vstup v pop-outu je ve výchozím nastavení aktivní (lze vypnout v Nastavení → Okno). - - - Rozumím - - - Otevřít nastavení - Hellion Chat se nemůže spustit, dokud je načten Chat 2. @@ -727,54 +676,6 @@ Deaktivuj Chat 2 v /xlplugins a poté znovu aktivuj Hellion Chat. - - Obecné - - - Jazyk, vstup, zvuk a výkon. - - - Vzhled - - - Průhlednost okna, písma, animace - - - Themes - - - Vyber theme nebo importuj vlastní - - - Okno - - - Kdy je okno viditelné a zda se dá přesouvat. - - - Chat - - - Telly, náhled, chování zpráv a emoty. - - - Záložky - - - Vytvárej a konfiguruj vlastní chat záložky. - - - Databáze - - - Úložiště, migrace, staré čištění - - - O pluginu - - - Rozšíření, verze, informace o projektu, překladatelé a changelog. - Themes @@ -817,9 +718,6 @@ Data a soukromí - - Filtr soukromí, uchovávání, čištění, export a statistiky databáze. - Theme @@ -838,9 +736,6 @@ Pokročilé (Shift+klik pro otevření) - - Hellion Chat 1.2.1 přeorganizoval nabídku nastavení a odstranil starou možnost „Override style" (nahrazenou systémem themes od verze 1.1.0). Zbývající nastavení zůstávají nezměněna. Průhlednost okna byla přesunuta do „Theme & Layout". Záloha předchozí konfigurace se nachází v pluginConfigs/HellionChat.json.pre-v16-backup vedle aktivního HellionChat.json. - Integrace pluginů umožňují HellionChat spolupracovat s ostatními nainstalovanými Dalamud pluginy. Každá integrace automaticky detekuje svůj cíl a tiše se deaktivuje, když cílový plugin chybí. diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index ed30752..67f9078 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -63,18 +63,9 @@ Sikkerhedsnet for ChatTypes tilføjet af fremtidige FFXIV-opdateringer, som plugin'et endnu ikke kender. Standard er FRA (dataminimering). Aktivér hvis du også vil have fremtidige kanaler logget fuldt ud. - - Anvend filter på eksisterende database - Privatlivsfilteret påvirker kun nye beskeder. Oprydningen nedenfor lader dig bagudrettet fjerne allerede gemte beskeder, der ikke matcher din gemte hvidliste. - - Oprydningen bruger din GEMTE hvidliste (Plugin.Config), ikke ugemte ændringer ovenfor. Klik Gem først, hvis du vil anvende dine aktuelle ændringer. - - - Den manuelle kørsel bruger din GEMTE opbevaringspolitik, ikke skyderknapværdierne ovenfor. Klik Gem først, hvis kørslen skal anvende dine aktuelle ændringer. - Forhåndsvisning er forældet: din hvidliste er ændret siden sidste opdatering. Klik Opdater for at genberegne. @@ -159,9 +150,6 @@ Anvend opbevaring nu - - Ctrl+Shift: Kører oprydningen med den GEMTE politik med det samme. Gem dine ændringer først. - Opbevaringsoprydning kører i baggrunden… @@ -273,9 +261,6 @@ Udseende - - Indlæs forrige session ved opstart - Anvend filtre på beskeder fra tidligere sessioner @@ -318,9 +303,6 @@ Indstillinger → Hellion Chat for at finjustere senere - - Eksport (GDPR Art. 15 — Ret til indsigt) - Eksportér gemte beskeder som Markdown, JSON eller CSV. Det lader dig opfylde en indsigtsanmodning fra en person, hvis beskeder du har gemt, eller tage din egen historik med. @@ -560,12 +542,6 @@ - - Indstillinger omstruktureret - - - Hellion Chat 0.5.0 har omstruktureret indstillingerne i tematiske faner. Din chat-database og beskedhistorik er uændret. Indstillinger er nulstillet til standarder. Hvis du vil vælge din privatlivsprofil på ny, finder du Genåbn-knappen under fanen Privatliv. En sikkerhedskopi af den forrige konfiguration ligger ved siden af den aktive konfigurationsfil som HellionChat.json.pre-v10-backup. - @@ -622,15 +598,6 @@ - - Lagring - - - Oversigt - - - Vedligeholdelse - @@ -700,24 +667,6 @@ Flytter chat-vinduet og alle aktive pop-outs tilbage til øverste venstre hjørne af den primære skærm. Nyttigt når et vindue er havnet uden for det synlige område efter en skærmlayoutændring (skærm frakoblet, opløsning ændret). Plugin'et udfører også et automatisk bounds-check én gang pr. session. Denne knap er den manuelle nødudgang, hvis noget alligevel ender utilgængeligt. - - Nyt i v0.6.0: Du kan nu skrive direkte i pop-outs. Aktivér masterknappen under Vinduesindstillinger. - - - Forstået - - - Åbn vinduesindstillinger - - - Du kan åbne enhver chat-tab som sit eget vindue. Klik på vindues-ikonet øverst til højre, eller højreklik på tab'en. Nyt i v0.6.1: pop-out-input er aktiv som standard (kan deaktiveres under Indstillinger → Vindue). - - - Forstået - - - Åbn indstillinger - Hellion Chat kan ikke starte mens Chat 2 er indlæst. @@ -727,54 +676,6 @@ Deaktivér Chat 2 i /xlplugins, og aktivér derefter Hellion Chat igen. - - Generelt - - - Sprog, input, lyd og ydeevne. - - - Udseende - - - Vindues-opacitet, skrifttyper, bevægelse - - - Themes - - - Vælg et theme eller importér dit eget - - - Vindue - - - Hvornår vinduet er synligt, og om det kan flyttes. - - - Chat - - - Tells, forhåndsvisning, beskedadfærd og emotes. - - - Tabs - - - Opret og konfigurér brugerdefinerede chat-tabs. - - - Database - - - Lagring, migration, gammel oprydning - - - Om - - - Udvidelser, version, projektoplysninger, oversættere og changelog. - Themes @@ -817,9 +718,6 @@ Data og privatliv - - Privatlivsfilter, opbevaring, oprydning, eksport og databasestatistik. - Theme @@ -838,9 +736,6 @@ Avanceret (Shift+klik for at åbne) - - Hellion Chat 1.2.1 har omorganiseret indstillingsmenuen og fjernet den gamle "Tilsidesæt stil"-indstilling (erstattet af theme-systemet fra 1.1.0). Dine øvrige indstillinger er uændret. Vinduesgennemsigtighed er migreret til "Theme & Layout". En sikkerhedskopi af den forrige konfiguration ligger ved siden af den aktive HellionChat.json som pluginConfigs/HellionChat.json.pre-v16-backup. - Plugin-integrationer lader HellionChat arbejde sammen med andre installerede Dalamud-plugins. Hver integration registrerer automatisk sit mål og deaktiverer sig stille, når målplugin'et mangler. diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index 6129f72..d59e4fc 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -63,18 +63,9 @@ Sicherheitsnetz für ChatTypes, die durch zukünftige FFXIV-Patches dazukommen und dem Plugin noch nicht bekannt sind. Standard ist AUS (Datensparsamkeit). Aktivieren, wenn du auch zukünftige Kanäle vollständig mitloggen willst. - - Filter auf bestehende Datenbank anwenden - Der Datenschutz-Filter wirkt nur auf neue Nachrichten. Über das Aufräumen unten kannst du bereits gespeicherte Nachrichten nachträglich entfernen, die nicht zu deiner gespeicherten Whitelist passen. - - Das Aufräumen nutzt deine GESPEICHERTE Whitelist (Plugin.Config), nicht ungespeicherte Änderungen oben. Klicke zuerst Speichern, wenn du deine aktuellen Änderungen anwenden willst. - - - Der manuelle Lauf nutzt deine GESPEICHERTE Retention-Policy, nicht die Slider-Werte oben. Klicke zuerst Speichern, wenn der Lauf deine aktuellen Änderungen anwenden soll. - Vorschau veraltet, deine Whitelist hat sich seit dem letzten Aktualisieren geändert. Klicke Aktualisieren, um neu zu berechnen. @@ -159,9 +150,6 @@ Aufbewahrung jetzt anwenden - - Strg+Umschalt: Führt die Aufbewahrungs-Bereinigung sofort mit der GESPEICHERTEN Vorgabe aus. Speichere deine Änderungen vorher. - Aufbewahrungs-Bereinigung läuft im Hintergrund… @@ -273,9 +261,6 @@ Optik - - Vorherige Session beim Start laden - Filter auch auf alte Messages anwenden @@ -318,9 +303,6 @@ Einstellungen → Hellion Chat zum späteren Anpassen - - Export (DSGVO Art. 15 — Auskunftsrecht) - Gespeicherte Nachrichten als Markdown, JSON oder CSV exportieren. Damit kannst du einer Auskunftsanfrage einer Person nachkommen, deren Nachrichten du gespeichert hast, oder deine eigene Historie mitnehmen. @@ -560,12 +542,6 @@ - - Settings umstrukturiert - - - Hellion Chat 0.5.0 hat die Settings in thematische Tabs umstrukturiert. Deine Chat-Datenbank und dein Nachrichtenverlauf bleiben unverändert. Settings wurden auf Defaults zurückgesetzt. Falls du das Privacy-Profil neu wählen willst, findest du den Reopen-Button im Datenschutz-Tab. Ein Backup der vorherigen Config liegt unter HellionChat.json.pre-v10-backup neben der aktiven Config-Datei. - @@ -622,15 +598,6 @@ - - Speicherung - - - Übersicht - - - Wartung - @@ -700,24 +667,6 @@ Holt das Chat-Fenster und alle aktiven Pop-Outs zurück in die linke obere Ecke des Hauptmonitors. Hilfreich wenn ein Fenster nach einem Display-Layout-Wechsel außerhalb des sichtbaren Bereichs gelandet ist (Monitor abgezogen, Auflösung geändert). Das Plugin macht außerdem einmal pro Session einen automatischen Bounds-Check, dieser Button ist der manuelle Notausgang falls trotzdem etwas unerreichbar bleibt. - - Neu in v0.6.0: Du kannst jetzt direkt im Pop-Out tippen. Master-Switch in den Fenster-Settings aktivieren. - - - Verstanden - - - Fenster-Settings öffnen - - - Du kannst jeden Chat-Tab als eigenes Fenster öffnen. Klicke auf das Fenster-Symbol oben rechts oder rechtsklicke den Tab. Neu in v0.6.1: die Pop-Out-Eingabe ist standardmäßig aktiv (abschaltbar unter Einstellungen → Fenster). - - - Verstanden - - - Einstellungen öffnen - Hellion Chat kann nicht starten, solange Chat 2 geladen ist. @@ -727,54 +676,6 @@ Chat 2 in /xlplugins deaktivieren, danach Hellion Chat erneut aktivieren. - - Allgemein - - - Sprache, Eingabe, Audio und Performance. - - - Erscheinungsbild - - - Fensterdeckkraft, Schriften, Bewegung - - - Themes - - - Theme wählen oder eigenes importieren - - - Fenster - - - Wann das Fenster sichtbar ist und ob es sich bewegen lässt. - - - Chat - - - Flüsternachrichten, Vorschau, Nachrichten-Verhalten und Emotes. - - - Tabs - - - Eigene Chat-Tabs anlegen und konfigurieren. - - - Datenbank - - - Speicher, Migration, alte Bereinigung - - - Über - - - Erweiterungen, Version, Projektinformationen, Übersetzer und Changelog. - Themes @@ -817,9 +718,6 @@ Daten & Privatsphäre - - Privatsphäre-Filter, Aufbewahrung, Aufräumen, Export und Datenbank-Statistiken. - Theme @@ -838,9 +736,6 @@ Erweitert (Shift+Klick zum Öffnen) - - Hellion Chat 1.2.1 hat das Settings-Menü neu sortiert und die alte „Stilüberschreiben"-Option entfernt (überholt durch das Theme-System aus 1.1.0). Deine restlichen Einstellungen bleiben unverändert. Die Fenster-Transparenz ist nach „Theme & Layout" migriert. Ein Backup der vorherigen Config liegt unter pluginConfigs/HellionChat.json.pre-v16-backup neben der aktiven HellionChat.json. - Plugin-Integrationen lassen HellionChat mit anderen installierten Dalamud-Plugins zusammenarbeiten. Jede Integration erkennt ihr Ziel automatisch und deaktiviert sich still, wenn das Ziel-Plugin fehlt. diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index d0d4ee7..8241688 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -63,18 +63,9 @@ Δίχτυ ασφαλείας για ChatTypes που θα προστεθούν από μελλοντικά patches του FFXIV και δεν τα γνωρίζει ακόμη το plugin. Η προεπιλογή είναι ΑΠΕΝΕΡΓΟ (ελαχιστοποίηση δεδομένων). Ενεργοποίησε αν θέλεις να καταγράφονται πλήρως και τα μελλοντικά κανάλια. - - Εφαρμογή φίλτρου στην υπάρχουσα βάση δεδομένων - Το φίλτρο απορρήτου επηρεάζει μόνο νέα μηνύματα. Το παρακάτω cleanup σου επιτρέπει να αφαιρέσεις αναδρομικά ήδη αποθηκευμένα μηνύματα που δεν ταιριάζουν με την αποθηκευμένη whitelist σου. - - Το cleanup χρησιμοποιεί την ΑΠΟΘΗΚΕΥΜΕΝΗ whitelist σου (Plugin.Config), όχι ανεπίσημες αλλαγές παραπάνω. Κάνε πρώτα Αποθήκευση αν θέλεις να εφαρμοστούν οι τρέχουσες αλλαγές σου. - - - Η χειροκίνητη εκτέλεση χρησιμοποιεί την ΑΠΟΘΗΚΕΥΜΕΝΗ πολιτική διατήρησης, όχι τις τιμές του slider παραπάνω. Κάνε πρώτα Αποθήκευση αν θέλεις να εφαρμοστούν οι τρέχουσες αλλαγές σου. - Η προεπισκόπηση είναι παλιά: η whitelist σου άλλαξε από την τελευταία ανανέωση. Κάνε κλικ στο Ανανέωση για επανυπολογισμό. @@ -159,9 +150,6 @@ Εφαρμογή διατήρησης τώρα - - Ctrl+Shift: Εκτελεί αμέσως το cleanup διατήρησης με την ΑΠΟΘΗΚΕΥΜΕΝΗ πολιτική. Αποθήκευσε πρώτα τις αλλαγές σου. - Το cleanup διατήρησης εκτελείται στο παρασκήνιο… @@ -273,9 +261,6 @@ Εμφάνιση - - Φόρτωση προηγούμενης συνεδρίας κατά την εκκίνηση - Εφαρμογή φίλτρων σε μηνύματα από προηγούμενες συνεδρίες @@ -318,9 +303,6 @@ Ρυθμίσεις → Hellion Chat για προσαρμογή αργότερα - - Εξαγωγή (GDPR Art. 15 — Δικαίωμα πρόσβασης) - Εξαγωγή αποθηκευμένων μηνυμάτων ως Markdown, JSON ή CSV. Έτσι μπορείς να ανταποκριθείς σε αίτημα πρόσβασης από κάποιον του οποίου τα μηνύματα έχεις αποθηκεύσει, ή να πάρεις μαζί σου το δικό σου ιστορικό. @@ -560,12 +542,6 @@ - - Οι ρυθμίσεις αναδιαρθρώθηκαν - - - Το Hellion Chat 0.5.0 αναδιάρθρωσε τις ρυθμίσεις σε θεματικές καρτέλες. Η βάση δεδομένων chat και το ιστορικό μηνυμάτων σου παραμένουν αναλλοίωτα. Οι ρυθμίσεις έχουν επαναφερθεί στις προεπιλογές. Αν θέλεις να επιλέξεις ξανά το προφίλ απορρήτου σου, το κουμπί Επαναφορά βρίσκεται στην καρτέλα Απόρρητο. Αντίγραφο ασφαλείας της προηγούμενης διαμόρφωσης βρίσκεται στο HellionChat.json.pre-v10-backup δίπλα στο ενεργό αρχείο διαμόρφωσης. - @@ -622,15 +598,6 @@ - - Αποθήκευση - - - Επισκόπηση - - - Συντήρηση - @@ -700,24 +667,6 @@ Μετακινεί το παράθυρο chat και όλα τα ενεργά pop-outs πίσω στην επάνω αριστερή γωνία της κύριας οθόνης. Χρήσιμο όταν ένα παράθυρο βρεθεί εκτός ορατής περιοχής μετά από αλλαγή διάταξης οθόνης (αποσύνδεση οθόνης, αλλαγή ανάλυσης). Το plugin εκτελεί επίσης αυτόματο έλεγχο ορίων μία φορά ανά συνεδρία. Αυτό το κουμπί είναι η χειροκίνητη διαφυγή αν κάτι παραμένει απρόσιτο. - - Νέο στην v0.6.0: Μπορείς πλέον να πληκτρολογείς απευθείας σε pop-outs. Ενεργοποίησε τον κεντρικό διακόπτη στις ρυθμίσεις Παραθύρου. - - - Κατάλαβα - - - Άνοιγμα ρυθμίσεων παραθύρου - - - Μπορείς να ανοίξεις οποιαδήποτε καρτέλα chat ως δικό της παράθυρο. Κάνε κλικ στο εικονίδιο παραθύρου επάνω δεξιά ή δεξί κλικ στην καρτέλα. Νέο στην v0.6.1: η εισαγωγή σε pop-out είναι ενεργή εξ ορισμού (μπορεί να απενεργοποιηθεί στις Ρυθμίσεις → Παράθυρο). - - - Κατάλαβα - - - Άνοιγμα ρυθμίσεων - Το Hellion Chat δεν μπορεί να ξεκινήσει ενώ το Chat 2 είναι φορτωμένο. @@ -727,54 +676,6 @@ Απενεργοποίησε το Chat 2 στο /xlplugins και μετά ενεργοποίησε ξανά το Hellion Chat. - - Γενικά - - - Γλώσσα, εισαγωγή, ήχος και απόδοση. - - - Εμφάνιση - - - Αδιαφάνεια παραθύρου, γραμματοσειρές, κίνηση - - - Themes - - - Επίλεξε theme ή εισαγωγή δικού σου - - - Παράθυρο - - - Πότε το παράθυρο είναι ορατό και αν μπορεί να μετακινηθεί. - - - Chat - - - Tells, προεπισκόπηση, συμπεριφορά μηνυμάτων και emotes. - - - Καρτέλες - - - Δημιουργία και ρύθμιση προσαρμοσμένων καρτελών chat. - - - Βάση δεδομένων - - - Αποθήκευση, μετεγκατάσταση, παλαιό cleanup - - - Σχετικά - - - Επεκτάσεις, έκδοση, πληροφορίες έργου, μεταφραστές και changelog. - Themes @@ -817,9 +718,6 @@ Δεδομένα και απόρρητο - - Φίλτρο απορρήτου, διατήρηση, cleanup, εξαγωγή και στατιστικά βάσης δεδομένων. - Theme @@ -838,9 +736,6 @@ Για προχωρημένους (Shift+κλικ για άνοιγμα) - - Το Hellion Chat 1.2.1 αναδιοργάνωσε το μενού ρυθμίσεων και αφαίρεσε την παλιά επιλογή "Override style" (αντικαταστάθηκε από το σύστημα themes από την έκδοση 1.1.0). Οι υπόλοιπες ρυθμίσεις σου παραμένουν αναλλοίωτες. Η διαφάνεια παραθύρου έχει μεταφερθεί στο "Theme & Layout". Αντίγραφο ασφαλείας της προηγούμενης διαμόρφωσης βρίσκεται στο pluginConfigs/HellionChat.json.pre-v16-backup δίπλα στο ενεργό HellionChat.json. - Οι ενσωματώσεις plugin επιτρέπουν στο HellionChat να συνεργάζεται με άλλα εγκατεστημένα Dalamud plugins. Κάθε ενσωμάτωση ανιχνεύει αυτόματα τον στόχο της και απενεργοποιείται σιωπηλά όταν το plugin-στόχος λείπει. diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index dd558b0..b057d75 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -63,18 +63,9 @@ Red de seguridad para ChatTypes añadidos por futuros parches de FFXIV que el plugin aún no conoce. El valor predeterminado es DESACTIVADO (minimización de datos). Actívalo si quieres que los canales futuros también se registren completamente. - - Aplicar filtro a la base de datos existente - El filtro de privacidad solo afecta a los mensajes nuevos. La limpieza de abajo te permite eliminar retroactivamente los mensajes ya almacenados que no coincidan con tu lista blanca guardada. - - La limpieza usa tu lista blanca GUARDADA (Plugin.Config), no los cambios sin guardar de arriba. Haz clic en Guardar primero si quieres que se apliquen tus cambios actuales. - - - La ejecución manual usa tu política de retención GUARDADA, no los valores del deslizador de arriba. Haz clic en Guardar primero si quieres que la ejecución aplique tus cambios actuales. - La vista previa está desactualizada: tu lista blanca ha cambiado desde la última actualización. Haz clic en Actualizar para recalcular. @@ -159,9 +150,6 @@ Aplicar retención ahora - - Ctrl+Mayús: Ejecuta la limpieza de retención de inmediato usando la política GUARDADA. Guarda los cambios primero. - Limpieza de retención ejecutándose en segundo plano… @@ -273,9 +261,6 @@ Visual - - Cargar sesión anterior al iniciar - Aplicar filtros a mensajes de sesiones anteriores @@ -318,9 +303,6 @@ Ajustes → Hellion Chat para personalizar más tarde - - Exportar (GDPR Art. 15 — Derecho de acceso) - Exporta los mensajes almacenados como Markdown, JSON o CSV. Esto te permite atender una solicitud de acceso de una persona cuyos mensajes hayas almacenado, o llevarte tu propio historial. @@ -560,12 +542,6 @@ - - Ajustes reestructurados - - - Hellion Chat 0.5.0 ha reestructurado los ajustes en pestañas temáticas. Tu base de datos de chat y tu historial de mensajes no han cambiado. Los ajustes se han restablecido a los valores predeterminados. Si quieres volver a seleccionar tu perfil de privacidad, el botón Reabrir está en la pestaña Privacidad. Una copia de seguridad de la configuración anterior se encuentra en HellionChat.json.pre-v10-backup junto al archivo de configuración activo. - @@ -622,15 +598,6 @@ - - Almacenamiento - - - Resumen - - - Mantenimiento - @@ -700,24 +667,6 @@ Mueve la ventana de chat y todas las ventanas emergentes activas de vuelta a la esquina superior izquierda del monitor principal. Útil cuando una ventana ha quedado fuera del área visible tras un cambio de configuración de pantalla (monitor desconectado, resolución cambiada). El plugin también realiza una comprobación automática de límites una vez por sesión; este botón es la salida de emergencia manual si algo sigue siendo inaccesible. - - Novedad en v0.6.0: ahora puedes escribir directamente en las ventanas emergentes. Activa el interruptor maestro en los ajustes de Ventana. - - - Entendido - - - Abrir ajustes de ventana - - - Puedes abrir cualquier pestaña de chat como su propia ventana. Haz clic en el icono de ventana en la parte superior derecha o haz clic derecho en la pestaña. Novedad en v0.6.1: la entrada en ventanas emergentes está activa por defecto (se puede desactivar en Ajustes → Ventana). - - - Entendido - - - Abrir ajustes - Hellion Chat no puede iniciarse mientras Chat 2 está cargado. @@ -727,54 +676,6 @@ Desactiva Chat 2 en /xlplugins y luego vuelve a activar Hellion Chat. - - General - - - Idioma, entrada, audio y rendimiento. - - - Apariencia - - - Opacidad de ventana, fuentes, movimiento - - - Themes - - - Elige un theme o importa el tuyo - - - Ventana - - - Cuándo es visible la ventana y si se puede mover. - - - Chat - - - Tells, vista previa, comportamiento de mensajes y emotes. - - - Pestañas - - - Crea y configura pestañas de chat personalizadas. - - - Base de datos - - - Almacenamiento, migración, limpieza de datos antiguos - - - Acerca de - - - Extensiones, versión, información del proyecto, traductores y changelog. - Themes @@ -817,9 +718,6 @@ Datos y privacidad - - Filtro de privacidad, retención, limpieza, exportación y estadísticas de base de datos. - Theme @@ -838,9 +736,6 @@ Avanzado (Mayús+clic para abrir) - - Hellion Chat 1.2.1 ha reorganizado el menú de ajustes y eliminado la antigua opción "Anular estilo" (reemplazada por el sistema de themes de la versión 1.1.0). El resto de tus ajustes no ha cambiado. La transparencia de ventana se ha migrado a "Theme & Layout". Una copia de seguridad de la configuración anterior se encuentra en pluginConfigs/HellionChat.json.pre-v16-backup junto al HellionChat.json activo. - Las integraciones de plugins permiten que HellionChat trabaje junto con otros plugins de Dalamud instalados. Cada integración detecta automáticamente su objetivo y se desactiva silenciosamente cuando el plugin objetivo no está presente. diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index f9229a8..c770bf9 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -63,18 +63,9 @@ Turvaverkko ChatType-tyypeille, jotka tulevat tulevissa FFXIV-päivityksissä eikä lisäosa vielä tunne niitä. Oletuksena POIS (tietojen minimointi). Ota käyttöön, jos haluat myös tulevat kanavat lokitettavan kokonaan. - - Käytä suodatinta olemassa olevaan tietokantaan - Tietosuojasuodatin vaikuttaa vain uusiin viesteihin. Alla oleva siivous antaa sinulle mahdollisuuden poistaa jälkikäteen jo tallennetut viestit, jotka eivät vastaa tallennettua sallittujen listaasi. - - Siivous käyttää TALLENNETTUA sallittujen listaasi (Plugin.Config), ei yllä olevia tallentamattomia muutoksia. Napsauta Tallenna ensin, jos haluat muutostesi tulevan voimaan. - - - Manuaalinen ajo käyttää TALLENNETTUA säilytyskäytäntöäsi, ei yllä olevia liukusäätimen arvoja. Napsauta Tallenna ensin, jos haluat ajon soveltavan nykyiset muutoksesi. - Esikatselu on vanhentunut: sallittujen listasi on muuttunut viimeisen päivityksen jälkeen. Napsauta Päivitä laskeaksesi uudelleen. @@ -159,9 +150,6 @@ Käytä säilytystä nyt - - Ctrl+Shift: Ajaa säilytykseen liittyvän siivouksen heti TALLENNETULLA käytännöllä. Tallenna muutoksesi ensin. - Säilytykseen liittyvä siivous käynnissä taustalla… @@ -273,9 +261,6 @@ Ulkoasu - - Lataa edellinen istunto käynnistyksen yhteydessä - Käytä suodattimia aiempien istuntojen viesteihin @@ -318,9 +303,6 @@ Asetukset → Hellion Chat hienosäätöä varten myöhemmin - - Vie (GDPR Art. 15 — Tarkastusoikeus) - Vie tallennetut viestit Markdown-, JSON- tai CSV-muodossa. Näin voit täyttää tietopyynnön henkilöltä, jonka viestejä olet tallentanut, tai ottaa oman historiasi mukaasi. @@ -560,12 +542,6 @@ - - Asetukset uudelleenjärjestetty - - - Hellion Chat 0.5.0 on uudelleenjärjestänyt asetukset temaattisiin välilehtiin. Chat-tietokantasi ja viestihistoriasi pysyvät muuttumattomina. Asetukset on nollattu oletuksiin. Jos haluat valita tietosuojaprofiilin uudelleen, Avaa uudelleen -painike on Tietosuoja-välilehdellä. Varmuuskopio aiemmasta asetustiedostosta löytyy nimellä HellionChat.json.pre-v10-backup aktiivisen asetustiedoston vierestä. - @@ -622,15 +598,6 @@ - - Tallennus - - - Yleiskatsaus - - - Ylläpito - @@ -700,24 +667,6 @@ Siirtää chat-ikkunan ja kaikki aktiiviset irrotetut ikkunat takaisin ensisijaisen näytön vasempaan yläkulmaan. Hyödyllinen, kun ikkuna on päätynyt näkyvän alueen ulkopuolelle näyttöasettelumuutoksen jälkeen (näyttö irrotettu, tarkkuus muutettu). Lisäosa suorittaa myös automaattisen rajatarkistuksen kerran istunnon aikana; tämä painike on manuaalinen pelastusreitti, jos jokin silti päätyy ulottumattomiin. - - Uutta versiossa v0.6.0: voit nyt kirjoittaa suoraan irrotettuissa ikkunoissa. Ota pääkytkin käyttöön Ikkuna-asetuksissa. - - - Selvä - - - Avaa ikkunan asetukset - - - Voit avata minkä tahansa chat-välilehden omana ikkunanaan. Napsauta ikkunakuvaketta oikeassa yläkulmassa tai hiiren oikealla painikkeella välilehteä. Uutta versiossa v0.6.1: irrotetun ikkunan syöte on oletuksena aktiivinen (voidaan poistaa käytöstä kohdassa Asetukset → Ikkuna). - - - Selvä - - - Avaa asetukset - Hellion Chat ei voi käynnistyä, kun Chat 2 on ladattuna. @@ -727,54 +676,6 @@ Poista Chat 2 käytöstä /xlplugins-kohdassa, ota sitten Hellion Chat uudelleen käyttöön. - - Yleiset - - - Kieli, syöte, ääni ja suorituskyky. - - - Ulkoasu - - - Ikkunan läpinäkyvyys, fontit, liike - - - Teemat - - - Valitse teema tai tuo oma - - - Ikkuna - - - Milloin ikkuna on näkyvissä ja voiko sitä siirtää. - - - Chat - - - Tellit, esikatselu, viestien toiminta ja emotet. - - - Välilehdet - - - Luo ja määritä mukautettuja chat-välilehtiä. - - - Tietokanta - - - Tallennus, siirto, vanhan datan siivous - - - Tietoja - - - Laajennukset, versio, projektin tiedot, kääntäjät ja changelog. - Teemat @@ -817,9 +718,6 @@ Tiedot ja yksityisyys - - Yksityisyyssuodatin, säilytys, siivous, vienti ja tietokantatilastot. - Teema @@ -838,9 +736,6 @@ Lisäasetukset (Shift+napsauta avataksesi) - - Hellion Chat 1.2.1 on uudelleenjärjestänyt asetusvalikon ja poistanut vanhan "Ohita tyyli" -vaihtoehdon (korvattu versiossa 1.1.0 esitellyllä teemajärjestelmällä). Muut asetuksesi pysyvät muuttumattomina. Ikkunan läpinäkyvyys on siirretty kohtaan "Teema & asettelu". Varmuuskopio aiemmasta asetustiedostosta löytyy nimellä pluginConfigs/HellionChat.json.pre-v16-backup aktiivisen HellionChat.json-tiedoston vierestä. - Lisäosaintegraatiot mahdollistavat HellionChatin yhteistyön muiden asennettujen Dalamud-lisäosien kanssa. Jokainen integraatio tunnistaa kohteensa automaattisesti ja poistaa itsensä hiljaisesti käytöstä, kun kohdalisäosa puuttuu. diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index 2b27927..361a5c2 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -63,18 +63,9 @@ Filet de sécurité pour les ChatTypes ajoutés par de futurs correctifs de FFXIV que le plugin ne connaît pas encore. Désactivé par défaut (minimisation des données). Activez cette option si vous souhaitez que les futurs canaux soient également enregistrés intégralement. - - Appliquer le filtre à la base de données existante - Le filtre de confidentialité n'affecte que les nouveaux messages. Le nettoyage ci-dessous vous permet de supprimer rétroactivement les messages déjà enregistrés qui ne correspondent pas à votre liste blanche enregistrée. - - Le nettoyage utilise votre liste blanche ENREGISTRÉE (Plugin.Config), pas les modifications non enregistrées ci-dessus. Cliquez d'abord sur Enregistrer si vous souhaitez que vos modifications actuelles soient appliquées. - - - L'exécution manuelle utilise votre politique de conservation ENREGISTRÉE, pas les valeurs du curseur ci-dessus. Cliquez d'abord sur Enregistrer si vous souhaitez que l'exécution applique vos modifications actuelles. - L'aperçu est obsolète : votre liste blanche a été modifiée depuis le dernier rafraîchissement. Cliquez sur Rafraîchir pour recalculer. @@ -159,9 +150,6 @@ Appliquer la conservation maintenant - - Ctrl+Maj : exécute immédiatement le nettoyage de conservation en utilisant la politique ENREGISTRÉE. Enregistrez d'abord vos modifications. - Nettoyage de conservation en cours en arrière-plan… @@ -273,9 +261,6 @@ Visuel - - Charger la session précédente au démarrage - Appliquer les filtres aux messages des sessions précédentes @@ -318,9 +303,6 @@ Paramètres → Hellion Chat pour affiner plus tard - - Exportation (RGPD art. 15 — Droit d'accès) - Exportez les messages enregistrés en Markdown, JSON ou CSV. Cela vous permet de répondre à une demande d'accès émanant d'une personne dont vous avez enregistré les messages, ou d'emporter votre propre historique. @@ -560,12 +542,6 @@ - - Paramètres restructurés - - - Hellion Chat 0.5.0 a restructuré les paramètres en onglets thématiques. Votre base de données de chat et votre historique de messages restent inchangés. Les paramètres ont été réinitialisés à leurs valeurs par défaut. Si vous souhaitez resélectionner votre profil de confidentialité, le bouton Rouvrir se trouve dans l'onglet Confidentialité. Une sauvegarde de la configuration précédente est disponible dans HellionChat.json.pre-v10-backup, à côté du fichier de configuration actif. - @@ -622,15 +598,6 @@ - - Stockage - - - Visualiseur - - - Maintenance - @@ -700,24 +667,6 @@ Ramène la fenêtre de chat et toutes les fenêtres détachées actives dans le coin supérieur gauche de l'écran principal. Utile lorsqu'une fenêtre s'est retrouvée hors de la zone visible après un changement de configuration d'affichage (déconnexion d'un moniteur, changement de résolution). Le plugin effectue également une vérification automatique des limites une fois par session ; ce bouton est l'issue de secours manuelle si quelque chose reste malgré tout inaccessible. - - Nouveauté de la v0.6.0 : vous pouvez désormais saisir directement dans les fenêtres détachées. Activez l'interrupteur principal dans les paramètres de la fenêtre. - - - Compris - - - Ouvrir les paramètres de la fenêtre - - - Vous pouvez ouvrir n'importe quel onglet de chat comme sa propre fenêtre. Cliquez sur l'icône de fenêtre en haut à droite ou faites un clic droit sur l'onglet. Nouveauté de la v0.6.1 : la saisie dans les fenêtres détachées est active par défaut (peut être désactivée dans Paramètres → Fenêtre). - - - Compris - - - Ouvrir les paramètres - Hellion Chat ne peut pas démarrer tant que Chat 2 est chargé. @@ -727,54 +676,6 @@ Désactivez Chat 2 dans /xlplugins, puis réactivez Hellion Chat. - - Général - - - Langue, saisie, audio et performance. - - - Apparence - - - Opacité de la fenêtre, polices, animations - - - Thèmes - - - Choisissez un thème ou importez le vôtre - - - Fenêtre - - - Quand la fenêtre est visible et si elle peut être déplacée. - - - Chat - - - Messages privés, aperçu, comportement des messages et emotes. - - - Onglets - - - Créez et configurez des onglets de chat personnalisés. - - - Base de données - - - Stockage, migration, nettoyage des données historiques - - - À propos - - - Extensions, version, informations du projet, traducteurs et journal des modifications. - Thèmes @@ -817,9 +718,6 @@ Données et confidentialité - - Filtre de confidentialité, conservation, nettoyage, exportation et statistiques de la base de données. - Thème @@ -838,9 +736,6 @@ Avancé (Maj+clic pour ouvrir) - - Hellion Chat 1.2.1 a réorganisé le menu des paramètres et supprimé l'ancienne option « Remplacer le style » (remplacée par le système de thèmes de la 1.1.0). Vos autres paramètres restent inchangés. La transparence de la fenêtre a été migrée vers « Thème et mise en page ». Une sauvegarde de la configuration précédente est disponible dans pluginConfigs/HellionChat.json.pre-v16-backup, à côté du HellionChat.json actif. - Les intégrations de plugins permettent à HellionChat de fonctionner avec d'autres plugins Dalamud installés. Chaque intégration détecte automatiquement sa cible et se désactive silencieusement lorsque le plugin cible est absent. diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index c6c366e..d94f370 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -63,18 +63,9 @@ Biztonsági háló azokhoz a ChatType-okhoz, amelyeket jövőbeli FFXIV-frissítések adnak hozzá, és a plugin még nem ismer. Az alapértelmezés KI (adatminimalizálás). Kapcsold be, ha a jövőbeli csatornákat is teljes egészében naplózni szeretnéd. - - Szűrő alkalmazása a meglévő adatbázisra - Az adatvédelmi szűrő csak az új üzenetekre hat. Az alábbi takarítással utólag eltávolíthatod a már tárolt üzeneteket, amelyek nem illeszkednek a mentett fehérlistádhoz. - - A takarítás a MENTETT fehérlistádat (Plugin.Config) használja, nem a fenti nem mentett módosításokat. Először kattints a Mentés gombra, ha az aktuális változtatásokat szeretnéd alkalmazni. - - - A kézi futtatás a MENTETT megőrzési szabályzatot használja, nem a fenti csúszkák értékeit. Először kattints a Mentés gombra, ha a futtatásban az aktuális változtatásokat szeretnéd alkalmazni. - Az előnézet elavult: a fehérlistád megváltozott az utolsó frissítés óta. Kattints a Frissítés gombra az újraszámításhoz. @@ -159,9 +150,6 @@ Megőrzés alkalmazása most - - Ctrl+Shift: A megőrzési takarítást azonnal futtatja a MENTETT szabályzat alapján. Előbb mentsd el a módosításaidat. - Megőrzési takarítás fut a háttérben… @@ -273,9 +261,6 @@ Megjelenés - - Előző munkamenet betöltése indításkor - Szűrők alkalmazása korábbi munkamenetek üzeneteire @@ -318,9 +303,6 @@ Beállítások → Hellion Chat a finomhangoláshoz - - Export (GDPR Art. 15 — Hozzáférési jog) - Tárolt üzenetek exportálása Markdown, JSON vagy CSV formátumban. Ezzel teljesíthetsz egy hozzáférési kérelmet attól a személytől, akinek az üzeneteit tároltad, vagy magaddal viheted a saját előzményeidet. @@ -560,12 +542,6 @@ - - Beállítások átstrukturálva - - - A Hellion Chat 0.5.0 tematikus fülekbe rendezte a beállításokat. A chat-adatbázisod és az üzenettörténeted változatlan maradt. A beállítások visszaálltak az alapértelmezettekre. Ha újra ki szeretnéd választani az adatvédelmi profilodat, a Varázsló újra megnyitása gomb az Adatvédelem fülön található. Az előző konfiguráció biztonsági másolata HellionChat.json.pre-v10-backup néven található az aktív konfigurációs fájl mellett. - @@ -622,15 +598,6 @@ - - Tárolás - - - Áttekintés - - - Karbantartás - @@ -700,24 +667,6 @@ A chat-ablakot és az összes aktív pop-outot az elsődleges monitor bal felső sarkába helyezi vissza. Akkor hasznos, ha egy ablak a megjelenítési elrendezés megváltozása után (monitor lecsatlakoztatva, felbontás megváltozott) a látható területen kívülre kerül. A plugin munkamenetenként egyszer automatikus határellenőrzést is végez; ez a gomb a kézi menekülési útvonal, ha valami még így is elérhetetlenné válik. - - Újdonság a v0.6.0-ban: most már közvetlenül a pop-outban is gépelhetsz. Kapcsold be a főkapcsolót az Ablak beállításokban. - - - Értettem - - - Ablakbeállítások megnyitása - - - Bármely chat-fület megnyithatod saját ablakként. Kattints az ablak ikonra a jobb felső sarokban, vagy jobb klikk a fülön. Újdonság a v0.6.1-ben: a pop-out bevitel alapértelmezés szerint aktív (kikapcsolható a Beállítások → Ablak alatt). - - - Értettem - - - Beállítások megnyitása - A Hellion Chat nem indítható el, amíg a Chat 2 be van töltve. @@ -727,54 +676,6 @@ Tiltsd le a Chat 2-t a /xlplugins-ban, majd engedélyezd újra a Hellion Chat-et. - - Általános - - - Nyelv, bevitel, hang és teljesítmény. - - - Megjelenés - - - Ablak átlátszatlansága, betűtípusok, animáció - - - Témák - - - Témát választhatsz vagy importálhatod a sajátodat - - - Ablak - - - Mikor látható az ablak, és lehet-e mozgatni. - - - Chat - - - Tellek, előnézet, üzenetviselkedés és emote-ok. - - - Fülek - - - Egyéni chat-fülek létrehozása és konfigurálása. - - - Adatbázis - - - Tárolás, migráció, régi adatok takarítása - - - Névjegy - - - Bővítmények, verzió, projektinformációk, fordítók és changelog. - Témák @@ -817,9 +718,6 @@ Adatok és adatvédelem - - Adatvédelmi szűrő, megőrzés, takarítás, export és adatbázis-statisztikák. - Theme @@ -838,9 +736,6 @@ Speciális (Shift+kattintás a megnyitáshoz) - - A Hellion Chat 1.2.1 átrendezte a beállítások menüt és eltávolította a régi „Stílus felülbírálása" opciót (amelyet az 1.1.0-ban bevezetett témarendszer váltott fel). A többi beállításod változatlan maradt. Az ablak átlátszósága átkerült a „Theme & Layout" menübe. Az előző konfiguráció biztonsági másolata a pluginConfigs/HellionChat.json.pre-v16-backup fájlban található az aktív HellionChat.json mellett. - A plugin-integrációk lehetővé teszik, hogy a HellionChat más telepített Dalamud-pluginekkel együttműködjön. Minden integráció automatikusan felismeri a célpluginét, és csendesen letiltja magát, ha a célplugin hiányzik. diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index 4e23358..a08b665 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -63,18 +63,9 @@ Rete di sicurezza per i ChatType aggiunti da futuri aggiornamenti di FFXIV che il plugin non conosce ancora. Il predefinito è DISATTIVATO (minimizzazione dei dati). Attiva se vuoi che anche i canali futuri vengano registrati completamente. - - Applica filtro al database esistente - Il filtro privacy agisce solo sui nuovi messaggi. La pulizia qui sotto consente di rimuovere retroattivamente i messaggi già salvati che non corrispondono alla whitelist salvata. - - La pulizia usa la tua whitelist SALVATA (Plugin.Config), non le modifiche non salvate sopra. Clicca Salva prima se vuoi che le modifiche attuali vengano applicate. - - - L'esecuzione manuale usa la tua policy di conservazione SALVATA, non i valori degli slider sopra. Clicca Salva prima se vuoi che l'esecuzione applichi le modifiche attuali. - Anteprima non aggiornata: la whitelist è cambiata dall'ultimo aggiornamento. Clicca Aggiorna per ricalcolare. @@ -159,9 +150,6 @@ Applica conservazione ora - - Ctrl+Shift: Esegue immediatamente la pulizia della conservazione usando la policy SALVATA. Salva prima le modifiche. - Pulizia della conservazione in esecuzione in background… @@ -273,9 +261,6 @@ Aspetto - - Carica la sessione precedente all'avvio - Applica filtri ai messaggi delle sessioni precedenti @@ -318,9 +303,6 @@ Impostazioni → Hellion Chat per regolazioni successive - - Esporta (GDPR Art. 15 — Diritto di accesso) - Esporta i messaggi salvati in Markdown, JSON o CSV. Puoi così soddisfare una richiesta di accesso da parte di una persona i cui messaggi hai conservato, o portare con te la tua cronologia. @@ -560,12 +542,6 @@ - - Impostazioni ristrutturate - - - Hellion Chat 0.5.0 ha ristrutturato le impostazioni in tab tematici. Il database della chat e la cronologia dei messaggi rimangono invariati. Le impostazioni sono state ripristinate ai valori predefiniti. Se vuoi riselezionare il profilo privacy, il pulsante Riapri si trova nel tab Privacy. Un backup della configurazione precedente si trova in HellionChat.json.pre-v10-backup accanto al file di configurazione attivo. - @@ -622,15 +598,6 @@ - - Archiviazione - - - Panoramica - - - Manutenzione - @@ -700,24 +667,6 @@ Sposta la finestra della chat e tutti i pop-out attivi nell'angolo in alto a sinistra del monitor principale. Utile quando una finestra è finita fuori dall'area visibile dopo un cambio di layout del display (monitor scollegato, risoluzione cambiata). Il plugin esegue anche un controllo automatico dei limiti una volta per sessione; questo pulsante è la via di fuga manuale se qualcosa finisce comunque irraggiungibile. - - Novità in v0.6.0: ora puoi digitare direttamente nei pop-out. Attiva l'interruttore principale nelle impostazioni Finestra. - - - Capito - - - Apri impostazioni finestra - - - Puoi aprire qualsiasi tab della chat come finestra separata. Clicca l'icona finestra in alto a destra o fai clic destro sul tab. Novità in v0.6.1: l'input nei pop-out è attivo per impostazione predefinita (disattivabile in Impostazioni → Finestra). - - - Capito - - - Apri impostazioni - Hellion Chat non può avviarsi mentre Chat 2 è caricato. @@ -727,54 +676,6 @@ Disattiva Chat 2 in /xlplugins, poi riattiva Hellion Chat. - - Generale - - - Lingua, input, audio e prestazioni. - - - Aspetto - - - Opacità finestra, font, animazioni - - - Themes - - - Scegli un theme o importa il tuo - - - Finestra - - - Quando la finestra è visibile e se può essere spostata. - - - Chat - - - Tell, anteprima, comportamento messaggi ed emote. - - - Tab - - - Crea e configura tab della chat personalizzati. - - - Database - - - Archiviazione, migrazione, pulizia legacy - - - Informazioni - - - Estensioni, versione, informazioni sul progetto, traduttori e changelog. - Themes @@ -817,9 +718,6 @@ Dati e privacy - - Filtro privacy, conservazione, pulizia, esportazione e statistiche database. - Theme @@ -838,9 +736,6 @@ Avanzate (Shift+clic per aprire) - - Hellion Chat 1.2.1 ha riorganizzato il menu delle impostazioni e rimosso la vecchia opzione "Override style" (sostituita dal sistema theme dalla 1.1.0). Le impostazioni rimanenti sono invariate. La trasparenza della finestra è stata migrata in "Theme & Layout". Un backup della configurazione precedente si trova in pluginConfigs/HellionChat.json.pre-v16-backup accanto al file HellionChat.json attivo. - Le integrazioni con plugin consentono a HellionChat di lavorare insieme ad altri plugin Dalamud installati. Ogni integrazione rileva automaticamente il suo target e si disattiva silenziosamente quando il plugin target è assente. diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index d807837..3716ff0 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -63,18 +63,9 @@ 将来の FFXIVパッチで追加されるプラグイン未対応の ChatType に対するセーフティネットです。デフォルトはオフ(データ最小化)。将来のチャンネルも完全に記録したい場合は有効にしてください。 - - 既存データベースにフィルターを適用する - プライバシーフィルターは新しいメッセージにのみ適用されます。以下のクリーンアップを使用すると、保存済みのホワイトリストに一致しない既存のメッセージをさかのぼって削除できます。 - - クリーンアップには保存済みのホワイトリスト(Plugin.Config)が使用されます。上部の未保存の変更は反映されません。現在の変更を適用したい場合は、先に保存してください。 - - - 手動実行には保存済みの保持ポリシーが使用されます。上部のスライダーの値は反映されません。現在の変更を適用したい場合は、先に保存してください。 - プレビューが古くなっています。前回の更新以降にホワイトリストが変更されました。「更新」をクリックして再計算してください。 @@ -159,9 +150,6 @@ 保持期間をすぐに適用する - - Ctrl+Shift: 保存済みのポリシーを使って保持期間のクリーンアップを即時実行します。先に変更を保存してください。 - バックグラウンドで保持期間のクリーンアップを実行中… @@ -273,9 +261,6 @@ 表示 - - 起動時に前回のセッションを読み込む - 以前のセッションのメッセージにもフィルターを適用する @@ -318,9 +303,6 @@ 設定 → Hellion Chat で後から細かく調整できます - - エクスポート(GDPR Art. 15 — アクセス権) - 保存済みメッセージを Markdown、JSON、CSV 形式でエクスポートします。メッセージを保存した相手からのアクセス請求への対応や、自分の履歴を持ち出す際に活用できます。 @@ -560,12 +542,6 @@ - - 設定が再構成されました - - - Hellion Chat 0.5.0 で設定がテーマ別タブに再構成されました。チャットデータベースとメッセージ履歴はそのまま保持されます。設定はデフォルト値にリセットされました。プライバシープロファイルを再選択したい場合は、プライバシータブの「再表示」ボタンをご利用ください。以前の設定のバックアップは、アクティブな設定ファイルの隣に HellionChat.json.pre-v10-backup として保存されています。 - @@ -622,15 +598,6 @@ - - ストレージ - - - 概要 - - - メンテナンス - @@ -700,24 +667,6 @@ チャットウィンドウとすべてのアクティブなポップアウトをプライマリモニターの左上隅に戻します。ディスプレイレイアウトの変更(モニターの切断、解像度変更)後にウィンドウが表示領域外に移動した場合に便利です。プラグインはセッションごとに自動で境界チェックを行いますが、それでも届かない場合の手動での脱出手段としてこのボタンをご利用ください。 - - v0.6.0 の新機能: ポップアウト内で直接入力できるようになりました。ウィンドウ設定のマスタースイッチを有効にしてください。 - - - 了解 - - - ウィンドウ設定を開く - - - どのチャットタブも独立したウィンドウとして開けます。右上のウィンドウアイコンをクリックするか、タブを右クリックしてください。v0.6.1 の新機能: ポップアウト入力がデフォルトで有効になりました(設定 → ウィンドウで無効にできます)。 - - - 了解 - - - 設定を開く - Chat 2 が読み込まれている間は Hellion Chat を起動できません。 @@ -727,54 +676,6 @@ /xlplugins で Chat 2 を無効にしてから、Hellion Chat を再度有効にしてください。 - - 一般 - - - 言語、入力、オーディオ、パフォーマンス。 - - - 外観 - - - ウィンドウの不透明度、フォント、モーション - - - テーマ - - - テーマを選択するか、独自のテーマをインポートする - - - ウィンドウ - - - ウィンドウの表示タイミングと移動可否。 - - - チャット - - - テル、プレビュー、メッセージの動作、エモート。 - - - タブ - - - カスタムチャットタブの作成と設定。 - - - データベース - - - ストレージ、移行、レガシーのクリーンアップ - - - 概要 - - - 拡張機能、バージョン、プロジェクト情報、翻訳者、Changelog。 - テーマ @@ -817,9 +718,6 @@ データとプライバシー - - プライバシーフィルター、保持期間、クリーンアップ、エクスポート、データベース統計。 - テーマ @@ -838,9 +736,6 @@ 詳細設定(Shift+クリックで開く) - - Hellion Chat 1.2.1 で設定メニューが整理され、古い「スタイルを上書き」オプションが削除されました(1.1.0 のテーマシステムに置き換えられました)。その他の設定は変更されていません。ウィンドウの透明度は「テーマ & レイアウト」に移行されました。以前の設定のバックアップは、アクティブな HellionChat.json の隣に pluginConfigs/HellionChat.json.pre-v16-backup として保存されています。 - プラグイン連携により、HellionChat は他のインストール済み Dalamud プラグインと連携できます。各連携は対象プラグインを自動で検出し、対象プラグインがない場合は静かに無効化されます。 diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 59b8f6a..6f68f84 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -63,18 +63,9 @@ 향후 FFXIV 패치로 추가될 수 있는 미지의 ChatType에 대한 안전장치입니다. 기본값은 꺼짐 (데이터 최소화)입니다. 향후 채널도 완전히 기록하고 싶다면 활성화하세요. - - 기존 데이터베이스에 필터 적용 - 개인정보 필터는 새 메시지에만 적용됩니다. 아래 정리 기능을 사용하면 저장된 화이트리스트에 맞지 않는 기존 메시지를 소급하여 삭제할 수 있습니다. - - 정리 작업은 위의 미저장 변경사항이 아닌 저장된 화이트리스트 (Plugin.Config)를 사용합니다. 현재 변경사항을 적용하려면 먼저 저장하세요. - - - 수동 실행은 위의 슬라이더 값이 아닌 저장된 보존 정책을 사용합니다. 현재 변경사항을 적용하려면 먼저 저장하세요. - 미리보기가 오래되었습니다. 마지막 새로 고침 이후 화이트리스트가 변경되었습니다. 새로 고침을 클릭하여 다시 계산하세요. @@ -159,9 +150,6 @@ 지금 보존 정책 적용 - - Ctrl+Shift: 저장된 정책으로 보존 정리를 즉시 실행합니다. 먼저 변경사항을 저장하세요. - 백그라운드에서 보존 정리 작업 중… @@ -273,9 +261,6 @@ 시각적 설정 - - 시작 시 이전 세션 불러오기 - 이전 세션 메시지에 필터 적용 @@ -318,9 +303,6 @@ 나중에 세부 조정은 설정 → Hellion Chat에서 - - 내보내기 (GDPR Art. 15 — 열람권) - 저장된 메시지를 Markdown, JSON, 또는 CSV 형식으로 내보냅니다. 저장된 메시지의 당사자가 열람을 요청하거나 자신의 기록을 가져갈 때 사용할 수 있습니다. @@ -560,12 +542,6 @@ - - 설정이 재구성되었습니다 - - - Hellion Chat 0.5.0에서 설정이 주제별 탭으로 재구성되었습니다. 채팅 데이터베이스와 메시지 기록은 변경되지 않았습니다. 설정이 기본값으로 초기화되었습니다. 개인정보 프로필을 다시 선택하려면 개인정보 탭의 다시 열기 버튼을 사용하세요. 이전 설정의 백업이 활성 설정 파일 옆에 HellionChat.json.pre-v10-backup으로 저장되어 있습니다. - @@ -622,15 +598,6 @@ - - 저장소 - - - 개요 - - - 유지 관리 - @@ -700,24 +667,6 @@ 채팅 창과 모든 활성 팝아웃을 기본 모니터의 왼쪽 상단 모서리로 이동합니다. 디스플레이 레이아웃 변경 (모니터 분리, 해상도 변경) 후 창이 화면 밖으로 벗어났을 때 유용합니다. 플러그인은 세션당 한 번 자동 경계 검사를 수행하며, 이 버튼은 그래도 접근할 수 없는 경우를 위한 수동 탈출구입니다. - - v0.6.0의 새 기능: 이제 팝아웃에서 직접 입력할 수 있습니다. 창 설정에서 마스터 스위치를 활성화하세요. - - - 확인 - - - 창 설정 열기 - - - 모든 채팅 탭을 별도 창으로 열 수 있습니다. 오른쪽 상단의 창 아이콘을 클릭하거나 탭을 우클릭하세요. v0.6.1의 새 기능: 팝아웃 입력이 기본으로 활성화되었습니다 (설정 → 창에서 비활성화 가능). - - - 확인 - - - 설정 열기 - Chat 2가 로드된 상태에서는 Hellion Chat을 시작할 수 없습니다. @@ -727,54 +676,6 @@ /xlplugins에서 Chat 2를 비활성화한 후 Hellion Chat을 다시 활성화하세요. - - 일반 - - - 언어, 입력, 오디오, 성능. - - - 외형 - - - 창 불투명도, 글꼴, 모션. - - - 테마 - - - 테마 선택 또는 가져오기 - - - 창 - - - 창의 표시 조건 및 이동 가능 여부. - - - 채팅 - - - 귓속말, 미리보기, 메시지 동작, 감정 표현. - - - 탭 - - - 사용자 정의 채팅 탭 생성 및 설정. - - - 데이터베이스 - - - 저장소, 마이그레이션, 레거시 정리. - - - 소개 - - - 확장 기능, 버전, 프로젝트 정보, 번역자, Changelog. - 테마 @@ -817,9 +718,6 @@ 데이터 및 개인 정보 - - 개인 정보 필터, 보존, 정리, 내보내기, 데이터베이스 통계. - 테마 @@ -838,9 +736,6 @@ 고급 설정 (Shift+클릭으로 열기) - - Hellion Chat 1.2.1에서 설정 메뉴가 재구성되었으며 기존 "스타일 재정의" 옵션이 제거되었습니다 (1.1.0의 테마 시스템으로 대체됨). 나머지 설정은 변경되지 않았습니다. 창 투명도는 "테마 & 레이아웃"으로 이전되었습니다. 이전 설정의 백업이 활성 HellionChat.json 옆에 pluginConfigs/HellionChat.json.pre-v16-backup으로 저장되어 있습니다. - 플러그인 연동을 통해 HellionChat은 설치된 다른 Dalamud 플러그인과 함께 작동합니다. 각 연동은 대상 플러그인을 자동으로 감지하며, 대상 플러그인이 없으면 자동으로 비활성화됩니다. diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index ccde0d7..815fe59 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -63,18 +63,9 @@ Sikkerhetsnett for ChatTypes som legges til av fremtidige FFXIV-oppdateringer som pluginen ennå ikke kjenner til. Standard er AV (dataminimering). Aktiver om du vil at fremtidige kanaler også skal logges fullstendig. - - Bruk filter på eksisterende database - Personvernfilteret påvirker bare nye meldinger. Oppryddingen nedenfor lar deg fjerne allerede lagrede meldinger som ikke samsvarer med den lagrede hvitelisten din. - - Oppryddingen bruker den LAGREDE hvitelisten din (Plugin.Config), ikke ulagrede endringer ovenfor. Klikk Lagre først om du vil at de nåværende endringene skal brukes. - - - Den manuelle kjøringen bruker den LAGREDE oppbevaringspolicyen din, ikke skyveknappverdiene ovenfor. Klikk Lagre først om du vil at kjøringen skal bruke de nåværende endringene. - Forhåndsvisningen er utdatert: hvitelisten din har endret seg siden siste oppdatering. Klikk Oppdater for å beregne på nytt. @@ -159,9 +150,6 @@ Bruk oppbevaring nå - - Ctrl+Shift: Kjører oppbevaringsoppryddingen umiddelbart med den LAGREDE policyen. Lagre endringene dine først. - Oppbevaringsopprydding kjører i bakgrunnen… @@ -273,9 +261,6 @@ Utseende - - Last inn forrige økt ved oppstart - Bruk filtre på meldinger fra tidligere økter @@ -318,9 +303,6 @@ Innstillinger → Hellion Chat for å finjustere senere - - Eksport (GDPR Art. 15 — Rett til innsyn) - Eksporter lagrede meldinger som Markdown, JSON eller CSV. Dette lar deg oppfylle en innsynsforespørsel fra en person hvis meldinger du har lagret, eller ta med deg din egen historikk. @@ -560,12 +542,6 @@ - - Innstillinger omstrukturert - - - Hellion Chat 0.5.0 har omstrukturert innstillingene i tematiske faner. Chat-databasen og meldingshistorikken din er uendret. Innstillinger har blitt tilbakestilt til standardverdier. Hvis du vil velge personvernprofil på nytt, finner du Åpne igjen-knappen i Personvern-fanen. En sikkerhetskopi av forrige konfig ligger på HellionChat.json.pre-v10-backup ved siden av den aktive konfig-filen. - @@ -622,15 +598,6 @@ - - Lagring - - - Oversikt - - - Vedlikehold - @@ -700,24 +667,6 @@ Flytter chat-vinduet og alle aktive pop-out-vinduer tilbake til øverste venstre hjørne på primærskjermen. Nyttig når et vindu har havnet utenfor det synlige området etter en endring i skjermoppsettet (skjerm koblet fra, oppløsning endret). Pluginen utfører også en automatisk grensekontroll én gang per økt. Denne knappen er den manuelle nødutgangen hvis noe likevel havner utenfor rekkevidde. - - Nytt i v0.6.0: Du kan nå skrive direkte i pop-out-vinduer. Aktiver hovedbryteren i Vindu-innstillingene. - - - Forstått - - - Åpne vindusinnstillinger - - - Du kan åpne hvilken som helst chat-fane som sitt eget vindu. Klikk vindusikonet øverst til høyre eller høyreklikk fanen. Nytt i v0.6.1: pop-out-inndata er aktiv som standard (kan deaktiveres under Innstillinger → Vindu). - - - Forstått - - - Åpne innstillinger - Hellion Chat kan ikke starte mens Chat 2 er lastet. @@ -727,54 +676,6 @@ Deaktiver Chat 2 i /xlplugins, aktiver deretter Hellion Chat på nytt. - - Generelt - - - Språk, inndata, lyd og ytelse. - - - Utseende - - - Vindusopasitet, skrifttyper, bevegelse - - - Themes - - - Velg et theme eller importer ditt eget - - - Vindu - - - Når vinduet er synlig og om det kan flyttes. - - - Chat - - - Tells, forhåndsvisning, meldingsoppførsel og emotes. - - - Faner - - - Opprett og konfigurer egendefinerte chat-faner. - - - Database - - - Lagring, migrering, eldre opprydding - - - Om - - - Utvidelser, versjon, prosjektinformasjon, oversettere og changelog. - Themes @@ -817,9 +718,6 @@ Data og personvern - - Personvernfilter, oppbevaring, opprydding, eksport og databasestatistikk. - Theme @@ -838,9 +736,6 @@ Avansert (Shift+klikk for å åpne) - - Hellion Chat 1.2.1 har reorganisert innstillingsmenyen og fjernet det gamle "Overstyr stil"-alternativet (erstattet av theme-systemet fra 1.1.0). Øvrige innstillinger er uendret. Vindustransparens er migrert til "Theme & Layout". En sikkerhetskopi av forrige konfig ligger under pluginConfigs/HellionChat.json.pre-v16-backup ved siden av den aktive HellionChat.json. - Plugin-integrasjoner lar HellionChat samarbeide med andre installerte Dalamud-plugins. Hver integrasjon oppdager automatisk målet sitt og deaktiverer seg stille når mål-pluginen mangler. diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 81b2bb3..1773a17 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -63,18 +63,9 @@ Vangnet voor ChatTypes die via toekomstige FFXIV-patches worden toegevoegd en nog niet bekend zijn bij de plugin. Standaard is UIT (dataminimalisatie). Schakel in als je toekomstige kanalen ook volledig wilt loggen. - - Filter toepassen op bestaande database - Het privacyfilter heeft alleen invloed op nieuwe berichten. Met de opschoning hieronder kun je reeds opgeslagen berichten alsnog verwijderen die niet overeenkomen met je opgeslagen whitelist. - - De opschoning gebruikt je OPGESLAGEN whitelist (Plugin.Config), niet de niet-opgeslagen wijzigingen hierboven. Klik eerst op Opslaan als je wilt dat je huidige wijzigingen worden toegepast. - - - De handmatige uitvoering gebruikt je OPGESLAGEN retentiebeleid, niet de sliderwaarden hierboven. Klik eerst op Opslaan als je wilt dat de uitvoering je huidige wijzigingen toepast. - Voorbeeld is verouderd: je whitelist is gewijzigd sinds de laatste vernieuwing. Klik op Vernieuwen om opnieuw te berekenen. @@ -159,9 +150,6 @@ Retentie nu toepassen - - Ctrl+Shift: Voert de retentieopschoning onmiddellijk uit met het OPGESLAGEN beleid. Sla je wijzigingen eerst op. - Retentieopschoning wordt op de achtergrond uitgevoerd… @@ -273,9 +261,6 @@ Uiterlijk - - Vorige sessie laden bij opstarten - Filters toepassen op berichten uit vorige sessies @@ -318,9 +303,6 @@ Instellingen → Hellion Chat om later te verfijnen - - Exporteren (AVG Art. 15 — Recht op inzage) - Exporteer opgeslagen berichten als Markdown, JSON of CSV. Hiermee kun je een inzageverzoek van een persoon wiens berichten je hebt opgeslagen afhandelen, of je eigen geschiedenis meenemen. @@ -560,12 +542,6 @@ - - Instellingen geherstructureerd - - - Hellion Chat 0.5.0 heeft de instellingen geherstructureerd in thematische tabbladen. Je chatdatabase en berichtengeschiedenis blijven ongewijzigd. Instellingen zijn teruggezet naar de standaardwaarden. Als je je privacyprofiel opnieuw wilt kiezen, staat de knop Wizard opnieuw tonen in het tabblad Privacy. Een back-up van de vorige configuratie staat bij HellionChat.json.pre-v10-backup naast het actieve configuratiebestand. - @@ -622,15 +598,6 @@ - - Opslag - - - Overzicht - - - Onderhoud - @@ -700,24 +667,6 @@ Verplaatst het chatvenster en alle actieve pop-outs terug naar de linkerbovenhoek van de primaire monitor. Handig wanneer een venster buiten het zichtbare gebied is beland na een wijziging in de schermindeling (monitor losgekoppeld, resolutie gewijzigd). De plugin voert ook eenmalig per sessie een automatische grenscontrole uit; deze knop is de handmatige nooduitgang als er toch iets onbereikbaar blijft. - - Nieuw in v0.6.0: je kunt nu direct typen in pop-outs. Schakel de hoofdschakelaar in via de vensterinstellingen. - - - Begrepen - - - Vensterinstellingen openen - - - Je kunt elk chattabblad als een eigen venster openen. Klik op het venster-pictogram rechtsboven of klik met rechts op het tabblad. Nieuw in v0.6.1: pop-outinvoer is standaard actief (uit te schakelen via Instellingen → Venster). - - - Begrepen - - - Instellingen openen - Hellion Chat kan niet starten terwijl Chat 2 geladen is. @@ -727,54 +676,6 @@ Schakel Chat 2 uit in /xlplugins en schakel daarna Hellion Chat opnieuw in. - - Algemeen - - - Taal, invoer, audio en prestaties. - - - Uiterlijk - - - Vensterdekking, lettertypen, beweging - - - Themes - - - Een theme kiezen of je eigen theme importeren - - - Venster - - - Wanneer het venster zichtbaar is en of het verplaatst kan worden. - - - Chat - - - Tells, voorbeeld, berichtgedrag en emotes. - - - Tabbladen - - - Aangepaste chattabbladen aanmaken en configureren. - - - Database - - - Opslag, migratie, verouderde opschoning - - - Over - - - Extensies, versie, projectinformatie, vertalers en changelog. - Themes @@ -817,9 +718,6 @@ Gegevens en privacy - - Privacyfilter, retentie, opschoning, export en databasestatistieken. - Theme @@ -838,9 +736,6 @@ Geavanceerd (Shift+klik om te openen) - - Hellion Chat 1.2.1 heeft het instellingenmenu opnieuw ingedeeld en de oude optie "Stijl overschrijven" verwijderd (vervangen door het themasysteem uit 1.1.0). Je overige instellingen zijn ongewijzigd. Venstertransparantie is verplaatst naar "Theme & Layout". Een back-up van de vorige configuratie staat bij pluginConfigs/HellionChat.json.pre-v16-backup naast de actieve HellionChat.json. - Plugin-integraties laten HellionChat samenwerken met andere geïnstalleerde Dalamud-plugins. Elke integratie detecteert automatisch zijn doelplugin en schakelt zichzelf stil uit als die ontbreekt. diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index 51a1933..6cea3dc 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -63,18 +63,9 @@ Zabezpieczenie na wypadek ChatTypes dodanych przez przyszłe patche FFXIV, których plugin jeszcze nie zna. Domyślnie WYŁĄCZONE (minimalizacja danych). Włącz, jeśli chcesz, żeby przyszłe kanały też były w pełni logowane. - - Zastosuj filtr do istniejącej bazy danych - Filtr prywatności działa tylko na nowe wiadomości. Czyszczenie poniżej pozwala retroaktywnie usunąć już zapisane wiadomości, które nie pasują do zapisanej listy dozwolonych. - - Czyszczenie używa ZAPISANEJ listy dozwolonych (Plugin.Config), a nie niezapisanych zmian powyżej. Najpierw kliknij Zapisz, jeśli chcesz zastosować bieżące zmiany. - - - Ręczne uruchomienie używa ZAPISANEJ polityki przechowywania, nie wartości suwaków powyżej. Najpierw kliknij Zapisz, jeśli chcesz zastosować bieżące zmiany. - Podgląd jest nieaktualny: lista dozwolonych zmieniła się od ostatniego odświeżenia. Kliknij Odśwież, aby przeliczyć. @@ -159,9 +150,6 @@ Zastosuj przechowywanie teraz - - Ctrl+Shift: Natychmiast uruchamia czyszczenie przechowywania przy użyciu ZAPISANEJ polityki. Najpierw zapisz zmiany. - Czyszczenie przechowywania trwa w tle… @@ -273,9 +261,6 @@ Wygląd - - Wczytaj poprzednią sesję przy uruchomieniu - Stosuj filtry do wiadomości z poprzednich sesji @@ -318,9 +303,6 @@ Ustawienia → Hellion Chat, aby dostosować później - - Eksport (GDPR Art. 15 — Prawo dostępu) - Eksportuj zapisane wiadomości jako Markdown, JSON lub CSV. Pozwala to zrealizować wniosek o dostęp od osoby, której wiadomości przechowujesz, lub zabrać własną historię ze sobą. @@ -560,12 +542,6 @@ - - Ustawienia przeorganizowane - - - Hellion Chat 0.5.0 przeorganizował ustawienia w tematyczne zakładki. Twoja baza danych czatu i historia wiadomości pozostają bez zmian. Ustawienia zostały zresetowane do domyślnych. Jeśli chcesz ponownie wybrać profil prywatności, przycisk Otwórz ponownie znajduje się w zakładce Prywatność. Kopia zapasowa poprzedniej konfiguracji znajduje się w pliku HellionChat.json.pre-v10-backup obok aktywnego pliku konfiguracji. - @@ -622,15 +598,6 @@ - - Przechowywanie - - - Przegląd - - - Konserwacja - @@ -700,24 +667,6 @@ Przenosi okno czatu i wszystkie aktywne pop-outy z powrotem do lewego górnego rogu głównego monitora. Przydatne, gdy okno znalazło się poza widocznym obszarem po zmianie układu wyświetlania (odłączony monitor, zmieniona rozdzielczość). Plugin wykonuje też automatyczne sprawdzenie granic raz na sesję; ten przycisk to ręczna opcja awaryjna, gdy coś nadal pozostaje niedostępne. - - Nowość w v0.6.0: Możesz teraz pisać bezpośrednio w pop-outach. Włącz główny przełącznik w ustawieniach okna. - - - Rozumiem - - - Otwórz ustawienia okna - - - Możesz otworzyć dowolną zakładkę czatu jako własne okno. Kliknij ikonę okna w prawym górnym rogu lub kliknij prawym przyciskiem zakładkę. Nowość w v0.6.1: wprowadzanie w pop-oucie jest domyślnie aktywne (można wyłączyć w Ustawieniach → Okno). - - - Rozumiem - - - Otwórz ustawienia - Hellion Chat nie może uruchomić się, gdy Chat 2 jest załadowany. @@ -727,54 +676,6 @@ Wyłącz Chat 2 w /xlplugins, a następnie ponownie włącz Hellion Chat. - - Ogólne - - - Język, wprowadzanie, audio i wydajność. - - - Wygląd - - - Przezroczystość okna, czcionki, animacje - - - Motywy - - - Wybierz motyw lub zaimportuj własny - - - Okno - - - Kiedy okno jest widoczne i czy można je przesuwać. - - - Chat - - - Tells, podgląd, zachowanie wiadomości i emoty. - - - Zakładki - - - Twórz i konfiguruj niestandardowe zakładki czatu. - - - Baza danych - - - Przechowywanie, migracja, stare czyszczenie - - - O wtyczce - - - Rozszerzenia, wersja, informacje o projekcie, tłumacze i changelog. - Motywy @@ -817,9 +718,6 @@ Dane i prywatność - - Filtr prywatności, przechowywanie, czyszczenie, eksport i statystyki bazy danych. - Theme @@ -838,9 +736,6 @@ Zaawansowane (Shift+kliknięcie, aby otworzyć) - - Hellion Chat 1.2.1 przeorganizował menu ustawień i usunął starą opcję „Override style" (zastąpioną przez system motywów z wersji 1.1.0). Pozostałe ustawienia pozostają bez zmian. Przezroczystość okna została przeniesiona do „Theme & Layout". Kopia zapasowa poprzedniej konfiguracji znajduje się w pluginConfigs/HellionChat.json.pre-v16-backup obok aktywnego HellionChat.json. - Integracje z pluginami pozwalają HellionChat współpracować z innymi zainstalowanymi pluginami Dalamud. Każda integracja automatycznie wykrywa swój cel i cicho się wyłącza, gdy docelowy plugin jest niedostępny. diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index 820e5d3..c185c79 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -63,18 +63,9 @@ Rede de segurança para ChatTypes adicionados por patches futuros do FFXIV que o plugin ainda não conhece. O padrão é DESATIVADO (minimização de dados). Ative se quiser que canais futuros também sejam registrados por completo. - - Aplicar filtro ao banco de dados existente - O filtro de privacidade afeta apenas novas mensagens. A limpeza abaixo permite remover retroativamente mensagens já armazenadas que não correspondem à sua lista de permissões salva. - - A limpeza usa sua lista de permissões SALVA (Plugin.Config), não as alterações não salvas acima. Clique em Salvar primeiro se quiser que suas alterações atuais sejam aplicadas. - - - A execução manual usa sua política de retenção SALVA, não os valores dos controles acima. Clique em Salvar primeiro se quiser que a execução aplique suas alterações atuais. - A prévia está desatualizada: sua lista de permissões mudou desde a última atualização. Clique em Atualizar para recalcular. @@ -159,9 +150,6 @@ Aplicar retenção agora - - Ctrl+Shift: Executa a limpeza de retenção imediatamente usando a política SALVA. Salve suas alterações primeiro. - Limpeza de retenção em execução em segundo plano… @@ -273,9 +261,6 @@ Visual - - Carregar sessão anterior ao iniciar - Aplicar filtros às mensagens de sessões anteriores @@ -318,9 +303,6 @@ Configurações → Hellion Chat para ajustar depois - - Exportar (GDPR Art. 15 — Direito de acesso) - Exporte mensagens armazenadas como Markdown, JSON ou CSV. Isso permite atender a uma solicitação de acesso de uma pessoa cujas mensagens você armazenou, ou levar seu próprio histórico com você. @@ -560,12 +542,6 @@ - - Configurações reestruturadas - - - O Hellion Chat 0.5.0 reestruturou as configurações em abas temáticas. Seu banco de dados de chat e histórico de mensagens permanecem inalterados. As configurações foram redefinidas para os padrões. Se quiser selecionar seu perfil de privacidade novamente, o botão Reabrir está na aba Privacidade. Um backup da configuração anterior está em HellionChat.json.pre-v10-backup ao lado do arquivo de configuração ativo. - @@ -622,15 +598,6 @@ - - Armazenamento - - - Visão geral - - - Manutenção - @@ -700,24 +667,6 @@ Move a janela de chat e todos os pop-outs ativos de volta para o canto superior esquerdo do monitor principal. Útil quando uma janela foi parar fora da área visível após uma mudança de layout de tela (monitor desconectado, resolução alterada). O plugin também realiza uma verificação automática de limites uma vez por sessão; este botão é a saída manual caso algo ainda fique inacessível. - - Novidade na v0.6.0: agora você pode digitar diretamente nos pop-outs. Ative a chave mestra nas configurações de Janela. - - - Entendi - - - Abrir configurações de janela - - - Você pode abrir qualquer aba de chat como sua própria janela. Clique no ícone de janela no canto superior direito ou clique com o botão direito na aba. Novidade na v0.6.1: a entrada em pop-out está ativa por padrão (pode ser desativada em Configurações → Janela). - - - Entendi - - - Abrir configurações - O Hellion Chat não pode iniciar enquanto o Chat 2 estiver carregado. @@ -727,54 +676,6 @@ Desative o Chat 2 em /xlplugins e depois reative o Hellion Chat. - - Geral - - - Idioma, entrada, áudio e performance. - - - Aparência - - - Opacidade da janela, fontes, animação - - - Themes - - - Escolha um tema ou importe o seu - - - Janela - - - Quando a janela é visível e se ela pode ser movida. - - - Chat - - - Tells, prévia, comportamento de mensagens e emotes. - - - Abas - - - Crie e configure abas de chat personalizadas. - - - Banco de Dados - - - Armazenamento, migração, limpeza legada - - - Sobre - - - Extensões, versão, informações do projeto, tradutores e changelog. - Themes @@ -817,9 +718,6 @@ Dados e privacidade - - Filtro de privacidade, retenção, limpeza, exportação e estatísticas do banco de dados. - Theme @@ -838,9 +736,6 @@ Avançado (Shift+clique para abrir) - - O Hellion Chat 1.2.1 reorganizou o menu de configurações e removeu a antiga opção "Substituir estilo" (substituída pelo sistema de temas da versão 1.1.0). Suas demais configurações permanecem inalteradas. A transparência da janela foi migrada para "Theme & Layout". Um backup da configuração anterior está em pluginConfigs/HellionChat.json.pre-v16-backup ao lado do HellionChat.json ativo. - As integrações de plugin permitem que o HellionChat trabalhe junto com outros plugins Dalamud instalados. Cada integração detecta automaticamente seu alvo e se desativa silenciosamente quando o plugin alvo está ausente. diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index a3ea6c0..925e834 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -63,18 +63,9 @@ Rede de segurança para ChatTypes adicionados por futuras atualizações de FFXIV que o plugin ainda não reconhece. A predefinição é DESATIVADO (minimização de dados). Ativa se quiseres que futuros canais sejam também registados na íntegra. - - Aplicar filtro à base de dados existente - O filtro de privacidade só afeta novas mensagens. A limpeza abaixo permite remover retroativamente mensagens já armazenadas que não correspondem à tua lista de permissões guardada. - - A limpeza usa a tua lista de permissões GUARDADA (Plugin.Config), não as alterações por guardar acima. Clica em Guardar primeiro se quiseres que as tuas alterações atuais sejam aplicadas. - - - A execução manual usa a tua política de retenção GUARDADA, não os valores do cursor acima. Clica em Guardar primeiro se quiseres que a execução aplique as tuas alterações atuais. - A pré-visualização está desatualizada: a tua lista de permissões mudou desde a última atualização. Clica em Atualizar para recalcular. @@ -159,9 +150,6 @@ Aplicar retenção agora - - Ctrl+Shift: Executa a limpeza de retenção imediatamente com a política GUARDADA. Guarda as tuas alterações primeiro. - Limpeza de retenção a decorrer em segundo plano… @@ -273,9 +261,6 @@ Visual - - Carregar sessão anterior no arranque - Aplicar filtros a mensagens de sessões anteriores @@ -318,9 +303,6 @@ Definições → Hellion Chat para ajustar mais tarde - - Exportar (GDPR Art. 15 — Direito de acesso) - Exporta mensagens armazenadas em Markdown, JSON ou CSV. Permite responder a um pedido de acesso de uma pessoa cujas mensagens guardaste, ou levar o teu próprio histórico contigo. @@ -560,12 +542,6 @@ - - Definições reestruturadas - - - O Hellion Chat 0.5.0 reestruturou as definições em separadores temáticos. A tua base de dados de chat e o histórico de mensagens permanecem inalterados. As definições foram repostas para os valores predefinidos. Se quiseres voltar a selecionar o teu perfil de privacidade, o botão Reabrir está no separador Privacidade. Uma cópia de segurança da configuração anterior encontra-se em HellionChat.json.pre-v10-backup junto ao ficheiro de configuração ativo. - @@ -622,15 +598,6 @@ - - Armazenamento - - - Visão geral - - - Manutenção - @@ -700,24 +667,6 @@ Move a janela de chat e todas as janelas flutuantes ativas de volta para o canto superior esquerdo do monitor principal. Útil quando uma janela ficou fora da área visível após uma alteração de layout de ecrã (monitor desligado, resolução alterada). O plugin também faz uma verificação automática de limites uma vez por sessão; este botão é a saída manual caso algo continue inacessível. - - Novidade na v0.6.0: já podes escrever diretamente em janelas flutuantes. Ativa o interruptor principal nas definições de Janela. - - - Percebido - - - Abrir definições de janela - - - Podes abrir qualquer separador de chat como janela própria. Clica no ícone de janela no canto superior direito ou faz clique direito no separador. Novidade na v0.6.1: a entrada em janelas flutuantes está ativa por predefinição (pode ser desativada em Definições → Janela). - - - Percebido - - - Abrir definições - O Hellion Chat não pode iniciar enquanto o Chat 2 estiver carregado. @@ -727,54 +676,6 @@ Desativa o Chat 2 em /xlplugins e volta a ativar o Hellion Chat. - - Geral - - - Idioma, entrada, áudio e desempenho. - - - Aparência - - - Opacidade da janela, tipos de letra, movimento - - - Themes - - - Escolhe um theme ou importa o teu próprio - - - Janela - - - Quando a janela está visível e se pode ser movida. - - - Chat - - - Tells, pré-visualização, comportamento de mensagens e emotes. - - - Separadores - - - Cria e configura separadores de chat personalizados. - - - Base de dados - - - Armazenamento, migração, limpeza de dados antigos - - - Sobre - - - Extensões, versão, informações do projeto, tradutores e changelog. - Themes @@ -817,9 +718,6 @@ Dados e privacidade - - Filtro de privacidade, retenção, limpeza, exportação e estatísticas da base de dados. - Theme @@ -838,9 +736,6 @@ Avançado (Shift+clique para abrir) - - O Hellion Chat 1.2.1 reorganizou o menu de definições e removeu a antiga opção "Substituir estilo" (substituída pelo sistema de themes a partir da versão 1.1.0). As tuas restantes definições ficam inalteradas. A transparência da janela foi migrada para "Theme & Layout". Uma cópia de segurança da configuração anterior encontra-se em pluginConfigs/HellionChat.json.pre-v16-backup junto ao ficheiro HellionChat.json ativo. - As integrações de plugins permitem que o HellionChat funcione em conjunto com outros plugins Dalamud instalados. Cada integração deteta automaticamente o seu alvo e desativa-se silenciosamente quando o plugin alvo está em falta. diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index 71e085a..b93a3de 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -63,18 +63,9 @@ Safety net for ChatTypes added by future FFXIV patches that the plugin does not yet know about. Default is OFF (data minimisation). Enable if you want future channels to be fully logged as well. - - Apply filter to existing database - The privacy filter only affects new messages. The cleanup below lets you retroactively remove already-stored messages that do not match your saved whitelist. - - Cleanup uses your SAVED whitelist (Plugin.Config), not unsaved changes above. Click Save first if you want your current changes to be applied. - - - The manual run uses your SAVED retention policy, not the slider values above. Click Save first if you want the run to apply your current changes. - Preview is stale: your whitelist has changed since the last refresh. Click Refresh to recalculate. @@ -159,9 +150,6 @@ Apply retention now - - Ctrl+Shift: Runs the retention cleanup immediately using the SAVED policy. Save your changes first. - Retention cleanup running in the background… @@ -273,9 +261,6 @@ Visual - - Load previous session on startup - Apply filters to messages from previous sessions @@ -318,9 +303,6 @@ Settings → Hellion Chat to fine-tune later - - Export (GDPR Art. 15 — Right of access) - Export stored messages as Markdown, JSON, or CSV. This lets you fulfil an access request from a person whose messages you have stored, or take your own history with you. @@ -560,12 +542,6 @@ - - Settings restructured - - - Hellion Chat 0.5.0 has restructured the settings into thematic tabs. Your chat database and message history remain unchanged. Settings have been reset to defaults. If you want to re-select your privacy profile, the Reopen button is in the Privacy tab. A backup of the previous config is located at HellionChat.json.pre-v10-backup next to the active config file. - @@ -617,15 +593,6 @@ - - Storage - - - Overview - - - Maintenance - @@ -693,24 +660,6 @@ Moves the chat window and all active pop-outs back to the top-left corner of the primary monitor. Useful when a window has ended up outside the visible area after a display layout change (monitor disconnected, resolution changed). The plugin also performs an automatic bounds check once per session; this button is the manual escape hatch if something still ends up unreachable. - - New in v0.6.0: You can now type directly in pop-outs. Enable the master switch in the Window settings. - - - Got it - - - Open window settings - - - You can open any chat tab as its own window. Click the window icon in the top right or right-click the tab. New in v0.6.1: pop-out input is active by default (can be disabled under Settings → Window). - - - Got it - - - Open settings - Hellion Chat cannot start while Chat 2 is loaded. @@ -720,54 +669,6 @@ Disable Chat 2 in /xlplugins, then re-enable Hellion Chat. - - General - - - Language, input, audio, and performance. - - - Appearance - - - Window opacity, fonts, motion - - - Themes - - - Choose a theme or import your own - - - Window - - - When the window is visible and whether it can be moved. - - - Chat - - - Tells, preview, message behaviour, and emotes. - - - Tabs - - - Create and configure custom chat tabs. - - - Database - - - Storage, migration, legacy cleanup - - - About - - - Extensions, version, project info, translators, and changelog. - Themes @@ -810,9 +711,6 @@ Data & Privacy - - Privacy filter, retention, cleanup, export, and database statistics. - Theme @@ -849,9 +747,6 @@ Advanced (Shift+click to open) - - Hellion Chat 1.2.1 has reorganised the settings menu and removed the old "Override style" option (superseded by the theme system from 1.1.0). Your remaining settings are unchanged. Window transparency has been migrated to "Theme & Layout". A backup of the previous config is located at pluginConfigs/HellionChat.json.pre-v16-backup next to the active HellionChat.json. - Plugin integrations let HellionChat work together with other installed Dalamud plugins. Each integration automatically detects its target and silently disables itself when the target plugin is missing. diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index 0503d7f..a7753e9 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -63,18 +63,9 @@ Plasă de siguranță pentru tipurile ChatType adăugate de patch-uri viitoare FFXIV pe care plugin-ul nu le cunoaște încă. Implicit este DEZACTIVAT (minimizarea datelor). Activează dacă vrei ca și canalele viitoare să fie înregistrate complet. - - Aplică filtrul pe baza de date existentă - Filtrul de confidențialitate afectează doar mesajele noi. Curățarea de mai jos îți permite să elimini retroactiv mesajele deja stocate care nu corespund listei albe salvate. - - Curățarea folosește lista albă SALVATĂ (Plugin.Config), nu modificările nesalvate de mai sus. Apasă Salvează întâi dacă vrei ca modificările curente să fie aplicate. - - - Rularea manuală folosește politica de retenție SALVATĂ, nu valorile sliderelor de mai sus. Apasă Salvează întâi dacă vrei ca rularea să aplice modificările curente. - Previzualizarea este depășită: lista albă s-a schimbat de la ultima reîmprospătare. Apasă Reîmprospătează pentru a recalcula. @@ -159,9 +150,6 @@ Aplică retenția acum - - Ctrl+Shift: Rulează imediat curățarea de retenție folosind politica SALVATĂ. Salvează mai întâi modificările. - Curățare de retenție în desfășurare în fundal… @@ -273,9 +261,6 @@ Vizual - - Încarcă sesiunea anterioară la pornire - Aplică filtrele și pe mesajele din sesiunile anterioare @@ -318,9 +303,6 @@ Setări → Hellion Chat pentru ajustări ulterioare - - Export (GDPR Art. 15 — Dreptul de acces) - Exportă mesajele stocate ca Markdown, JSON sau CSV. Acest lucru îți permite să onorezi o cerere de acces din partea unei persoane ale cărei mesaje le-ai stocat, sau să îți iei propriul istoric cu tine. @@ -560,12 +542,6 @@ - - Setări restructurate - - - Hellion Chat 0.5.0 a restructurat setările în tab-uri tematice. Baza ta de date de chat și istoricul mesajelor rămân nemodificate. Setările au fost resetate la valorile implicite. Dacă vrei să reselecți profilul de confidențialitate, butonul Redeschide se află în tab-ul Confidențialitate. O copie de rezervă a configurației anterioare se află la HellionChat.json.pre-v10-backup lângă fișierul de configurare activ. - @@ -622,15 +598,6 @@ - - Stocare - - - Prezentare generală - - - Întreținere - @@ -700,24 +667,6 @@ Mută fereastra de chat și toate pop-out-urile active înapoi în colțul din stânga sus al monitorului principal. Util când o fereastră a ajuns în afara zonei vizibile după o schimbare a configurației de afișaj (monitor deconectat, rezoluție schimbată). Plugin-ul efectuează și o verificare automată a limitelor o dată per sesiune; acest buton este ieșirea manuală de urgență dacă ceva tot rămâne inaccesibil. - - Nou în v0.6.0: Poți scrie direct în pop-out-uri. Activează comutatorul principal din setările Fereastră. - - - Am înțeles - - - Deschide setările ferestrei - - - Poți deschide orice tab de chat ca propria fereastră. Apasă pe icoana de fereastră din dreapta sus sau clic dreapta pe tab. Nou în v0.6.1: introducerea în pop-out este activă implicit (poate fi dezactivată din Setări → Fereastră). - - - Am înțeles - - - Deschide setările - Hellion Chat nu poate porni cât timp Chat 2 este încărcat. @@ -727,54 +676,6 @@ Dezactivează Chat 2 în /xlplugins, apoi reactivează Hellion Chat. - - General - - - Limbă, introducere, audio și performanță. - - - Aspect - - - Opacitate fereastră, fonturi, mișcare - - - Teme - - - Alege o temă sau importă propria ta temă - - - Fereastră - - - Când fereastra este vizibilă și dacă poate fi mutată. - - - Chat - - - Tell-uri, previzualizare, comportament mesaje și emote-uri. - - - Tab-uri - - - Creează și configurează tab-uri de chat personalizate. - - - Bază de date - - - Stocare, migrare, curățare veche - - - Despre - - - Extensii, versiune, informații despre proiect, traducători și changelog. - Teme @@ -817,9 +718,6 @@ Date și confidențialitate - - Filtru de confidențialitate, retenție, curățare, export și statistici bază de date. - Temă @@ -838,9 +736,6 @@ Avansat (Shift+clic pentru deschidere) - - Hellion Chat 1.2.1 a reorganizat meniul de setări și a eliminat vechea opțiune „Override style" (înlocuită de sistemul de teme din 1.1.0). Setările tale rămase sunt nemodificate. Transparența ferestrei a fost migrată la „Temă & aspect". O copie de rezervă a configurației anterioare se află la pluginConfigs/HellionChat.json.pre-v16-backup lângă fișierul activ HellionChat.json. - Integrările de plugin-uri permit HellionChat să colaboreze cu alte plugin-uri Dalamud instalate. Fiecare integrare își detectează automat ținta și se dezactivează în liniște când plugin-ul țintă lipsește. diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index 35d9b9e..bf7c5b0 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -63,18 +63,9 @@ Страховочная сеть для типов ChatType, добавленных в будущих патчах FFXIV, которые плагин ещё не знает. По умолчанию ВЫКЛ (минимизация данных). Включите, если хотите, чтобы будущие каналы тоже полностью логировались. - - Применить фильтр к существующей базе данных - Фильтр конфиденциальности действует только на новые сообщения. Очистка ниже позволяет ретроактивно удалить уже сохранённые сообщения, не соответствующие вашему сохранённому белому списку. - - Очистка использует СОХРАНЁННЫЙ белый список (Plugin.Config), а не несохранённые изменения выше. Нажмите «Сохранить» сначала, если хотите применить текущие изменения. - - - Ручной запуск использует СОХРАНЁННУЮ политику хранения, а не значения слайдеров выше. Нажмите «Сохранить» сначала, если хотите применить текущие изменения. - Предпросмотр устарел — ваш белый список изменился с момента последнего обновления. Нажмите «Обновить» для пересчёта. @@ -159,9 +150,6 @@ Применить хранение сейчас - - Ctrl+Shift: Немедленно запускает очистку по хранению, используя СОХРАНЁННУЮ политику. Сначала сохраните изменения. - Очистка по хранению выполняется в фоне… @@ -273,9 +261,6 @@ Оформление - - Загружать предыдущую сессию при запуске - Применять фильтры к сообщениям из предыдущих сессий @@ -318,9 +303,6 @@ Настройки → Hellion Chat для тонкой настройки позже - - Экспорт (GDPR Art. 15 — Право на доступ) - Экспортируйте сохранённые сообщения в формате Markdown, JSON или CSV. Это позволяет выполнить запрос на доступ от лица, чьи сообщения вы сохранили, или забрать собственную историю. @@ -560,12 +542,6 @@ - - Настройки реструктурированы - - - Hellion Chat 0.5.0 реструктурировал настройки по тематическим вкладкам. Ваша база данных чата и история сообщений остались без изменений. Настройки сброшены к значениям по умолчанию. Если вы хотите повторно выбрать профиль конфиденциальности, кнопка «Открыть снова» находится на вкладке Конфиденциальность. Резервная копия предыдущей конфигурации расположена по адресу HellionChat.json.pre-v10-backup рядом с активным файлом конфигурации. - @@ -622,15 +598,6 @@ - - Хранение - - - Обзор - - - Обслуживание - @@ -700,24 +667,6 @@ Возвращает окно чата и все активные всплывающие окна в верхний левый угол основного монитора. Полезно, когда окно оказалось за пределами видимой области после изменения раскладки дисплея (отключён монитор, изменено разрешение). Плагин также выполняет автоматическую проверку границ один раз за сессию; эта кнопка — ручной аварийный выход, если что-то всё равно оказалось недоступным. - - Новое в v0.6.0: теперь можно вводить текст прямо во всплывающих окнах. Включите главный переключатель в настройках окна. - - - Понятно - - - Открыть настройки окна - - - Любую вкладку чата можно открыть в собственном окне. Нажмите иконку окна в правом верхнем углу или кликните правой кнопкой мыши по вкладке. Новое в v0.6.1: ввод во всплывающих окнах активен по умолчанию (можно отключить в разделе Настройки → Окно). - - - Понятно - - - Открыть настройки - Hellion Chat не может запуститься, пока загружен Chat 2. @@ -727,54 +676,6 @@ Отключите Chat 2 в /xlplugins, затем снова включите Hellion Chat. - - Общие - - - Язык, ввод, звук и производительность. - - - Внешний вид - - - Прозрачность окна, шрифты, анимации - - - Темы - - - Выберите тему или импортируйте свою - - - Окно - - - Когда окно видимо и можно ли его перемещать. - - - Чат - - - ЛС, предпросмотр, поведение сообщений и эмоции. - - - Вкладки - - - Создание и настройка пользовательских вкладок чата. - - - База данных - - - Хранение, миграция, очистка устаревшего - - - О плагине - - - Расширения, версия, информация о проекте, переводчики и changelog. - Темы @@ -817,9 +718,6 @@ Данные и конфиденциальность - - Фильтр конфиденциальности, хранение, очистка, экспорт и статистика базы данных. - Theme @@ -838,9 +736,6 @@ Дополнительно (Shift+клик для открытия) - - Hellion Chat 1.2.1 реорганизовал меню настроек и удалил устаревшую опцию «Override style» (заменена системой тем из версии 1.1.0). Остальные настройки не изменились. Прозрачность окна перенесена в раздел «Theme & Layout». Резервная копия предыдущей конфигурации расположена по адресу pluginConfigs/HellionChat.json.pre-v16-backup рядом с активным файлом HellionChat.json. - Интеграции плагинов позволяют HellionChat работать совместно с другими установленными плагинами Dalamud. Каждая интеграция автоматически определяет целевой плагин и тихо отключается, если тот не установлен. diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index 9ebf96b..8ecf1a6 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -63,18 +63,9 @@ Säkerhetsnät för ChatTypes som läggs till av framtida FFXIV-patchar och som pluginen ännu inte känner till. Standard är AV (dataminimering). Aktivera om du vill att framtida kanaler också loggas fullt ut. - - Tillämpa filter på befintlig databas - Sekretessfiltret påverkar bara nya meddelanden. Rensningen nedan låter dig retroaktivt ta bort redan sparade meddelanden som inte matchar din sparade vitlista. - - Rensningen använder din SPARADE vitlista (Plugin.Config), inte osparade ändringar ovan. Klicka Spara först om du vill att dina aktuella ändringar ska tillämpas. - - - Den manuella körningen använder din SPARADE lagringspolicy, inte skjutreglagevärdena ovan. Klicka Spara först om du vill att körningen ska tillämpa dina aktuella ändringar. - Förhandsgranskningen är inaktuell: din vitlista har ändrats sedan senaste uppdateringen. Klicka Uppdatera för att räkna om. @@ -159,9 +150,6 @@ Tillämpa lagring nu - - Ctrl+Shift: Kör lagringsrensningen omedelbart med den SPARADE policyn. Spara dina ändringar först. - Lagringsrensning pågår i bakgrunden… @@ -273,9 +261,6 @@ Utseende - - Läs in föregående session vid start - Tillämpa filter på meddelanden från tidigare sessioner @@ -318,9 +303,6 @@ Inställningar → Hellion Chat för att finjustera senare - - Export (GDPR Art. 15 — Rätt till tillgång) - Exportera sparade meddelanden som Markdown, JSON eller CSV. Det här låter dig uppfylla en begäran om tillgång från en person vars meddelanden du har sparat, eller ta med din egen historik. @@ -560,12 +542,6 @@ - - Inställningar omstrukturerade - - - Hellion Chat 0.5.0 har omstrukturerat inställningarna i tematiska flikar. Din chattdatabas och meddelandehistorik är oförändrade. Inställningarna har återställts till standardvärden. Om du vill välja om din sekretesprofil finns Återöppna-knappen i fliken Sekretess. En säkerhetskopia av den tidigare konfigurationen finns vid HellionChat.json.pre-v10-backup bredvid den aktiva konfigurationsfilen. - @@ -622,15 +598,6 @@ - - Lagring - - - Översikt - - - Underhåll - @@ -700,24 +667,6 @@ Flyttar chattfönstret och alla aktiva pop-out-fönster tillbaka till det övre vänstra hörnet av primärskärmen. Användbart när ett fönster har hamnat utanför det synliga området efter en skärmlayoutändring (skärm frånkopplad, upplösning ändrad). Pluginen utför också en automatisk gränskontroll en gång per session. Den här knappen är den manuella nödutgången om något ändå hamnar oåtkomligt. - - Nytt i v0.6.0: Du kan nu skriva direkt i pop-out-fönster. Aktivera huvudströmbrytaren i fönsterinställningarna. - - - Förstått - - - Öppna fönsterinställningar - - - Du kan öppna valfri chattflik som ett eget fönster. Klicka på fönsterikonen uppe till höger eller högerklicka på fliken. Nytt i v0.6.1: pop-out-inmatning är aktiv som standard (kan inaktiveras under Inställningar → Fönster). - - - Förstått - - - Öppna inställningar - Hellion Chat kan inte starta medan Chat 2 är inläst. @@ -727,54 +676,6 @@ Inaktivera Chat 2 i /xlplugins och aktivera sedan Hellion Chat igen. - - Allmänt - - - Språk, inmatning, ljud och prestanda. - - - Utseende - - - Fönsteropacitet, teckensnitt, rörelse - - - Themes - - - Välj ett theme eller importera ett eget - - - Fönster - - - När fönstret är synligt och om det kan flyttas. - - - Chatt - - - Tells, förhandsgranskning, meddelandebeteende och emotes. - - - Flikar - - - Skapa och konfigurera anpassade chattflikar. - - - Databas - - - Lagring, migrering, äldre rensning - - - Om - - - Tillägg, version, projektinformation, översättare och changelog. - Themes @@ -817,9 +718,6 @@ Data och sekretess - - Sekretessfilter, lagring, rensning, export och databasstatistik. - Theme @@ -838,9 +736,6 @@ Avancerat (Shift+klicka för att öppna) - - Hellion Chat 1.2.1 har omorganiserat inställningsmenyn och tagit bort det gamla alternativet "Åsidosätt stil" (ersatt av theme-systemet från 1.1.0). Dina övriga inställningar är oförändrade. Fönstrets genomskinlighet har migrerats till "Theme & Layout". En säkerhetskopia av den tidigare konfigurationen finns vid pluginConfigs/HellionChat.json.pre-v16-backup bredvid den aktiva HellionChat.json. - Plugin-integrationer låter HellionChat samarbeta med andra installerade Dalamud-plugins. Varje integration identifierar automatiskt sitt mål och inaktiverar sig tyst när målpluginen saknas. diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index e867403..fef7005 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -63,18 +63,9 @@ Eklentinin henüz tanımadığı, gelecekteki FFXIV yamaları tarafından eklenen ChatType'lar için güvenlik ağı. Varsayılan KAPALI'dır (veri minimizasyonu). Gelecekteki kanalların da tam olarak loglanmasını istiyorsan etkinleştir. - - Filtreyi mevcut veritabanına uygula - Gizlilik filtresi yalnızca yeni mesajları etkiler. Aşağıdaki temizlik, kayıtlı beyaz listene uymayan önceden depolanmış mesajları geriye dönük olarak kaldırmanı sağlar. - - Temizlik, yukarıdaki kaydedilmemiş değişiklikler değil KAYITLI beyaz listeni (Plugin.Config) kullanır. Mevcut değişikliklerinin uygulanmasını istiyorsan önce Kaydet'e tıkla. - - - Manuel çalıştırma, yukarıdaki kaydırıcı değerleri değil KAYITLI saklama politikasını kullanır. Mevcut değişikliklerinin uygulanmasını istiyorsan önce Kaydet'e tıkla. - Önizleme güncel değil: beyaz listen son yenilemeden bu yana değişti. Yeniden hesaplamak için Yenile'ye tıkla. @@ -159,9 +150,6 @@ Saklama süresini şimdi uygula - - Ctrl+Shift: KAYITLI politikayı kullanarak saklama temizliğini hemen çalıştırır. Önce değişikliklerini kaydet. - Saklama temizliği arka planda çalışıyor… @@ -273,9 +261,6 @@ Görsel - - Başlangıçta önceki oturumu yükle - Önceki oturumlardan gelen mesajlara filtre uygula @@ -318,9 +303,6 @@ Daha sonra ince ayar için Ayarlar → Hellion Chat - - Dışa aktarma (GDPR Art. 15 — Erişim hakkı) - Saklanan mesajları Markdown, JSON veya CSV olarak dışa aktar. Bu, mesajlarını sakladığın bir kişinin erişim talebini karşılamana veya kendi geçmişini yanında götürmene olanak tanır. @@ -560,12 +542,6 @@ - - Ayarlar yeniden yapılandırıldı - - - Hellion Chat 0.5.0, ayarları tematik sekmelere göre yeniden yapılandırdı. Sohbet veritabanın ve mesaj geçmişin değişmedi. Ayarlar varsayılanlara sıfırlandı. Gizlilik profilini yeniden seçmek istiyorsan, Yeniden Aç düğmesi Gizlilik sekmesindedir. Önceki yapılandırmanın yedeği, aktif yapılandırma dosyasının yanında HellionChat.json.pre-v10-backup olarak bulunmaktadır. - @@ -622,15 +598,6 @@ - - Depolama - - - Genel bakış - - - Bakım - @@ -700,24 +667,6 @@ Sohbet penceresini ve tüm aktif pop-out'ları birincil monitörün sol üst köşesine geri taşır. Ekran düzeni değişikliğinden sonra (monitör bağlantısı kesildi, çözünürlük değişti) bir pencere görünür alanın dışında kaldığında kullanışlıdır. Eklenti ayrıca oturum başına bir kez otomatik sınır denetimi yapar; bu düğme, bir şey hâlâ erişilemez kalırsa kullanılacak manuel kaçış çıkışıdır. - - v0.6.0'da yeni: Artık doğrudan pop-out'larda yazabilirsin. Pencere ayarlarındaki ana anahtarı etkinleştir. - - - Anladım - - - Pencere ayarlarını aç - - - Herhangi bir sohbet sekmesini kendi penceresi olarak açabilirsin. Sağ üstteki pencere ikonuna tıkla ya da sekmeye sağ tıkla. v0.6.1'de yeni: pop-out girişi varsayılan olarak aktiftir (Ayarlar → Pencere altından devre dışı bırakılabilir). - - - Anladım - - - Ayarları aç - Chat 2 yüklüyken Hellion Chat başlatılamaz. @@ -727,54 +676,6 @@ Chat 2'yi /xlplugins'te devre dışı bırak, ardından Hellion Chat'i yeniden etkinleştir. - - Genel - - - Dil, giriş, ses ve performans. - - - Görünüm - - - Pencere opaklığı, yazı tipleri, hareket - - - Themes - - - Bir tema seç veya kendininkini içe aktar - - - Pencere - - - Pencerenin ne zaman görünür olduğu ve taşınıp taşınamayacağı. - - - Sohbet - - - Tell'ler, önizleme, mesaj davranışı ve emote'lar. - - - Sekmeler - - - Özel sohbet sekmeleri oluştur ve yapılandır. - - - Veritabanı - - - Depolama, geçiş, eski temizlik - - - Hakkında - - - Eklentiler, sürüm, proje bilgileri, çevirmenler ve changelog. - Themes @@ -817,9 +718,6 @@ Veri ve gizlilik - - Gizlilik filtresi, saklama süresi, temizlik, dışa aktarma ve veritabanı istatistikleri. - Theme @@ -838,9 +736,6 @@ Gelişmiş (açmak için Shift+tıkla) - - Hellion Chat 1.2.1, ayarlar menüsünü yeniden düzenledi ve eski "Stili geçersiz kıl" seçeneğini kaldırdı (1.1.0'daki tema sistemi tarafından yerini aldı). Kalan ayarların değişmedi. Pencere şeffaflığı "Theme & Layout" bölümüne taşındı. Önceki yapılandırmanın yedeği, aktif HellionChat.json dosyasının yanında pluginConfigs/HellionChat.json.pre-v16-backup olarak bulunmaktadır. - Eklenti entegrasyonları, HellionChat'in yüklü diğer Dalamud eklentileriyle birlikte çalışmasını sağlar. Her entegrasyon hedefini otomatik olarak algılar ve hedef eklenti eksik olduğunda sessizce devre dışı kalır. diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 16d6a65..8c337e5 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -63,18 +63,9 @@ Страховий захід для типів ChatTypes, доданих у майбутніх патчах FFXIV, які плагін ще не знає. За замовчуванням ВИМК (мінімізація даних). Увімкніть, якщо хочете, щоб майбутні канали також повністю журналювались. - - Застосувати фільтр до наявної бази даних - Фільтр конфіденційності діє лише на нові повідомлення. Очищення нижче дозволяє заднім числом видалити вже збережені повідомлення, які не відповідають збереженому білому списку. - - Очищення використовує ЗБЕРЕЖЕНИЙ білий список (Plugin.Config), а не незбережені зміни вище. Спочатку натисніть «Зберегти», якщо хочете застосувати поточні зміни. - - - Ручний запуск використовує ЗБЕРЕЖЕНУ політику зберігання, а не значення повзунків вище. Спочатку натисніть «Зберегти», якщо хочете застосувати поточні зміни. - Попередній перегляд застарів — Ваш білий список змінився після останнього оновлення. Натисніть «Оновити» для перерахунку. @@ -159,9 +150,6 @@ Застосувати термін зберігання зараз - - Ctrl+Shift: Негайно запускає очищення за терміном зберігання, використовуючи ЗБЕРЕЖЕНУ політику. Спочатку збережіть зміни. - Очищення за терміном зберігання виконується у фоновому режимі… @@ -273,9 +261,6 @@ Зовнішній вигляд - - Завантажувати попередній сеанс при запуску - Застосовувати фільтри до повідомлень з попередніх сеансів @@ -318,9 +303,6 @@ Налаштування → Hellion Chat для подальшого тонкого налаштування - - Експорт (GDPR Art. 15 — Право доступу) - Експорт збережених повідомлень у форматі Markdown, JSON або CSV. Це дозволяє виконати запит на доступ від особи, чиї повідомлення Ви зберегли, або взяти власну історію з собою. @@ -560,12 +542,6 @@ - - Налаштування реструктуровано - - - Hellion Chat 0.5.0 реструктурував налаштування на тематичні вкладки. База даних чату та історія повідомлень залишились без змін. Налаштування скинуто до стандартних. Якщо Ви хочете повторно вибрати профіль конфіденційності, кнопка «Показати знову» знаходиться на вкладці «Конфіденційність». Резервна копія попередньої конфігурації знаходиться за адресою HellionChat.json.pre-v10-backup поруч із активним конфігураційним файлом. - @@ -622,15 +598,6 @@ - - Зберігання - - - Огляд - - - Обслуговування - @@ -700,24 +667,6 @@ Переміщує вікно чату та всі активні спливаючі вікна назад у верхній лівий кут основного монітора. Корисно, коли вікно опинилось поза видимою областю після зміни конфігурації дисплея (монітор від'єднано, змінено роздільну здатність). Плагін також виконує автоматичну перевірку меж один раз за сеанс; ця кнопка є ручним запасним виходом, якщо щось все одно залишається недоступним. - - Нове у v0.6.0: тепер можна вводити текст безпосередньо у спливаючих вікнах. Увімкніть головний перемикач у налаштуваннях вікна. - - - Зрозуміло - - - Відкрити налаштування вікна - - - Будь-яку вкладку чату можна відкрити як окреме вікно. Натисніть іконку вікна у верхньому правому куті або клацніть правою кнопкою миші по вкладці. Нове у v0.6.1: введення у спливаючих вікнах активне за замовчуванням (можна вимкнути в розділі Налаштування → Вікно). - - - Зрозуміло - - - Відкрити налаштування - Hellion Chat не може запуститись, поки завантажено Chat 2. @@ -727,54 +676,6 @@ Вимкніть Chat 2 у /xlplugins, а потім знову увімкніть Hellion Chat. - - Загальні - - - Мова, введення, звук і продуктивність. - - - Зовнішній вигляд - - - Непрозорість вікна, шрифти, анімація - - - Теми - - - Вибрати тему або імпортувати власну - - - Вікно - - - Коли вікно видиме та чи можна його переміщати. - - - Чат - - - Tells, попередній перегляд, поведінка повідомлень та емоції. - - - Вкладки - - - Створення та налаштування власних вкладок чату. - - - База даних - - - Зберігання, міграція, видалення застарілого - - - Про плагін - - - Розширення, версія, інформація про проект, перекладачі та changelog. - Теми @@ -817,9 +718,6 @@ Дані та конфіденційність - - Фільтр конфіденційності, термін зберігання, очищення, експорт та статистика бази даних. - Тема @@ -838,9 +736,6 @@ Додаткові параметри (Shift+клік для відкриття) - - Hellion Chat 1.2.1 реорганізував меню налаштувань і видалив стару опцію «Перевизначити стиль» (замінена системою тем із версії 1.1.0). Решта налаштувань залишилась без змін. Прозорість вікна перенесена до розділу «Тема & Макет». Резервна копія попередньої конфігурації знаходиться за адресою pluginConfigs/HellionChat.json.pre-v16-backup поруч із активним HellionChat.json. - Інтеграції плагінів дозволяють HellionChat взаємодіяти з іншими встановленими плагінами Dalamud. Кожна інтеграція автоматично визначає свою ціль і тихо вимикається, якщо цільовий плагін відсутній. diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index 66d8f71..cb8c456 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -63,18 +63,9 @@ 用于兜底处理未来 FFXIV 补丁新增而插件尚未识别的 ChatType。默认为关闭(数据最小化)。如需完整记录未来新增频道,请启用此选项。 - - 将过滤器应用到现有数据库 - 隐私过滤器仅对新消息生效。下方的清理功能可让你追溯删除已保存但不符合当前白名单的消息。 - - 清理使用的是已保存的白名单(Plugin.Config),而非上方未保存的更改。如需应用当前更改,请先点击保存。 - - - 手动运行使用的是已保存的保留策略,而非上方滑块的当前值。如需应用当前更改,请先点击保存。 - 预览已过时,白名单自上次刷新后有所更改。请点击刷新以重新计算。 @@ -159,9 +150,6 @@ 立即应用保留策略 - - Ctrl+Shift:使用已保存的策略立即执行保留清理。请先保存更改。 - 保留清理正在后台运行… @@ -273,9 +261,6 @@ 视觉 - - 启动时加载上次会话 - 将过滤器应用到上次会话的消息 @@ -318,9 +303,6 @@ 进入设置 → Hellion Chat 可进一步微调 - - 导出(GDPR Art. 15 — 访问权) - 将已保存的消息导出为 Markdown、JSON 或 CSV 格式。可用于响应他人的数据访问请求,或将自己的聊天历史带走备份。 @@ -560,12 +542,6 @@ - - 设置已重新整理 - - - Hellion Chat 0.5.0 已将设置重新整理为分主题的标签页。你的聊天数据库和消息历史保持不变。设置已重置为默认值。如需重新选择隐私方案,可在隐私标签页中找到"再次显示向导"按钮。上一版配置的备份文件位于活动配置文件旁边,文件名为 HellionChat.json.pre-v10-backup。 - @@ -622,15 +598,6 @@ - - 存储 - - - 概览 - - - 维护 - @@ -700,24 +667,6 @@ 将聊天窗口及所有活跃的弹出窗口移回主显示器左上角。当显示器布局更改(断开显示器、更改分辨率)导致窗口超出可见区域时很有用。插件每次会话也会自动进行一次边界检查;如果仍有窗口无法访问,可使用此按钮手动修复。 - - v0.6.0 新功能:现在可以直接在弹出窗口中输入。请在窗口设置中启用主开关。 - - - 知道了 - - - 打开窗口设置 - - - 可以将任意聊天标签页作为独立窗口打开。点击右上角的窗口图标,或右键点击标签页即可。v0.6.1 新功能:弹出窗口输入默认已启用(可在设置 → 窗口中关闭)。 - - - 知道了 - - - 打开设置 - Chat 2 处于加载状态时 Hellion Chat 无法启动。 @@ -727,54 +676,6 @@ 请在 /xlplugins 中禁用 Chat 2,然后重新启用 Hellion Chat。 - - 通用 - - - 语言、输入、音频与性能。 - - - 外观 - - - 窗口不透明度、字体、动效 - - - 主题 - - - 选择主题或导入自定义主题 - - - 窗口 - - - 窗口的显示时机以及是否可移动。 - - - 聊天 - - - 密语、预览、消息行为与情感动作。 - - - 标签页 - - - 创建并配置自定义聊天标签页。 - - - 数据库 - - - 存储、迁移、历史清理 - - - 关于 - - - 扩展、版本、项目信息、译者与更新日志。 - 主题 @@ -817,9 +718,6 @@ 数据与隐私 - - 隐私过滤器、保留策略、清理、导出与数据库统计。 - 主题 @@ -838,9 +736,6 @@ 高级选项(Shift+点击展开) - - Hellion Chat 1.2.1 已重新整理设置菜单,并移除了旧版"覆盖样式"选项(已由 1.1.0 引入的主题系统取代)。其余设置保持不变。窗口透明度已迁移至"Theme & Layout"。上一版配置的备份文件位于活动 HellionChat.json 旁边,文件名为 pluginConfigs/HellionChat.json.pre-v16-backup。 - 插件集成功能让 HellionChat 能与其他已安装的 Dalamud 插件协同工作。每项集成会自动检测目标插件,目标插件缺失时将静默禁用。 diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index e8ccac9..8290ce9 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -63,18 +63,9 @@ 為未來 FFXIV 更新新增而插件尚未識別的 ChatType 提供安全網。預設為關閉(資料最小化)。若想讓未來的頻道也完整記錄,請啟用此選項。 - - 將篩選器套用至現有資料庫 - 隱私篩選器只影響新訊息。下方的清理功能讓你可以追溯刪除已儲存但不符合白名單的訊息。 - - 清理使用的是已儲存的白名單(Plugin.Config),而非上方未儲存的變更。若要套用目前的變更,請先點擊儲存。 - - - 手動執行使用的是已儲存的保留原則,而非上方的滑桿數值。若要套用目前的變更,請先點擊儲存。 - 預覽已過時,你的白名單自上次重新整理後已有變更。點擊重新整理以重新計算。 @@ -159,9 +150,6 @@ 立即套用保留原則 - - Ctrl+Shift:使用已儲存的原則立即執行保留清理。請先儲存你的變更。 - 保留清理正在背景執行中… @@ -273,9 +261,6 @@ 視覺 - - 啟動時載入上次的會話 - 將篩選器套用至先前會話的訊息 @@ -318,9 +303,6 @@ 設定 → Hellion Chat 可在之後進行細部調整 - - 匯出(GDPR Art. 15 — 查閱權) - 將已儲存的訊息匯出為 Markdown、JSON 或 CSV 格式。這讓你可以回應你所儲存訊息的當事人提出的查閱請求,或帶走自己的聊天歷史。 @@ -560,12 +542,6 @@ - - 設定已重新整理 - - - Hellion Chat 0.5.0 已將設定重新整理為主題式標籤頁。你的聊天資料庫和訊息歷史保持不變。設定已重設為預設值。若要重新選擇隱私設定檔,請至隱私標籤頁中點擊重新開啟按鈕。先前設定的備份位於 HellionChat.json.pre-v10-backup,與目前使用中的設定檔位於同一目錄。 - @@ -622,15 +598,6 @@ - - 儲存 - - - 概覽 - - - 維護 - @@ -700,24 +667,6 @@ 將聊天視窗和所有已開啟的彈出視窗移回主螢幕左上角。適用於在顯示器配置變更後(螢幕斷線、解析度變更)視窗跑到可見區域外的情況。插件每次工作階段也會自動執行一次邊界檢查;若仍有視窗無法存取,此按鈕是手動應急出口。 - - v0.6.0 新功能:現在可以直接在彈出視窗中輸入。請在視窗設定中啟用主開關。 - - - 了解 - - - 開啟視窗設定 - - - 你可以將任何聊天標籤頁以獨立視窗開啟。點擊右上角的視窗圖示,或右鍵點擊標籤頁。v0.6.1 新功能:彈出視窗輸入功能預設為啟用(可在設定 → 視窗中停用)。 - - - 了解 - - - 開啟設定 - Hellion Chat 無法在 Chat 2 已載入的情況下啟動。 @@ -727,54 +676,6 @@ 請在 /xlplugins 中停用 Chat 2,然後重新啟用 Hellion Chat。 - - 一般 - - - 語言、輸入、音效和效能。 - - - 外觀 - - - 視窗不透明度、字型、動態效果 - - - 佈景主題 - - - 選擇佈景主題或匯入自訂主題 - - - 視窗 - - - 視窗的顯示時機以及是否可以移動。 - - - 聊天 - - - 悄悄話、預覽、訊息行為和情感動作。 - - - 標籤頁 - - - 建立和設定自訂聊天標籤頁。 - - - 資料庫 - - - 儲存、遷移、舊版清理 - - - 關於 - - - 擴充功能、版本、專案資訊、譯者和 Changelog。 - 佈景主題 @@ -817,9 +718,6 @@ 資料與隱私 - - 隱私過濾器、保留、清理、匯出和資料庫統計資訊。 - 佈景主題 @@ -838,9 +736,6 @@ 進階(Shift+點擊開啟) - - Hellion Chat 1.2.1 已重新整理設定選單,並移除舊的「覆寫樣式」選項(已由 1.1.0 的佈景主題系統取代)。你其餘的設定保持不變。視窗透明度已遷移至「Theme & Layout」。先前設定的備份位於 pluginConfigs/HellionChat.json.pre-v16-backup,與目前使用中的 HellionChat.json 位於同一目錄。 - 插件整合功能讓 HellionChat 能與其他已安裝的 Dalamud 插件協同運作。每個整合功能會自動偵測目標插件,並在目標插件不存在時靜默停用自身。 diff --git a/scripts/find-orphan-strings.sh b/scripts/find-orphan-strings.sh new file mode 100755 index 0000000..c621d7e --- /dev/null +++ b/scripts/find-orphan-strings.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Lists resource keys with no caller in the C# sources. +# +# Written for the v1.12.0 cleanup and kept, because the count only stays +# honest if it can be re-measured: every block of that cycle both revived +# orphans and created new ones, so a list taken once is wrong by the next +# commit. +# +# Reports rather than deletes. A key without a caller is a question -- was the +# feature removed on purpose, or did it only lose its button? -- and the answer +# is in the git history, not in this script. +set -euo pipefail +cd "$(dirname "$0")/.." + +for bundle in HellionStrings Language; do + echo "=== ${bundle} ===" + python3 - "$bundle" <<'PY' +import sys, re, pathlib, xml.etree.ElementTree as E + +bundle = sys.argv[1] +root = pathlib.Path("HellionChat") +keys = [d.get("name") for d in E.parse(root / f"Resources/{bundle}.resx").getroot().findall("data")] + +sources = "\n".join( + p.read_text(encoding="utf-8") + for p in root.rglob("*.cs") + if "obj/" not in p.as_posix() and "/bin/" not in p.as_posix() and "Designer.cs" not in p.name +) + +orphans = [k for k in keys if f"{bundle}.{k}" not in sources and f"nameof({k})" not in sources] +print(f"{len(orphans)} of {len(keys)} keys have no caller") +for k in sorted(orphans): + print(f" {k}") +PY +done From 9ea9e96145da936fa614f9f9ac8ee63bcc19f2ae Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 07:09:54 +0200 Subject: [PATCH 126/129] fix: close what the style review found, starting with a gate the metadata skipped The real defect first. RefreshDatabaseMetadata was the one worker of six that never took the shared lock, and its flag was the one of six missing from the tab's busy state. It calls MessageCount, which holds the read lock, so a wipe could start while it was in there -- and the tab would not have known to grey the button, because it could not see the worker. Both halves fixed. The pattern is why: seven near-copies of one worker skeleton, and each copy decided something slightly different. The clear button failed silently when its thread could not start. The most destructive control in the plugin, pressed, and nothing happens, with no way to tell that from a wipe that worked -- while the three harmless workers beside it do report. Maintenance was the mirror: its comment promises refusals are said out loud, and then swallowed the actual failure. Three start-failure paths also bypassed the notify helper that carries the teardown check, three weeks after it was added for exactly that. The database numbers now wait for a real read, like the clear hint already did. Zero bytes and zero messages read as an empty database, not as a number nobody has fetched. SelectionAfterDelete is gone, with its three tests. The accordion has no selection, so its return value went into a discard -- a function answering a question the interface does not ask, with green tests guarding nothing. The project's own self-test README calls that the anti-pattern of record. Six new keys replaced by the translated orphans that already said the same thing. A commit earlier in this cycle is literally called "stop duplicating a key" and these went past it. The duplicate button also had the label "Add", which is the one string out of ninety-four that was never written. Tests: CleanupDeleteTypes had none, and with the failsafe on -- how a fresh config ships -- it is the path every cleanup takes. Four now, including the one that matters: an empty list deletes nothing rather than everything. And a self-test for the gate wiring, which is what would have caught the metadata worker. The unit tests prove the gate works; nothing proved the workers use it. --- HellionChat/Plugin.cs | 16 ++- HellionChat/Privacy/ChannelGroups.cs | 10 +- .../Resources/HellionStrings.Designer.cs | 7 +- HellionChat/Resources/HellionStrings.ca.resx | 21 +-- HellionChat/Resources/HellionStrings.cs.resx | 21 +-- HellionChat/Resources/HellionStrings.da.resx | 21 +-- HellionChat/Resources/HellionStrings.de.resx | 21 +-- HellionChat/Resources/HellionStrings.el.resx | 21 +-- HellionChat/Resources/HellionStrings.es.resx | 21 +-- HellionChat/Resources/HellionStrings.fi.resx | 21 +-- HellionChat/Resources/HellionStrings.fr.resx | 21 +-- HellionChat/Resources/HellionStrings.hu.resx | 21 +-- HellionChat/Resources/HellionStrings.it.resx | 21 +-- HellionChat/Resources/HellionStrings.ja.resx | 21 +-- HellionChat/Resources/HellionStrings.ko.resx | 21 +-- HellionChat/Resources/HellionStrings.nb.resx | 21 +-- HellionChat/Resources/HellionStrings.nl.resx | 21 +-- HellionChat/Resources/HellionStrings.pl.resx | 21 +-- .../Resources/HellionStrings.pt-BR.resx | 21 +-- .../Resources/HellionStrings.pt-PT.resx | 21 +-- HellionChat/Resources/HellionStrings.resx | 21 +-- HellionChat/Resources/HellionStrings.ro.resx | 21 +-- HellionChat/Resources/HellionStrings.ru.resx | 21 +-- HellionChat/Resources/HellionStrings.sv.resx | 21 +-- HellionChat/Resources/HellionStrings.tr.resx | 21 +-- HellionChat/Resources/HellionStrings.uk.resx | 21 +-- .../Resources/HellionStrings.zh-Hans.resx | 21 +-- .../Resources/HellionStrings.zh-Hant.resx | 21 +-- HellionChat/SelfTests/DbGateWiringStep.cs | 131 ++++++++++++++++++ .../Ui/Components/Settings/TabEditor.cs | 8 +- .../Settings/Tabs/DataPrivacyTab.cs | 86 ++++++++---- .../Ui/Components/Settings/Tabs/GeneralTab.cs | 6 +- .../Ui/Components/Settings/Tabs/WindowTab.cs | 4 +- .../Settings/ThemeImportExportRow.cs | 2 +- HellionChat/Util/TabLifecycleHelpers.cs | 18 --- PRIVACY.md | 22 +-- docs/THEME-AUTHORING.md | 4 +- 37 files changed, 310 insertions(+), 529 deletions(-) create mode 100644 HellionChat/SelfTests/DbGateWiringStep.cs diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 9e28c46..91c9397 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -118,6 +118,11 @@ public sealed class Plugin : IAsyncDalamudPlugin internal Commands Commands { get; private set; } = null!; internal GameFunctions.GameFunctions Functions { get; private set; } = null!; internal MessageManager MessageManager { get; private set; } = null!; + + // Reached by the gate-wiring self-test, which has to ask the live tab + // whether it sees a held gate. + internal Ui.Components.Settings.Tabs.DataPrivacyTab DataPrivacyTab { get; private set; } = + null!; internal AutoTellTabsService AutoTellTabsService { get; private set; } = null!; internal IpcManager Ipc { get; private set; } = null!; internal ExtraChat ExtraChat { get; private set; } = null!; @@ -163,10 +168,6 @@ public sealed class Plugin : IAsyncDalamudPlugin // Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race. private int _disposeStarted; - // Set in the first DisposeAsync statement so async callbacks scheduled - // via Framework.RunOnTick (v1.4.8 B3 retention sweep) can early-bail - // before they touch state that has already been torn down. Volatile - // because the tick reads it from a different thread than the writer. // The three hide conditions v1.5.6 evaluated and cf4705e left without a // reader. Advanced once per draw, before any window is drawn. private Util.ChatHideReason _hideReason = Util.ChatHideReason.None; @@ -175,6 +176,10 @@ public sealed class Plugin : IAsyncDalamudPlugin // A cutscene the user dismissed stays dismissed until it ends. internal bool ChatActivationRequested; + // Set in the first DisposeAsync statement so async callbacks scheduled + // via Framework.RunOnTick (v1.4.8 B3 retention sweep) can early-bail + // before they touch state that has already been torn down. Volatile + // because the tick reads it from a different thread than the writer. private volatile bool _isDisposing; // Read by background workers that outlive a teardown -- the export thread @@ -414,6 +419,8 @@ public sealed class Plugin : IAsyncDalamudPlugin InputBar = _host.Services.GetRequiredService(); MainWindow = _host.Services.GetRequiredService(); SettingsWindow = _host.Services.GetRequiredService(); + DataPrivacyTab = + _host.Services.GetRequiredService(); DbViewer = _host.Services.GetRequiredService(); InputPreview = _host.Services.GetRequiredService(); CommandHelpWindow = _host.Services.GetRequiredService(); @@ -485,6 +492,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.OnOpenMainUiRoutesMainWindowStep(this), new SelfTests.TypingIpcStateStep(this), new SelfTests.ConfigMigrationV25Step(this), + new SelfTests.DbGateWiringStep(this), new SelfTests.ChannelPopoutBindStep(this), new SelfTests.HoverStateFootprintStep(), new SelfTests.HonorificHeaderRenderStep(this), diff --git a/HellionChat/Privacy/ChannelGroups.cs b/HellionChat/Privacy/ChannelGroups.cs index 1adae18..8fabbb7 100644 --- a/HellionChat/Privacy/ChannelGroups.cs +++ b/HellionChat/Privacy/ChannelGroups.cs @@ -3,17 +3,15 @@ using HellionChat.Resources; namespace HellionChat.Privacy; -// The eight buckets the privacy surface sorts channels into. Sixty checkboxes in -// one flat list is not a choice anybody makes; eight named groups is. +// The eight buckets the privacy surface sorts channels into. Eighty-nine +// checkboxes in one flat list is not a choice anybody makes; eight named groups +// is. Shared by the export form and the persist grid so the two screens +// describe channels the same way. // // Headings are functions, not strings, so a language switch at runtime relabels // them on the next frame. A captured string would keep the language the window // happened to be opened in. // -// Lives here rather than in a tab because the retention and cleanup screens -// will need the same grouping, and three copies would drift the moment a patch -// adds a channel. Only the export uses it so far. -// // Game Master channels follow ChatTypeExt.Parent(), which already pairs each of // them with its player counterpart. Filing them all under system traffic reads // tidier but puts GmTell -- a private two-person conversation -- outside the diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index 6510312..887fb2f 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -364,9 +364,9 @@ internal class HellionStrings internal static string Settings_Theme_ResetUnavailable => Get(nameof(Settings_Theme_ResetUnavailable)); internal static string Settings_Theme_LockedTooltip => Get(nameof(Settings_Theme_LockedTooltip)); internal static string Settings_Theme_Custom => Get(nameof(Settings_Theme_Custom)); + internal static string Settings_Tabs_Duplicate => Get(nameof(Settings_Tabs_Duplicate)); internal static string Settings_Theme_ForkActive => Get(nameof(Settings_Theme_ForkActive)); internal static string Settings_Theme_ImportFile => Get(nameof(Settings_Theme_ImportFile)); - internal static string Settings_Theme_ExportActive => Get(nameof(Settings_Theme_ExportActive)); internal static string Settings_Theme_ImportPathHint => Get(nameof(Settings_Theme_ImportPathHint)); internal static string Settings_Theme_ExportDialogTitle => Get(nameof(Settings_Theme_ExportDialogTitle)); internal static string Settings_Theme_Category_Cool => Get(nameof(Settings_Theme_Category_Cool)); @@ -395,7 +395,6 @@ internal class HellionStrings internal static string Settings_Section_Integrations => Get(nameof(Settings_Section_Integrations)); internal static string Settings_Section_Credits => Get(nameof(Settings_Section_Credits)); internal static string Settings_Section_License => Get(nameof(Settings_Section_License)); - internal static string Settings_General_ReduceMotion_Description => Get(nameof(Settings_General_ReduceMotion_Description)); internal static string Settings_Keybinds_Hint => Get(nameof(Settings_Keybinds_Hint)); internal static string Settings_Keybinds_CycleNext => Get(nameof(Settings_Keybinds_CycleNext)); internal static string Settings_Keybinds_CyclePrevious => Get(nameof(Settings_Keybinds_CyclePrevious)); @@ -415,8 +414,6 @@ internal class HellionStrings internal static string Settings_Window_TabPlacement_Description => Get(nameof(Settings_Window_TabPlacement_Description)); internal static string Settings_Window_TitleBar_Name => Get(nameof(Settings_Window_TitleBar_Name)); internal static string Settings_Window_PopoutTitleBar_Name => Get(nameof(Settings_Window_PopoutTitleBar_Name)); - internal static string Settings_Window_InactiveOpacity_Name => Get(nameof(Settings_Window_InactiveOpacity_Name)); - internal static string Settings_Window_InactiveOpacity_Description => Get(nameof(Settings_Window_InactiveOpacity_Description)); internal static string Settings_Window_AllowMove_Name => Get(nameof(Settings_Window_AllowMove_Name)); internal static string Settings_Window_AllowResize_Name => Get(nameof(Settings_Window_AllowResize_Name)); internal static string Settings_Window_SidebarThreshold_Name => Get(nameof(Settings_Window_SidebarThreshold_Name)); @@ -427,8 +424,6 @@ internal class HellionStrings internal static string Settings_About_CustomRepo => Get(nameof(Settings_About_CustomRepo)); internal static string Settings_Section_AutoTranslate => Get(nameof(Settings_Section_AutoTranslate)); internal static string Settings_Emotes_Block => Get(nameof(Settings_Emotes_Block)); - internal static string Settings_NotifyFailedTell_Name => Get(nameof(Settings_NotifyFailedTell_Name)); - internal static string Settings_NotifyFailedTell_Description => Get(nameof(Settings_NotifyFailedTell_Description)); internal static string Settings_Telemetry_None => Get(nameof(Settings_Telemetry_None)); internal static string Settings_Section_Database => Get(nameof(Settings_Section_Database)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index 18dba14..7354097 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1094,12 +1094,6 @@ Bloqueja - - Avisa quan un tell no arriba - - - El joc només informa d'un tell fallit al registre. Això mostra una notificació, perquè un missatge a algú desconnectat o en un altre món no passi desapercebut. - Vés al missatge més recent @@ -1163,9 +1157,6 @@ Llicència - - Desactiva els fosos del tema, els efectes en passar el cursor i les partícules. - Fes clic a un botó i després prem la combinació de tecles. Esc esborra. @@ -1223,12 +1214,6 @@ Barra de títol als pop-outs - - Opacitat inactiva - - - S'aplica mentre una altra finestra té el focus. - Permet moure @@ -1307,9 +1292,6 @@ Importa un fitxer de tema… - - Exporta el tema actiu… - Ruta al fitxer JSON (o arrossega'l a la carpeta) @@ -1343,4 +1325,7 @@ Escriu un missatge... + + Duplica + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index d2943ec..aaaa3fa 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1093,12 +1093,6 @@ Zablokovat - - Upozornit, když tell nedorazí - - - Hra hlásí neúspěšný tell jen v logu. Toto místo toho zobrazí upozornění, aby zpráva někomu offline nebo na jiném světě nezůstala bez povšimnutí. - Přejít na nejnovější zprávu @@ -1162,9 +1156,6 @@ Licence - - Vypne prolínání motivů, přechody při najetí myší a poletující částice. - Klikni na tlačítko a poté stiskni kombinaci kláves. Esc smaže. @@ -1222,12 +1213,6 @@ Záhlaví u pop-out oken - - Neprůhlednost při neaktivitě - - - Platí, když má fokus jiné okno. - Povolit přesouvání @@ -1306,9 +1291,6 @@ Importovat soubor motivu… - - Exportovat aktivní motiv… - Cesta k souboru JSON (nebo přetáhni do složky) @@ -1342,4 +1324,7 @@ Napiš zprávu... + + Duplikovat + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index 67f9078..d13777b 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1093,12 +1093,6 @@ Bloker - - Advar når en tell ikke når frem - - - Spillet melder kun en mislykket tell i loggen. Dette giver en notifikation i stedet, så en besked til nogen offline eller på en anden verden ikke overses. - Hop til den seneste besked @@ -1162,9 +1156,6 @@ Licens - - Slår temaovergange, hover-fade og de svævende partikler fra. - Klik på en knap, og tryk derefter tastekombinationen. Esc rydder. @@ -1222,12 +1213,6 @@ Vis titellinje for pop-out-vinduer - - Uigennemsigtighed når inaktiv - - - Gælder, mens et andet vindue har fokus. - Tillad flytning @@ -1306,9 +1291,6 @@ Importér temafil… - - Eksportér aktivt tema… - Sti til JSON-fil (eller træk-og-slip i mappen) @@ -1342,4 +1324,7 @@ Skriv en besked... + + Duplikér + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index d59e4fc..e2760fa 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1088,12 +1088,6 @@ Sperren - - Warnen, wenn ein Flüstern nicht ankommt - - - Das Spiel meldet einen fehlgeschlagenen Flüstern nur im Protokoll. Das hier zeigt stattdessen eine Benachrichtigung, damit eine Nachricht an jemanden offline oder auf einer anderen Welt nicht untergeht. - Zur neuesten Nachricht springen @@ -1157,9 +1151,6 @@ Lizenz - - Schaltet Theme-Überblendungen, Hover-Überblendungen und die schwebenden Partikel ab. - Auf einen Knopf klicken, dann die Tastenkombination drücken. Esc löscht. @@ -1217,12 +1208,6 @@ Titelleiste für Pop-Outs - - Deckkraft im Hintergrund - - - Gilt, solange ein anderes Fenster den Fokus hat. - Verschieben erlauben @@ -1301,9 +1286,6 @@ Theme-Datei importieren… - - Aktives Theme exportieren… - Pfad zur JSON-Datei (oder in den Ordner ziehen) @@ -1337,4 +1319,7 @@ Nachricht schreiben... + + Duplizieren + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index 8241688..f35e762 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1093,12 +1093,6 @@ Αποκλεισμός - - Προειδοποίηση όταν ένα tell δεν φτάνει - - - Το παιχνίδι αναφέρει ένα αποτυχημένο tell μόνο στο αρχείο καταγραφής. Αυτό εμφανίζει ειδοποίηση, ώστε ένα μήνυμα σε κάποιον εκτός σύνδεσης ή σε άλλον κόσμο να μην περάσει απαρατήρητο. - Μετάβαση στο πιο πρόσφατο μήνυμα @@ -1162,9 +1156,6 @@ Άδεια - - Απενεργοποιεί τις μεταβάσεις θέματος, τα εφέ αιώρησης και τα σωματίδια. - Κάντε κλικ σε ένα κουμπί και μετά πατήστε τον συνδυασμό πλήκτρων. Το Esc καθαρίζει. @@ -1222,12 +1213,6 @@ Εμφάνιση γραμμής τίτλου στα αποσπώμενα παράθυρα - - Αδιαφάνεια όταν ανενεργό - - - Ισχύει όσο ένα άλλο παράθυρο έχει την εστίαση. - Να επιτρέπεται η μετακίνηση @@ -1306,9 +1291,6 @@ Εισαγωγή αρχείου θέματος… - - Εξαγωγή ενεργού θέματος… - Διαδρομή προς αρχείο JSON (ή σύρετε στον φάκελο) @@ -1342,4 +1324,7 @@ Γράψτε ένα μήνυμα... + + Αντιγραφή + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index b057d75..105a5c7 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1094,12 +1094,6 @@ Bloquear - - Avisar cuando un tell no llega - - - El juego solo informa de un tell fallido en el registro. Esto muestra una notificación, para que un mensaje a alguien desconectado o en otro mundo no pase desapercibido. - Ir al mensaje más reciente @@ -1163,9 +1157,6 @@ Licencia - - Desactiva las transiciones del tema, los efectos al pasar el cursor y las partículas. - Haz clic en un botón y luego pulsa la combinación de teclas. Esc borra. @@ -1223,12 +1214,6 @@ Barra de título en pop-outs - - Opacidad inactiva - - - Se aplica mientras otra ventana tiene el foco. - Permitir mover @@ -1307,9 +1292,6 @@ Importar archivo de tema… - - Exportar el tema activo… - Ruta al archivo JSON (o arrástralo a la carpeta) @@ -1343,4 +1325,7 @@ Escribe un mensaje... + + Duplicar + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index c770bf9..0c02e98 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1093,12 +1093,6 @@ Estä - - Varoita, kun tell ei mene perille - - - Peli ilmoittaa epäonnistuneesta tellistä vain lokissa. Tämä näyttää sen sijaan ilmoituksen, jottei viesti offline-tilassa tai toisessa maailmassa olevalle jää huomaamatta. - Siirry uusimpaan viestiin @@ -1162,9 +1156,6 @@ Lisenssi - - Poistaa teemaristihäivytykset, osoitinhäivytykset ja leijuvat hiukkaset käytöstä. - Napsauta painiketta ja paina sitten näppäinyhdistelmää. Esc tyhjentää. @@ -1222,12 +1213,6 @@ Näytä otsikkopalkki irrotetuissa ikkunoissa - - Läpinäkymättömyys epäaktiivisena - - - Koskee, kun toinen ikkuna on kohdistettuna. - Salli siirtäminen @@ -1306,9 +1291,6 @@ Tuo teematiedosto… - - Vie aktiivinen teema… - JSON-tiedoston polku (tai vedä kansioon) @@ -1342,4 +1324,7 @@ Kirjoita viesti... + + Kahdenna + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index 361a5c2..93aeb61 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1094,12 +1094,6 @@ Bloquer - - Avertir quand un message privé n'arrive pas - - - Le jeu ne signale un message privé échoué que dans le journal. Ceci affiche une notification à la place, pour qu'un message à quelqu'un hors ligne ou sur un autre monde ne passe pas inaperçu. - Aller au message le plus récent @@ -1163,9 +1157,6 @@ Licence - - Désactive les fondus de thème, les effets de survol et les particules flottantes. - Cliquez sur un bouton, puis appuyez sur la combinaison de touches. Échap efface. @@ -1223,12 +1214,6 @@ Afficher la barre de titre des fenêtres détachées - - Opacité inactive - - - S'applique quand une autre fenêtre a le focus. - Autoriser le déplacement @@ -1307,9 +1292,6 @@ Importer un fichier de thème… - - Exporter le thème actif… - Chemin du fichier JSON (ou glissez-déposez dans le dossier) @@ -1343,4 +1325,7 @@ Écrivez un message... + + Dupliquer + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index d94f370..ed40eb9 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1093,12 +1093,6 @@ Letiltás - - Figyelmeztetés, ha egy tell nem érkezik meg - - - A játék csak a naplóban jelzi a sikertelen tellt. Ez helyette értesítést jelenít meg, hogy egy offline vagy másik világon lévő játékosnak küldött üzenet ne maradjon észrevétlen. - Ugrás a legutóbbi üzenetre @@ -1162,9 +1156,6 @@ Licenc - - Kikapcsolja a témaátmeneteket, a rámutatási elhalványulást és a lebegő részecskéket. - Kattints egy gombra, majd nyomd le a billentyűkombinációt. Az Esc törli. @@ -1222,12 +1213,6 @@ Címsor a pop-out ablakoknál - - Átlátszatlanság inaktívan - - - Akkor érvényes, ha másik ablak van fókuszban. - Mozgatás engedélyezése @@ -1306,9 +1291,6 @@ Témafájl importálása… - - Aktív téma exportálása… - A JSON-fájl útvonala (vagy húzd a mappába) @@ -1342,4 +1324,7 @@ Írj egy üzenetet... + + Másolat + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index a08b665..98a745c 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1094,12 +1094,6 @@ Blocca - - Avvisa quando un tell non arriva - - - Il gioco segnala un tell fallito solo nel registro. Questo mostra invece una notifica, così un messaggio a qualcuno offline o in un altro mondo non passa inosservato. - Vai al messaggio più recente @@ -1163,9 +1157,6 @@ Licenza - - Disattiva le dissolvenze del tema, gli effetti al passaggio del mouse e le particelle. - Fai clic su un pulsante, poi premi la combinazione di tasti. Esc cancella. @@ -1223,12 +1214,6 @@ Barra del titolo nei pop-out - - Opacità finestra inattiva - - - Si applica mentre un'altra finestra ha il focus. - Consenti lo spostamento @@ -1307,9 +1292,6 @@ Importa file tema… - - Esporta il tema attivo… - Percorso del file JSON (o trascinalo nella cartella) @@ -1343,4 +1325,7 @@ Scrivi un messaggio... + + Duplica + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index 3716ff0..9b23c70 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1094,12 +1094,6 @@ ブロック - - テルが届かなかったときに警告する - - - ゲームはテルの失敗をログにしか表示しません。この設定では代わりに通知を出すので、オフラインや別ワールドの相手へのメッセージを見落としません。 - 最新のメッセージへ移動 @@ -1163,9 +1157,6 @@ ライセンス - - テーマのクロスフェード、ホバー時のフェード、漂う粒子を無効にします。 - ボタンをクリックしてからキーの組み合わせを押してください。Esc で解除します。 @@ -1223,12 +1214,6 @@ ポップアウトのタイトルバーを表示 - - 非アクティブ時の不透明度 - - - 別のウィンドウがフォーカスされている間に適用されます。 - 移動を許可 @@ -1307,9 +1292,6 @@ テーマファイルをインポート… - - 現在のテーマをエクスポート… - JSONファイルのパス(またはフォルダーにドラッグ&ドロップ) @@ -1343,4 +1325,7 @@ メッセージを入力... + + 複製 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 6f68f84..47cc108 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1094,12 +1094,6 @@ 차단 - - 귓속말이 전달되지 않으면 알림 - - - 게임은 귓속말 실패를 로그에만 표시합니다. 이 설정은 대신 알림을 띄워, 오프라인이거나 다른 월드에 있는 상대에게 보낸 메시지를 놓치지 않게 합니다. - 최신 메시지로 이동 @@ -1163,9 +1157,6 @@ 라이선스 - - 테마 전환 효과, 마우스 오버 페이드, 떠다니는 입자를 끕니다. - 버튼을 클릭한 뒤 키 조합을 누르세요. Esc로 지웁니다. @@ -1223,12 +1214,6 @@ 팝아웃 창의 제목 표시줄 보이기 - - 비활성 상태 불투명도 - - - 다른 창이 포커스를 가진 동안 적용됩니다. - 이동 허용 @@ -1307,9 +1292,6 @@ 테마 파일 가져오기… - - 현재 테마 내보내기… - JSON 파일 경로 (또는 폴더로 끌어다 놓기) @@ -1343,4 +1325,7 @@ 메시지를 입력하세요... + + 복제 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index 815fe59..f3824e9 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1093,12 +1093,6 @@ Blokker - - Varsle når en tell ikke kommer frem - - - Spillet melder en mislykket tell bare i loggen. Dette gir et varsel i stedet, så en melding til noen offline eller på en annen verden ikke blir oversett. - Hopp til den nyeste meldingen @@ -1162,9 +1156,6 @@ Lisens - - Slår av temaovergang, hover-toning og de drivende partiklene. - Klikk på en knapp, og trykk deretter tastekombinasjonen. Esc tømmer. @@ -1222,12 +1213,6 @@ Tittellinje for pop-out-vinduer - - Dekkevne når inaktiv - - - Gjelder mens et annet vindu har fokus. - Tillat flytting @@ -1306,9 +1291,6 @@ Importer temafil… - - Eksporter aktivt tema… - Sti til JSON-fil (eller dra og slipp i mappen) @@ -1342,4 +1324,7 @@ Skriv en melding... + + Dupliser + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 1773a17..8e5fed6 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1094,12 +1094,6 @@ Blokkeren - - Waarschuwen als een tell niet aankomt - - - Het spel meldt een mislukte tell alleen in het log. Dit toont in plaats daarvan een melding, zodat een bericht aan iemand offline of op een andere wereld niet onopgemerkt blijft. - Ga naar het nieuwste bericht @@ -1163,9 +1157,6 @@ Licentie - - Schakelt themaovergangen, hover-fades en de zwevende deeltjes uit. - Klik op een knop en druk daarna de toetsencombinatie. Esc wist. @@ -1223,12 +1214,6 @@ Titelbalk voor pop-outvensters - - Dekking bij inactiviteit - - - Geldt terwijl een ander venster de focus heeft. - Verplaatsen toestaan @@ -1307,9 +1292,6 @@ Themabestand importeren… - - Actief thema exporteren… - Pad naar JSON-bestand (of sleep het naar de map) @@ -1343,4 +1325,7 @@ Typ een bericht... + + Dupliceren + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index 6cea3dc..8164f55 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1093,12 +1093,6 @@ Zablokuj - - Ostrzegaj, gdy tell nie dotrze - - - Gra zgłasza nieudany tell tylko w dzienniku. To zamiast tego wyświetla powiadomienie, żeby wiadomość do kogoś offline lub na innym świecie nie umknęła. - Przejdź do najnowszej wiadomości @@ -1162,9 +1156,6 @@ Licencja - - Wyłącza przenikanie motywów, wygaszanie przy najechaniu i unoszące się cząstki. - Kliknij przycisk, a następnie naciśnij kombinację klawiszy. Esc czyści. @@ -1222,12 +1213,6 @@ Pokaż pasek tytułu w oknach odłączonych - - Nieprzezroczystość, gdy nieaktywne - - - Obowiązuje, gdy inne okno ma fokus. - Zezwól na przesuwanie @@ -1306,9 +1291,6 @@ Importuj plik motywu… - - Eksportuj aktywny motyw… - Ścieżka do pliku JSON (lub przeciągnij do folderu) @@ -1342,4 +1324,7 @@ Napisz wiadomość... + + Duplikuj + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index c185c79..4c418ea 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1094,12 +1094,6 @@ Bloquear - - Avisar quando um tell não chega - - - O jogo relata um tell que falhou apenas no registro. Isto mostra uma notificação, para que uma mensagem a alguém offline ou em outro mundo não passe despercebida. - Ir para a mensagem mais recente @@ -1163,9 +1157,6 @@ Licença - - Desativa as transições de tema, os efeitos ao passar o cursor e as partículas. - Clique em um botão e depois pressione a combinação de teclas. Esc limpa. @@ -1223,12 +1214,6 @@ Mostrar barra de título nas janelas destacadas - - Opacidade inativa - - - Aplica-se enquanto outra janela está em foco. - Permitir mover @@ -1307,9 +1292,6 @@ Importar arquivo de tema… - - Exportar o tema ativo… - Caminho do arquivo JSON (ou arraste para a pasta) @@ -1343,4 +1325,7 @@ Digite uma mensagem... + + Duplicar + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 925e834..361a83f 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1093,12 +1093,6 @@ Bloquear - - Avisar quando um tell não chega - - - O jogo comunica um tell falhado apenas no registo. Isto mostra uma notificação, para que uma mensagem a alguém offline ou noutro mundo não passe despercebida. - Ir para a mensagem mais recente @@ -1162,9 +1156,6 @@ Licença - - Desativa as transições de tema, os efeitos ao passar o cursor e as partículas. - Clique num botão e depois prima a combinação de teclas. Esc limpa. @@ -1222,12 +1213,6 @@ Mostrar barra de título nas janelas destacadas - - Opacidade inativa - - - Aplica-se enquanto outra janela está em foco. - Permitir mover @@ -1306,9 +1291,6 @@ Importar ficheiro de tema… - - Exportar o tema ativo… - Caminho do ficheiro JSON (ou arraste para a pasta) @@ -1342,4 +1324,7 @@ Escreva uma mensagem... + + Duplicar + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index b93a3de..087d394 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1114,12 +1114,6 @@ Block - - Warn when a tell does not arrive - - - The game reports a failed tell in the log only. This raises a notification instead, so a message to someone offline or on another world does not go unnoticed. - off @@ -1174,9 +1168,6 @@ License - - Turns off theme crossfades, hover fades and the drifting motes. - Click a button, then press the key combination. Esc clears. @@ -1234,12 +1225,6 @@ Show title bar for pop-outs - - Inactive opacity - - - Applies while another window has focus. - Allow movement @@ -1318,9 +1303,6 @@ Import theme file… - - Export active theme… - Path to JSON file (or drag-and-drop into the folder) @@ -1354,4 +1336,7 @@ Type a message... + + Duplicate + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index a7753e9..cc06371 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1094,12 +1094,6 @@ Blochează - - Avertizează când un tell nu ajunge - - - Jocul raportează un tell eșuat doar în jurnal. Aceasta afișează în schimb o notificare, ca un mesaj către cineva offline sau aflat pe altă lume să nu treacă neobservat. - Salt la cel mai recent mesaj @@ -1163,9 +1157,6 @@ Licență - - Dezactivează tranzițiile temei, efectele la trecerea cursorului și particulele. - Fă clic pe un buton, apoi apasă combinația de taste. Esc șterge. @@ -1223,12 +1214,6 @@ Afișează bara de titlu la ferestrele desprinse - - Opacitate când e inactiv - - - Se aplică cât timp altă fereastră are focusul. - Permite mutarea @@ -1307,9 +1292,6 @@ Importă fișier de temă… - - Exportă tema activă… - Calea către fișierul JSON (sau trage-l în folder) @@ -1343,4 +1325,7 @@ Scrie un mesaj... + + Duplică + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index bf7c5b0..de4d70d 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1094,12 +1094,6 @@ Заблокировать - - Предупреждать, когда tell не доходит - - - Игра сообщает о недоставленном tell только в журнале. Здесь вместо этого показывается уведомление, чтобы сообщение офлайн-игроку или игроку с другого мира не осталось незамеченным. - Перейти к последнему сообщению @@ -1163,9 +1157,6 @@ Лицензия - - Отключает переходы темы, затухание при наведении и плавающие частицы. - Нажмите кнопку, затем нажмите сочетание клавиш. Esc очищает. @@ -1223,12 +1214,6 @@ Показывать заголовок в отдельных окнах - - Непрозрачность в неактивном состоянии - - - Действует, пока фокус на другом окне. - Разрешить перемещение @@ -1307,9 +1292,6 @@ Импортировать файл темы… - - Экспортировать активную тему… - Путь к файлу JSON (или перетащите его в папку) @@ -1343,4 +1325,7 @@ Введите сообщение... + + Дублировать + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index 8ecf1a6..c77e20a 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1094,12 +1094,6 @@ Blockera - - Varna när en tell inte kommer fram - - - Spelet rapporterar en misslyckad tell bara i loggen. Det här visar en avisering i stället, så att ett meddelande till någon offline eller på en annan värld inte missas. - Hoppa till det senaste meddelandet @@ -1163,9 +1157,6 @@ Licens - - Stänger av temaövertoningar, hovertoningar och de svävande partiklarna. - Klicka på en knapp och tryck sedan tangentkombinationen. Esc rensar. @@ -1223,12 +1214,6 @@ Namnlist för pop-out-fönster - - Opacitet vid inaktivitet - - - Gäller medan ett annat fönster har fokus. - Tillåt flytt @@ -1307,9 +1292,6 @@ Importera temafil… - - Exportera aktivt tema… - Sökväg till JSON-fil (eller dra och släpp i mappen) @@ -1343,4 +1325,7 @@ Skriv ett meddelande... + + Duplicera + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index fef7005..7d95d2c 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1093,12 +1093,6 @@ Engelle - - Bir tell ulaşmadığında uyar - - - Oyun başarısız bir tell'i yalnızca kayıtta bildirir. Bu ayar bunun yerine bir bildirim gösterir, böylece çevrimdışı ya da başka bir dünyadaki birine gönderilen ileti gözden kaçmaz. - En son iletiye git @@ -1162,9 +1156,6 @@ Lisans - - Tema geçişlerini, üzerine gelme solmalarını ve süzülen parçacıkları kapatır. - Bir düğmeye tıklayın, ardından tuş kombinasyonuna basın. Esc temizler. @@ -1222,12 +1213,6 @@ Ayrık pencereler için başlık çubuğunu göster - - Etkin değilken opaklık - - - Başka bir pencere odaktayken geçerlidir. - Taşımaya izin ver @@ -1306,9 +1291,6 @@ Tema dosyası içe aktar… - - Etkin temayı dışa aktar… - JSON dosyasının yolu (veya klasöre sürükleyip bırakın) @@ -1342,4 +1324,7 @@ Bir ileti yazın... + + Çoğalt + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 8c337e5..403ad43 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1093,12 +1093,6 @@ Заблокувати - - Попереджати, коли tell не доходить - - - Гра повідомляє про недоставлений tell лише в журналі. Це натомість показує сповіщення, щоб повідомлення офлайн-гравцю або гравцю з іншого світу не залишилося непоміченим. - Перейти до останнього повідомлення @@ -1162,9 +1156,6 @@ Ліцензія - - Вимикає переходи теми, згасання при наведенні та плаваючі частинки. - Натисніть кнопку, потім натисніть комбінацію клавіш. Esc очищає. @@ -1222,12 +1213,6 @@ Показувати рядок заголовка у відокремлених вікнах - - Непрозорість у неактивному стані - - - Діє, поки фокус на іншому вікні. - Дозволити переміщення @@ -1306,9 +1291,6 @@ Імпортувати файл теми… - - Експортувати активну тему… - Шлях до файлу JSON (або перетягніть у теку) @@ -1342,4 +1324,7 @@ Введіть повідомлення... + + Дублювати + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index cb8c456..a21a232 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1094,12 +1094,6 @@ 屏蔽 - - 密语未送达时提醒 - - - 游戏只在日志里提示密语发送失败。此设置改为弹出通知,让发给离线或其他世界玩家的消息不会被忽略。 - 跳到最新消息 @@ -1163,9 +1157,6 @@ 许可证 - - 关闭主题淡入淡出、悬停渐变和漂浮粒子。 - 点击按钮后按下组合键。Esc 清除。 @@ -1223,12 +1214,6 @@ 显示弹出窗口的标题栏 - - 非活动时的不透明度 - - - 当另一个窗口获得焦点时生效。 - 允许移动 @@ -1307,9 +1292,6 @@ 导入主题文件… - - 导出当前主题… - JSON 文件路径(或拖放到文件夹) @@ -1343,4 +1325,7 @@ 输入消息... + + 复制 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index 8290ce9..f4026a6 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1094,12 +1094,6 @@ 封鎖 - - 悄悄話未送達時提醒 - - - 遊戲只在日誌裡提示悄悄話傳送失敗。此設定改為彈出通知,讓發給離線或其他世界玩家的訊息不會被忽略。 - 跳到最新訊息 @@ -1163,9 +1157,6 @@ 授權 - - 關閉主題淡入淡出、滑過漸變和漂浮粒子。 - 點擊按鈕後按下組合鍵。Esc 清除。 @@ -1223,12 +1214,6 @@ 顯示彈出視窗的標題列 - - 非作用中的不透明度 - - - 當另一個視窗取得焦點時生效。 - 允許移動 @@ -1307,9 +1292,6 @@ 匯入主題檔案… - - 匯出目前主題… - JSON 檔案路徑(或拖放到資料夾) @@ -1343,4 +1325,7 @@ 輸入訊息... + + 複製 + \ No newline at end of file diff --git a/HellionChat/SelfTests/DbGateWiringStep.cs b/HellionChat/SelfTests/DbGateWiringStep.cs new file mode 100644 index 0000000..573d199 --- /dev/null +++ b/HellionChat/SelfTests/DbGateWiringStep.cs @@ -0,0 +1,131 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Ui.Components.Settings; +using HellionChat.Util; + +namespace HellionChat.SelfTests; + +// The gate has fourteen unit tests. None of them prove that the workers are +// wired to it, and that is where the mistake actually happened: the metadata +// refresh reached the store without taking the gate at all, and its flag was +// missing from the tab's shared busy state, so a wipe could start while it held +// the read lock. +// +// A unit test cannot see that. It needs the real singleton, the real flags and +// the real settings tab, so it lives here. +internal sealed class DbGateWiringStep : ISelfTestStep +{ + private readonly Plugin _plugin; + + public DbGateWiringStep(Plugin plugin) => _plugin = plugin; + + public string Name => "Hellion Chat - DB gate wiring"; + + public SelfTestStepResult RunStep() + { + var failures = new List(); + + // Nothing may be running when the probe starts, or the assertions below + // measure somebody else's operation. + if (_plugin.DbOperations.IsBusy) + failures.Add($"gate already held by {_plugin.DbOperations.Current} before the probe"); + + CheckEveryOperationRoundTrips(failures); + CheckRefusalLeavesTheOwnerAlone(failures); + CheckRevisionTracksMutations(failures); + CheckTabSeesTheGate(failures); + + foreach (var f in failures) + ImGui.Text(f); + + SelfTestReport.Append(Name, failures.Count == 0 ? "PASS" : "FAIL", failures); + return failures.Count == 0 ? SelfTestStepResult.Pass : SelfTestStepResult.Fail; + } + + private void CheckEveryOperationRoundTrips(List failures) + { + foreach (var op in EnumValues.All) + { + if (op == DbOperation.None) + continue; + + if (!_plugin.DbOperations.TryBegin(op)) + { + failures.Add($"{op}: TryBegin refused on a free gate"); + continue; + } + + if (_plugin.DbOperations.Current != op) + failures.Add($"{op}: gate reports {_plugin.DbOperations.Current} after TryBegin"); + + if (_plugin.DbOperations.TryBegin(op)) + failures.Add($"{op}: a second TryBegin succeeded while it was held"); + + _plugin.DbOperations.End(op); + + if (_plugin.DbOperations.IsBusy) + failures.Add($"{op}: still busy after End"); + } + } + + private void CheckRefusalLeavesTheOwnerAlone(List failures) + { + if (!_plugin.DbOperations.TryBegin(DbOperation.Export)) + { + failures.Add("could not take the gate for the refusal check"); + return; + } + + // A worker whose TryBegin was refused still runs its finally. Releasing + // there must not hand away somebody else's lock. + _plugin.DbOperations.End(DbOperation.Clear); + + if (_plugin.DbOperations.Current != DbOperation.Export) + failures.Add("a foreign End released the gate"); + + _plugin.DbOperations.End(DbOperation.Export); + } + + private void CheckRevisionTracksMutations(List failures) + { + var before = _plugin.DbOperations.Revision; + + _plugin.DbOperations.TryBegin(DbOperation.Export); + _plugin.DbOperations.End(DbOperation.Export); + if (_plugin.DbOperations.Revision != before) + failures.Add("export moved the revision; it cannot change a row"); + + _plugin.DbOperations.TryBegin(DbOperation.Cleanup); + _plugin.DbOperations.End(DbOperation.Cleanup); + if (_plugin.DbOperations.Revision == before) + failures.Add( + "cleanup did not move the revision; a stale preview would pass as current" + ); + } + + // The settings tab decides whether the destructive buttons are live. If it + // cannot see a held gate, two of them are clickable at once. + private void CheckTabSeesTheGate(List failures) + { + if (!_plugin.DbOperations.TryBegin(DbOperation.Clear)) + { + failures.Add("could not take the gate for the tab check"); + return; + } + + try + { + if (!_plugin.DataPrivacyTab.AnythingRunningForSelfTest) + failures.Add("the data and privacy tab does not see a held gate"); + } + finally + { + _plugin.DbOperations.End(DbOperation.Clear); + } + + if (_plugin.DataPrivacyTab.AnythingRunningForSelfTest) + failures.Add("the tab still reports busy after the gate was released"); + } + + public void CleanUp() { } +} diff --git a/HellionChat/Ui/Components/Settings/TabEditor.cs b/HellionChat/Ui/Components/Settings/TabEditor.cs index 739ed1a..524d297 100644 --- a/HellionChat/Ui/Components/Settings/TabEditor.cs +++ b/HellionChat/Ui/Components/Settings/TabEditor.cs @@ -171,7 +171,12 @@ internal sealed class TabEditor // already has its sixty channels picked, and Tab.Clone has been ready // for it since v1.8.0. ImGui.SameLine(); - if (ImGuiUtil.IconButton(FontAwesomeIcon.Copy, tooltip: Language.Options_Tabs_Add)) + if ( + ImGuiUtil.IconButton( + FontAwesomeIcon.Copy, + tooltip: HellionStrings.Settings_Tabs_Duplicate + ) + ) { var copy = tab.Clone(); copy.Identifier = Guid.NewGuid(); @@ -191,7 +196,6 @@ internal sealed class TabEditor if (_editing == tab.Identifier) ClearWorking(); - _ = TabLifecycleHelpers.SelectionAfterDelete(tabs, index); _plugin.SaveConfig(); RequestRefilter(); } diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs index 1841ce3..e7ea970 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -69,6 +69,11 @@ internal sealed class DataPrivacyTab // refusal the user has to trigger to discover is not an answer. private DbOperation CurrentOperation => _plugin.DbOperations.Current; + // The gate-wiring self-test asserts that this sees a held gate. It is the + // one thing about this state that a unit test cannot reach: the flags are + // instance state on a DI singleton and the gate is another. + internal bool AnythingRunningForSelfTest => AnythingRunning; + private bool AnythingRunning => _exportRunning || _exportDialogOpen @@ -76,6 +81,7 @@ internal sealed class DataPrivacyTab || _cleanupRunning || _clearRunning || _maintenanceRunning + || _dbRefreshRunning || _plugin.RetentionSweepRunning || CurrentOperation != DbOperation.None; @@ -380,21 +386,27 @@ internal sealed class DataPrivacyTab ImGuiUtil.Tooltip(Language.Options_Database_Metadata_CopyConfigPath); } - ImGuiUtil.HelpText( - string.Format( - Language.Options_Database_Metadata_Size, - StringUtil.BytesToString(_dbSize) - ) - ); - ImGuiUtil.HelpText( - string.Format( - Language.Options_Database_Metadata_LogSize, - StringUtil.BytesToString(_dbLogSize) - ) - ); - ImGuiUtil.HelpText( - string.Format(Language.Options_Database_Metadata_MessageCount, _dbMessageCount) - ); + // Same reason the clear hint below waits: the fields start at zero, and + // "0 B / 0 messages" reads as an empty database rather than as a number + // that has not been fetched yet. + if (_dbEverRefreshed) + { + ImGuiUtil.HelpText( + string.Format( + Language.Options_Database_Metadata_Size, + StringUtil.BytesToString(_dbSize) + ) + ); + ImGuiUtil.HelpText( + string.Format( + Language.Options_Database_Metadata_LogSize, + StringUtil.BytesToString(_dbLogSize) + ) + ); + ImGuiUtil.HelpText( + string.Format(Language.Options_Database_Metadata_MessageCount, _dbMessageCount) + ); + } ImGui.Spacing(); @@ -458,11 +470,25 @@ internal sealed class DataPrivacyTab { try { - _dbSize = _plugin.MessageManager.Store.DatabaseSize(); - _dbLogSize = _plugin.MessageManager.Store.DatabaseLogSize(); - _dbMessageCount = _plugin.MessageManager.Store.MessageCount(); - _dbRefreshedAt = Environment.TickCount64; - _dbEverRefreshed = true; + // Takes the gate like every other reader of the store. + // MessageCount holds _readLock, and a VACUUM starting under it + // is exactly the collision this gate was written for -- being a + // read rather than a write does not exempt it. + if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup)) + return; + + try + { + _dbSize = _plugin.MessageManager.Store.DatabaseSize(); + _dbLogSize = _plugin.MessageManager.Store.DatabaseLogSize(); + _dbMessageCount = _plugin.MessageManager.Store.MessageCount(); + _dbRefreshedAt = Environment.TickCount64; + _dbEverRefreshed = true; + } + finally + { + _plugin.DbOperations.End(DbOperation.Cleanup); + } } catch (Exception e) { @@ -561,6 +587,11 @@ internal sealed class DataPrivacyTab { _clearRunning = false; _logger.LogError(e, "Could not start the clear thread"); + + // The most destructive button in the plugin. Silence here means the + // user pressed it and nothing happened, with no way to tell that + // from a wipe that worked. + Notify(HellionStrings.Settings_Database_ClearError, NotificationType.Error); } } @@ -682,6 +713,11 @@ internal sealed class DataPrivacyTab catch (Exception e) { _logger.LogError(e, "Manual maintenance failed"); + + // The comment above promises refusals are said out loud. A + // failure that is not is the same silence wearing a different + // hat. + Notify(HellionStrings.Settings_Database_ClearError, NotificationType.Error); } finally { @@ -1047,7 +1083,7 @@ internal sealed class DataPrivacyTab { _cleanupRunning = false; _logger.LogError(e, "Could not start the cleanup thread"); - WrapperUtil.AddNotification(HellionStrings.Cleanup_Error, NotificationType.Error); + Notify(HellionStrings.Cleanup_Error, NotificationType.Error); } } @@ -1106,9 +1142,9 @@ internal sealed class DataPrivacyTab ); } - // Whole groups rather than sixty individual channels. Nobody makes a - // sixty-way choice, and the eight groups are the same ones the privacy - // wizard already uses, so the two screens describe the world the same way. + // Whole groups rather than eighty-nine individual channels. Nobody makes an + // eighty-nine-way choice, and the same eight groups carry the persist grid + // below, so the two screens describe channels the same way. private void DrawExportChannels() { ImGui.Spacing(); @@ -1334,7 +1370,7 @@ internal sealed class DataPrivacyTab // The thread never ran, so nothing will clear the flag for us. _exportRunning = false; _logger.LogError(e, "Could not start the export thread"); - WrapperUtil.AddNotification(HellionStrings.Export_Error, NotificationType.Error); + Notify(HellionStrings.Export_Error, NotificationType.Error); } } diff --git a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs index f7e0c31..fb8328c 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs @@ -44,7 +44,7 @@ internal sealed class GeneralTab _w.ToggleRow( ImGui.GetID("general.behaviour.reducemotion"u8), HellionStrings.Settings_ThemeAndLayout_ReduceMotion_Name, - HellionStrings.Settings_General_ReduceMotion_Description, + HellionStrings.Settings_ThemeAndLayout_ReduceMotion_Description, () => Plugin.Config.ReduceMotion, v => Plugin.Config.ReduceMotion = v ); @@ -80,8 +80,8 @@ internal sealed class GeneralTab // is typing does not see it. On by default and never reachable. _w.ToggleRow( ImGui.GetID("general.notifications.failedtell"u8), - HellionStrings.Settings_NotifyFailedTell_Name, - HellionStrings.Settings_NotifyFailedTell_Description, + HellionStrings.Settings_Chat_NotifyFailedTell_Name, + HellionStrings.Settings_Chat_NotifyFailedTell_Description, () => Plugin.Config.NotifyFailedTell, v => Plugin.Config.NotifyFailedTell = v ); diff --git a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs index dd45dc6..885b6df 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs @@ -93,8 +93,8 @@ internal sealed class WindowTab ); _w.SliderFloatRow( ImGui.GetID("window.opacity.inactive"u8), - HellionStrings.Settings_Window_InactiveOpacity_Name, - HellionStrings.Settings_Window_InactiveOpacity_Description, + HellionStrings.Settings_ThemeAndLayout_WindowOpacityInactive_Name, + HellionStrings.Settings_ThemeAndLayout_WindowOpacityInactive_Description, () => Plugin.Config.WindowOpacityInactive, v => Plugin.Config.WindowOpacityInactive = v, 0.1f, diff --git a/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs b/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs index 1656a08..2801923 100644 --- a/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs +++ b/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs @@ -39,7 +39,7 @@ internal sealed class ThemeImportExportRow } ImGui.SameLine(); - if (ImGui.Button(HellionStrings.Settings_Theme_ExportActive)) + if (ImGui.Button(HellionStrings.Settings_Themes_ExportActive)) { ExportActive(); } diff --git a/HellionChat/Util/TabLifecycleHelpers.cs b/HellionChat/Util/TabLifecycleHelpers.cs index f08aaa6..5c9887f 100644 --- a/HellionChat/Util/TabLifecycleHelpers.cs +++ b/HellionChat/Util/TabLifecycleHelpers.cs @@ -52,24 +52,6 @@ internal static class TabLifecycleHelpers return index; } - // Which tab the editor should select after deleting the one at index. - // - // The next editable tab below, else the one above, else nothing. Returning - // the index the caller should select *after* the removal, so it is already - // shifted. - public static int SelectionAfterDelete(IReadOnlyList tabs, int deletedIndex) - { - for (var i = deletedIndex + 1; i < tabs.Count; i++) - if (IsEditable(tabs[i])) - return i - 1; - - for (var i = deletedIndex - 1; i >= 0; i--) - if (IsEditable(tabs[i])) - return i; - - return -1; - } - // Deleting the last editable tab leaves a window with nothing to draw, and // the message list has no empty state. The editor offers templates instead // of a delete in that situation. diff --git a/PRIVACY.md b/PRIVACY.md index 1918b8f..a6fedca 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -22,8 +22,8 @@ Last reviewed: 2026-08-18 (HellionChat v1.12.0). no remote update check beyond what Dalamud itself does. - One outbound network call exists by design: the BetterTTV emote service (for chat emotes). It is documented in detail below and can be reasoned about per request. -- You can export every message the plugin has stored, in Markdown, JSON or CSV. You can delete it - by channel, by age, or all of it at once. +- You can export every message the plugin has stored, in Markdown, JSON or CSV. You can delete it by + channel, by age, or all of it at once. --- @@ -96,8 +96,9 @@ font is bundled instead. incoming chat message contains a token matching one of the cached IDs. These are cached locally (`EmoteCacheV1/`) and reused across sessions. - **Cached:** Yes, in `EmoteCacheV1/`. A given emote is downloaded once per machine and reused. -- **How to opt out:** Turn off the **Show emotes** option in Settings → Chat → Display modes. With it disabled, the - emote cache does not load and no requests to BetterTTV are made for the rest of the session. +- **How to opt out:** Turn off the **Show emotes** option in Settings → Chat → Display modes. With + it disabled, the emote cache does not load and no requests to BetterTTV are made for the rest of + the session. - **BetterTTV's privacy policy:** Source: `HellionChat/EmoteCache.cs`. @@ -119,10 +120,10 @@ Cached `FFXIV_Lodestone_SSF.ttf` files left over from earlier versions remain in ### Links you click yourself (no automatic traffic) The About tab contains buttons that open external pages in your browser when you click them: the -Hellion Forge Discord invite, the HellionChat Gitea repository, its custom-repo manifest, and -- when -the Honorific integration row is shown -- that plugin's GitHub repository and its author's profile. -Nothing happens until you click. They are documented here for completeness, not because they -generate background traffic. +Hellion Forge Discord invite, the HellionChat Gitea repository, its custom-repo manifest, and -- +when the Honorific integration row is shown -- that plugin's GitHub repository and its author's +profile. Nothing happens until you click. They are documented here for completeness, not because +they generate background traffic. --- @@ -150,8 +151,8 @@ locally, those rights translate directly into plugin features: ### Right to access (Art. 15) -Settings → Data & Privacy → Export. You can export to **Markdown**, **JSON** or **CSV**, narrowed -by channel group, by age in days, or by a substring of the sender's name. The export goes through a +Settings → Data & Privacy → Export. You can export to **Markdown**, **JSON** or **CSV**, narrowed by +channel group, by age in days, or by a substring of the sender's name. The export goes through a Dalamud file dialog and writes wherever you point it, on your machine. It reads the database on its own connection and writes to a temporary file first, so a run that is interrupted leaves the previous export in place rather than a file that looks complete and is not. @@ -183,6 +184,7 @@ Two options: channel is stored and nothing contradicts your settings; with no channel selected, a cleanup would delete everything, and that is what the clear button is for. Both cases say so instead of offering a button that does not do what it looks like. + 2. **Full deletion.** Close the game and delete the `pluginConfigs/HellionChat/` directory. The next plugin start will produce a fresh, empty configuration. diff --git a/docs/THEME-AUTHORING.md b/docs/THEME-AUTHORING.md index 6556268..924f3c7 100644 --- a/docs/THEME-AUTHORING.md +++ b/docs/THEME-AUTHORING.md @@ -183,8 +183,8 @@ Check `/xllog` after a plugin reload to see what loaded and what didn't. 4. Watch every plugin window (chat, settings, pop-out) and pick something to fix. 5. Tweak. Reload. Repeat. -Tip: the **Settings → Appearance** picker shows a mini-mockup per theme — your colors are visible before -you switch. +Tip: the **Settings → Appearance** picker shows a mini-mockup per theme — your colors are visible +before you switch. ## Sharing themes From fdb1a985195d16d0d121bc7f09f1fe6ed0d95c91 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 07:17:33 +0200 Subject: [PATCH 127/129] fix(privacy): the cleanup could never be applied, and three more from the audit The cleanup preview marked itself stale before it could be drawn. The gate bumps a revision on release so a preview cannot survive a wipe; I then made the preview take the gate, so its own release invalidated it every single time and the apply button never appeared. The feature has been shipping non-functional since it was written, with a self-test that asserted the exact bump that killed it. Read-only operations no longer move the revision, and preview and maintenance have their own marks instead of borrowing Cleanup -- which also stops the five-second metadata refresh from expiring previews, and stops the UI announcing "another operation is running: cleanup" during a VACUUM. The JSON export produced invalid JSON. The chat relation kinds were interpolated straight into the output, and interpolating an enum writes its member name, so every message with a recognised relation came out as "source_kind":LocalPlayer. That is the file a GDPR access request goes out on. The self-test wrote a JSON file and never parsed it; it does now. Retention with the limit at zero still deleted. The slider is labelled "0 = never" and the sweep seeded 31 spec defaults unconditionally before reading the user's overrides, so zero still lost free company, linkshell and party history after ninety days -- and the short-circuit written for exactly this case could never be reached, because the map was never empty. A wipe that worked reported that it had failed. VACUUM needs the database to itself, the refilter walks a lazy reader on the primary connection outside the lock, and the two collide -- after the DELETE has committed. The delete paths no longer let that escape: the rows are gone, an uncompacted file is a housekeeping problem, and telling somebody their history is still there when it is not is a different kind of problem. Also: - CSV cells starting with =, +, - or @ get a leading apostrophe. The content is text other people typed into a chat channel and the file exists to be opened in a spreadsheet. - An export that matched nothing no longer replaces the previous one. It used to write its header, move it into place, and then report that nothing matched. Dalamud's save dialog offers no overwrite confirmation to fall back on, so this is the part that had to move. - The retention sweep says so when it loses the race for the gate, and routes its notifications through the teardown check like everything else. --- HellionChat/Export/MessageExporter.cs | 30 ++++++++++++- HellionChat/MessageStore.cs | 35 +++++++++++++-- HellionChat/Plugin.cs | 45 +++++++++++++++++-- .../Resources/HellionStrings.Designer.cs | 2 + HellionChat/Resources/HellionStrings.ca.resx | 6 +++ HellionChat/Resources/HellionStrings.cs.resx | 6 +++ HellionChat/Resources/HellionStrings.da.resx | 6 +++ HellionChat/Resources/HellionStrings.de.resx | 6 +++ HellionChat/Resources/HellionStrings.el.resx | 6 +++ HellionChat/Resources/HellionStrings.es.resx | 6 +++ HellionChat/Resources/HellionStrings.fi.resx | 6 +++ HellionChat/Resources/HellionStrings.fr.resx | 6 +++ HellionChat/Resources/HellionStrings.hu.resx | 6 +++ HellionChat/Resources/HellionStrings.it.resx | 6 +++ HellionChat/Resources/HellionStrings.ja.resx | 6 +++ HellionChat/Resources/HellionStrings.ko.resx | 6 +++ HellionChat/Resources/HellionStrings.nb.resx | 6 +++ HellionChat/Resources/HellionStrings.nl.resx | 6 +++ HellionChat/Resources/HellionStrings.pl.resx | 6 +++ .../Resources/HellionStrings.pt-BR.resx | 6 +++ .../Resources/HellionStrings.pt-PT.resx | 6 +++ HellionChat/Resources/HellionStrings.resx | 6 +++ HellionChat/Resources/HellionStrings.ro.resx | 6 +++ HellionChat/Resources/HellionStrings.ru.resx | 6 +++ HellionChat/Resources/HellionStrings.sv.resx | 6 +++ HellionChat/Resources/HellionStrings.tr.resx | 6 +++ HellionChat/Resources/HellionStrings.uk.resx | 6 +++ .../Resources/HellionStrings.zh-Hans.resx | 6 +++ .../Resources/HellionStrings.zh-Hant.resx | 6 +++ HellionChat/SelfTests/ExportRoundTripStep.cs | 17 +++++++ .../Settings/Tabs/DataPrivacyTab.cs | 23 +++++----- HellionChat/Util/DbOperationGate.cs | 18 +++++++- 32 files changed, 300 insertions(+), 20 deletions(-) diff --git a/HellionChat/Export/MessageExporter.cs b/HellionChat/Export/MessageExporter.cs index 3f33006..c6ddb47 100644 --- a/HellionChat/Export/MessageExporter.cs +++ b/HellionChat/Export/MessageExporter.cs @@ -92,6 +92,18 @@ internal static class MessageExporter }; } + // An export that matched nothing does not replace anything. The + // file still has a header and a footer, so moving it would put a + // near-empty file where the user's previous export was -- and then + // report "no message matched the filter", which reads as "nothing + // happened". Dalamud's save dialog has no overwrite confirmation to + // fall back on. + if (written == 0) + { + TryDeleteTemp(temp); + return 0; + } + File.Move(temp, path, overwrite: true); return written; } @@ -225,8 +237,13 @@ internal static class MessageExporter w.Write($",\"date\":\"{m.Date.ToString("O", CultureInfo.InvariantCulture)}\""); w.Write($",\"chat_type\":{(int)m.Code.Type}"); w.Write($",\"chat_type_name\":\"{chatType}\""); - w.Write($",\"source_kind\":{m.Code.Source}"); - w.Write($",\"target_kind\":{m.Code.Target}"); + // Cast, not interpolate. These are XivChatRelationKind, and string + // interpolation of an enum writes the member name -- so every + // message with a recognised relation produced + // "source_kind":LocalPlayer, which no parser accepts. This is the + // file an access request goes out on. + w.Write($",\"source_kind\":{(int)m.Code.Source}"); + w.Write($",\"target_kind\":{(int)m.Code.Target}"); w.Write($",\"receiver\":{m.Receiver}"); w.Write($",\"content_id\":{m.ContentId}"); w.Write($",\"sender\":{JsonString(SenderText(m))}"); @@ -313,8 +330,17 @@ internal static class MessageExporter private static string CsvString(string s) { + // Leading =, +, - and @ make a spreadsheet treat the cell as a formula. + // Every value here is text somebody else typed into a chat channel, and + // this file exists to be opened in Excel, so a prefixed apostrophe goes + // in front. It is the standard defence and it costs one character that + // spreadsheets hide. + if (s.Length > 0 && s[0] is '=' or '+' or '-' or '@' or '\t' or '\r') + s = "'" + s; + if (s.IndexOfAny(['"', ',', '\n', '\r']) < 0) return s; + return "\"" + s.Replace("\"", "\"\"") + "\""; } } diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index 0f390be..b0c6f5a 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -493,7 +493,7 @@ internal class MessageStore : IDisposable { Connection.Execute("DELETE FROM messages;"); InvalidateFtsIndex(); - PerformMaintenance(); + TryPerformMaintenance(); } } @@ -595,7 +595,7 @@ internal class MessageStore : IDisposable if (deleted > 0) { InvalidateFtsIndex(); - PerformMaintenance(); + TryPerformMaintenance(); } return deleted; @@ -628,7 +628,7 @@ internal class MessageStore : IDisposable if (deleted > 0) { InvalidateFtsIndex(); - PerformMaintenance(); + TryPerformMaintenance(); } return deleted; @@ -663,7 +663,7 @@ internal class MessageStore : IDisposable if (deleted > 0) { InvalidateFtsIndex(); - PerformMaintenance(); + TryPerformMaintenance(); } return deleted; @@ -684,6 +684,33 @@ internal class MessageStore : IDisposable } } + // Runs maintenance and swallows a failure, for the delete paths only. + // + // VACUUM needs the database to itself, and a lazily consumed reader on the + // primary connection -- which GetMostRecentMessages hands out and the + // refilter walks outside the lock -- makes it fail immediately with "cannot + // VACUUM - SQL statements in progress". That happens after the DELETE has + // committed, so letting it escape means the caller reports "nothing was + // removed" about a wipe that emptied the database. + // + // The rows are gone either way. An uncompacted file is a housekeeping + // problem; telling somebody their history is still there when it is not is + // a different kind of problem. + private void TryPerformMaintenance() + { + try + { + PerformMaintenance(); + } + catch (Exception e) + { + _logger.LogWarning( + e, + "Maintenance after a delete failed; the rows are gone but the file was not compacted." + ); + } + } + private string LogPath => DbPath + "-wal"; internal long DatabaseSize() => !File.Exists(DbPath) ? 0 : new FileInfo(DbPath).Length; diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 91c9397..85e9965 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -1064,9 +1064,23 @@ public sealed class Plugin : IAsyncDalamudPlugin return false; // Snapshot the policy so the user can edit settings while the sweep runs. + // + // Seeded from the spec defaults only when the global limit is not "keep + // forever". The slider is labelled "0 = never", and pre-filling 31 + // channels with 365- and 90-day windows made that label a lie: setting + // it to zero still lost free company, linkshell and party history after + // ninety days, and the short-circuit in DeleteByRetentionPolicy could + // never be reached because the map was never empty. + // + // Explicit per-channel overrides still apply. Somebody who typed a + // number for one channel meant that number. var policy = new Dictionary(); - foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays) - policy[(int)(ushort)type] = days; + if (Config.RetentionDefaultDays > 0) + { + foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays) + policy[(int)(ushort)type] = days; + } + // This is the enumerator the wizard's Clear() cuts short. Reading under the // same lock the writers take keeps the policy snapshot whole. lock (ConfigMapsLock) @@ -1086,7 +1100,18 @@ public sealed class Plugin : IAsyncDalamudPlugin try { if (!DbOperations.TryBegin(Util.DbOperation.RetentionSweep)) + { + // A run the user pressed a button for has to say something. + // The pre-check in StartRetentionSweep only covers a gate + // that was already busy; losing the race here is the same + // outcome and used to be silent. + if (notify) + NotifySweep( + Resources.HellionStrings.Retention_Error, + Dalamud.Interface.ImGuiNotification.NotificationType.Warning + ); return; + } try { @@ -1142,7 +1167,7 @@ public sealed class Plugin : IAsyncDalamudPlugin { Log.Error(e, "Retention sweep failed"); if (notify) - Util.WrapperUtil.AddNotification( + NotifySweep( Resources.HellionStrings.Retention_Error, Dalamud.Interface.ImGuiNotification.NotificationType.Error ); @@ -1170,6 +1195,20 @@ public sealed class Plugin : IAsyncDalamudPlugin } } + // The sweep is a background thread that can outlive an unload, same as the + // settings-tab workers. A notification filed against a plugin that is gone + // belongs to nobody. + private void NotifySweep( + string message, + Dalamud.Interface.ImGuiNotification.NotificationType type + ) + { + if (_isDisposing) + return; + + Util.WrapperUtil.AddNotification(message, type); + } + // Read by the settings tab every frame so the manual button can say a run is // in progress. The gate itself cannot answer that: it goes busy only once // the worker reaches TryBegin, which is after Start returns. diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index 887fb2f..8f70b61 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -277,6 +277,8 @@ internal class HellionStrings internal static string Settings_Database_Op_Export => Get(nameof(Settings_Database_Op_Export)); internal static string Settings_Database_Op_Cleanup => Get(nameof(Settings_Database_Op_Cleanup)); internal static string Settings_Database_Op_Clear => Get(nameof(Settings_Database_Op_Clear)); + internal static string Settings_Database_Op_Preview => Get(nameof(Settings_Database_Op_Preview)); + internal static string Settings_Database_Op_Maintenance => Get(nameof(Settings_Database_Op_Maintenance)); // Hellion Chat — Default tab presets (channel-themed) internal static string Tabs_Presets_System => Get(nameof(Tabs_Presets_System)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index 7354097..52114ac 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1328,4 +1328,10 @@ Duplica + + vista prèvia + + + manteniment + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index aaaa3fa..73e8050 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1327,4 +1327,10 @@ Duplikovat + + náhled + + + údržba + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index d13777b..521f41c 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1327,4 +1327,10 @@ Duplikér + + forhåndsvisning + + + vedligeholdelse + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index e2760fa..b030897 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1322,4 +1322,10 @@ Duplizieren + + Vorschau + + + Wartung + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index f35e762..5ac707e 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1327,4 +1327,10 @@ Αντιγραφή + + προεπισκόπηση + + + συντήρηση + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index 105a5c7..46acd7d 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1328,4 +1328,10 @@ Duplicar + + vista previa + + + mantenimiento + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index 0c02e98..d80898d 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1327,4 +1327,10 @@ Kahdenna + + esikatselu + + + ylläpito + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index 93aeb61..2f17062 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1328,4 +1328,10 @@ Dupliquer + + aperçu + + + maintenance + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index ed40eb9..43e6153 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1327,4 +1327,10 @@ Másolat + + előnézet + + + karbantartás + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index 98a745c..8880a1f 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1328,4 +1328,10 @@ Duplica + + anteprima + + + manutenzione + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index 9b23c70..f1beb26 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1328,4 +1328,10 @@ 複製 + + プレビュー + + + メンテナンス + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 47cc108..3fb0e96 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1328,4 +1328,10 @@ 복제 + + 미리보기 + + + 유지 관리 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index f3824e9..e1654d5 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1327,4 +1327,10 @@ Dupliser + + forhåndsvisning + + + vedlikehold + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 8e5fed6..59bc189 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1328,4 +1328,10 @@ Dupliceren + + voorbeeld + + + onderhoud + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index 8164f55..7a74f13 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1327,4 +1327,10 @@ Duplikuj + + podgląd + + + konserwacja + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index 4c418ea..89fd3dd 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1328,4 +1328,10 @@ Duplicar + + pré-visualização + + + manutenção + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 361a83f..8087376 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1327,4 +1327,10 @@ Duplicar + + pré-visualização + + + manutenção + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index 087d394..2f352d2 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1339,4 +1339,10 @@ Duplicate + + preview + + + maintenance + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index cc06371..bc6d3eb 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1328,4 +1328,10 @@ Duplică + + previzualizare + + + întreținere + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index de4d70d..891dae5 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1328,4 +1328,10 @@ Дублировать + + предпросмотр + + + обслуживание + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index c77e20a..2db63e2 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1328,4 +1328,10 @@ Duplicera + + förhandsgranskning + + + underhåll + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index 7d95d2c..eeeb1c6 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1327,4 +1327,10 @@ Çoğalt + + önizleme + + + bakım + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 403ad43..74744c5 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1327,4 +1327,10 @@ Дублювати + + попередній перегляд + + + обслуговування + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index a21a232..f5ab8e6 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1328,4 +1328,10 @@ 复制 + + 预览 + + + 维护 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index f4026a6..357b246 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1328,4 +1328,10 @@ 複製 + + 預覽 + + + 維護 + \ No newline at end of file diff --git a/HellionChat/SelfTests/ExportRoundTripStep.cs b/HellionChat/SelfTests/ExportRoundTripStep.cs index c7e7731..be1ac17 100644 --- a/HellionChat/SelfTests/ExportRoundTripStep.cs +++ b/HellionChat/SelfTests/ExportRoundTripStep.cs @@ -125,6 +125,23 @@ internal sealed class ExportRoundTripStep : ISelfTestStep failures.Add("export produced no file"); if (File.Exists(path + ".part")) failures.Add("temporary file left behind after a successful export"); + + // Parsed, not merely counted. The writer builds JSON by hand, and it + // shipped a build where the chat relation kinds were interpolated as + // enum names -- "source_kind":LocalPlayer -- which every parser + // rejects. A test that only checks the file exists would have passed. + try + { + using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path)); + if (!doc.RootElement.TryGetProperty("messages", out var messages)) + failures.Add("export JSON has no messages array"); + else if (messages.GetArrayLength() != 1) + failures.Add($"export JSON holds {messages.GetArrayLength()} messages, expected 1"); + } + catch (System.Text.Json.JsonException e) + { + failures.Add($"export JSON does not parse: {e.Message}"); + } } // Built with empty SeStrings on purpose: the whole point is that the text diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs index e7ea970..8bf389f 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -140,10 +140,11 @@ internal sealed class DataPrivacyTab v => Plugin.Config.RetentionEnabled = v ); - // Down to 0, which DeleteByRetentionPolicy reads as "keep forever" - // for channels without an override. The slider started at 1, so the - // one value that means "never delete anything" was unreachable - // through the UI while the label promised it. + // Down to 0, which the sweep reads as "keep forever" for every + // channel without an explicit override. That only became true in + // v1.12.0: the sweep used to seed the spec defaults unconditionally, + // so zero still deleted free company and linkshell history after + // ninety days while this label said otherwise. _w.SliderIntRow( ImGui.GetID("privacy.logging.default"u8), HellionStrings.Retention_Default_Label, @@ -474,7 +475,7 @@ internal sealed class DataPrivacyTab // MessageCount holds _readLock, and a VACUUM starting under it // is exactly the collision this gate was written for -- being a // read rather than a write does not exempt it. - if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup)) + if (!_plugin.DbOperations.TryBegin(DbOperation.Preview)) return; try @@ -487,7 +488,7 @@ internal sealed class DataPrivacyTab } finally { - _plugin.DbOperations.End(DbOperation.Cleanup); + _plugin.DbOperations.End(DbOperation.Preview); } } catch (Exception e) @@ -692,7 +693,7 @@ internal sealed class DataPrivacyTab { try { - if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup)) + if (!_plugin.DbOperations.TryBegin(DbOperation.Maintenance)) { // Said out loud, like every other refusal. A developer tool // that silently does nothing is how you end up debugging the @@ -707,7 +708,7 @@ internal sealed class DataPrivacyTab } finally { - _plugin.DbOperations.End(DbOperation.Cleanup); + _plugin.DbOperations.End(DbOperation.Maintenance); } } catch (Exception e) @@ -880,7 +881,7 @@ internal sealed class DataPrivacyTab { try { - if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup)) + if (!_plugin.DbOperations.TryBegin(DbOperation.Preview)) { NotifyBusy(); return; @@ -892,7 +893,7 @@ internal sealed class DataPrivacyTab } finally { - _plugin.DbOperations.End(DbOperation.Cleanup); + _plugin.DbOperations.End(DbOperation.Preview); } } catch (Exception e) @@ -1235,6 +1236,8 @@ internal sealed class DataPrivacyTab DbOperation.Export => HellionStrings.Settings_Database_Op_Export, DbOperation.Cleanup => HellionStrings.Settings_Database_Op_Cleanup, DbOperation.Clear => HellionStrings.Settings_Database_Op_Clear, + DbOperation.Preview => HellionStrings.Settings_Database_Op_Preview, + DbOperation.Maintenance => HellionStrings.Settings_Database_Op_Maintenance, _ => string.Empty, }; diff --git a/HellionChat/Util/DbOperationGate.cs b/HellionChat/Util/DbOperationGate.cs index ffa585a..4fc3516 100644 --- a/HellionChat/Util/DbOperationGate.cs +++ b/HellionChat/Util/DbOperationGate.cs @@ -8,6 +8,12 @@ internal enum DbOperation Export, Cleanup, Clear, + + // Read-only, but they hold the store long enough to matter: the preview + // scans every row, the metadata read takes the read lock, and maintenance + // rewrites the file without touching a single row. + Preview, + Maintenance, } // One gate for every operation that holds the message store for longer than a @@ -47,6 +53,16 @@ internal sealed class DbOperationGate internal long Revision => Interlocked.Read(ref _revision); + // Which operations can change what a preview counted. Getting this wrong in + // the permissive direction only costs a needless recount; getting it wrong + // the other way lets somebody confirm a number that is no longer true. + // + // The preview itself must not be in here, and that is not a detail: it takes + // the gate, so counting its own release would mark every preview stale the + // instant it finished and the apply button would never appear. + private static bool Mutates(DbOperation operation) => + operation is DbOperation.RetentionSweep or DbOperation.Cleanup or DbOperation.Clear; + internal bool IsBusy => _current != DbOperation.None; // False when another operation already owns the store. Callers must not @@ -84,7 +100,7 @@ internal sealed class DbOperationGate return; _current = DbOperation.None; - if (operation != DbOperation.Export) + if (Mutates(operation)) Interlocked.Increment(ref _revision); } } From e0c9efca0530dad97d6ebdb657f1316b4aebe312 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 08:24:31 +0200 Subject: [PATCH 128/129] i18n: the five spots the smoke test found, and a BOM in the export Reported from a real pass through the window. The theme categories were a static readonly array, so the five names froze at whatever language the plugin started in and a runtime switch relabelled the entire window except them. Same shape as the layout labels earlier in this cycle; this one got missed because replacing the literals with resource lookups looks finished until you actually switch. The status bar built its counts from English literals -- tab, tabs, msg, tell, tells -- and the privacy pill said "Privacy-First" in all 25 files. The thousands separator follows the user's culture now too, so German reads 1,2k rather than 1.2k. The live preview claims to show what the window will look like. It was showing English channel names next to a translated placeholder, which is worse than either. Channel labels come from ChatType.Name() now and the status slots share the strings with the real status bar. The four mock chat lines stay English on the earlier decision. And the export wrote a byte order mark. Encoding.UTF8 emits one, and a leading U+FEFF makes the JSON invalid for every strict parser -- confirmed against a real export from the game, where python's json.load refused the file. CSV keeps its BOM, because without one Excel guesses the codepage and mangles every non-ASCII name. The self-test that was supposed to catch that read the file with File.ReadAllText, which strips a BOM while detecting the encoding. It reads bytes now. The status bar tests asserted English literals and started failing on a German machine -- they pin a fixed culture now instead of inheriting the locale of whoever runs them. --- HellionChat/Export/MessageExporter.cs | 11 +++++- .../Resources/HellionStrings.Designer.cs | 8 +++++ HellionChat/Resources/HellionStrings.ca.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.cs.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.da.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.de.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.el.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.es.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.fi.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.fr.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.hu.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.it.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.ja.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.ko.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.nb.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.nl.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.pl.resx | 26 +++++++++++++- .../Resources/HellionStrings.pt-BR.resx | 26 +++++++++++++- .../Resources/HellionStrings.pt-PT.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.resx | 24 +++++++++++++ HellionChat/Resources/HellionStrings.ro.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.ru.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.sv.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.tr.resx | 26 +++++++++++++- HellionChat/Resources/HellionStrings.uk.resx | 26 +++++++++++++- .../Resources/HellionStrings.zh-Hans.resx | 26 +++++++++++++- .../Resources/HellionStrings.zh-Hant.resx | 26 +++++++++++++- HellionChat/SelfTests/ExportRoundTripStep.cs | 7 ++++ .../Components/Settings/LivePreviewPanel.cs | 21 ++++++++--- .../Ui/Components/Settings/ThemePicker.cs | 35 ++++++++++--------- HellionChat/Ui/Components/StatusBar.cs | 18 +++++++--- 31 files changed, 699 insertions(+), 49 deletions(-) diff --git a/HellionChat/Export/MessageExporter.cs b/HellionChat/Export/MessageExporter.cs index c6ddb47..5509459 100644 --- a/HellionChat/Export/MessageExporter.cs +++ b/HellionChat/Export/MessageExporter.cs @@ -49,6 +49,8 @@ internal static class ExportFormatExt // substring is applied here. internal static class MessageExporter { + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + internal record FilterDescription( IReadOnlyCollection? ChatTypes, DateTimeOffset? From, @@ -82,7 +84,14 @@ internal static class MessageExporter int written; try { - using (var writer = new StreamWriter(temp, append: false, encoding: Encoding.UTF8)) + // Encoding.UTF8 writes a byte order mark, and that is not a + // cosmetic detail here: a leading U+FEFF makes the JSON invalid for + // every strict parser, Python's json.load included. CSV is the one + // format that wants it -- without a BOM Excel guesses the codepage + // and mangles every non-ASCII name in the file. + var encoding = format == ExportFormat.Csv ? Encoding.UTF8 : Utf8NoBom; + + using (var writer = new StreamWriter(temp, append: false, encoding)) { written = format switch { diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index 8f70b61..d1682c0 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -380,6 +380,14 @@ internal class HellionStrings internal static string Settings_Fonts_Global => Get(nameof(Settings_Fonts_Global)); internal static string Settings_Fonts_Active => Get(nameof(Settings_Fonts_Active)); internal static string Settings_Preview_TypeAMessage => Get(nameof(Settings_Preview_TypeAMessage)); + internal static string StatusBar_Tabs_One => Get(nameof(StatusBar_Tabs_One)); + internal static string StatusBar_Tabs_Other => Get(nameof(StatusBar_Tabs_Other)); + internal static string StatusBar_Tells_One => Get(nameof(StatusBar_Tells_One)); + internal static string StatusBar_Tells_Other => Get(nameof(StatusBar_Tells_Other)); + internal static string StatusBar_Messages => Get(nameof(StatusBar_Messages)); + internal static string StatusBar_MessagesThousands => Get(nameof(StatusBar_MessagesThousands)); + internal static string Settings_Preview_TitleMock => Get(nameof(Settings_Preview_TitleMock)); + internal static string Settings_Preview_StatusOpen => Get(nameof(Settings_Preview_StatusOpen)); internal static string Settings_Section_Links => Get(nameof(Settings_Section_Links)); internal static string Settings_Section_Behaviour => Get(nameof(Settings_Section_Behaviour)); internal static string Settings_Section_Keybinds => Get(nameof(Settings_Section_Keybinds)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index 52114ac..d2d0148 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -704,7 +704,7 @@ Conserva - Privacy-First + Privadesa primer Obert @@ -1334,4 +1334,28 @@ manteniment + + {0} pestanya + + + {0} pestanyes + + + {0} xiuxiueig + + + {0} xiuxiueigs + + + {0} msg + + + {0:0.0}k msg + + + «Campió» Vista prèvia + + + obert + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index 73e8050..ea554e1 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -704,7 +704,7 @@ Ponechat - Privacy-First + Soukromí především Otevřeno @@ -1333,4 +1333,28 @@ údržba + + {0} karta + + + {0} karty + + + {0} šeptání + + + {0} šeptání + + + {0} zpr. + + + {0:0.0}k zpr. + + + «Šampion» Náhled + + + otevřeno + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index 521f41c..29802a1 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -704,7 +704,7 @@ Behold - Privacy-First + Privatliv først Åben @@ -1333,4 +1333,28 @@ vedligeholdelse + + {0} tab + + + {0} tabs + + + {0} tell + + + {0} tells + + + {0} besk. + + + {0:0.0}k besk. + + + «Champion» Forhåndsvisning + + + åben + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index b030897..de48bc0 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -704,7 +704,7 @@ Behalten - Privacy-First + Datenschutz zuerst Offen @@ -1328,4 +1328,28 @@ Wartung + + {0} Tab + + + {0} Tabs + + + {0} Flüstern + + + {0} Flüstern + + + {0} Nachr. + + + {0:0.0}k Nachr. + + + «Champion» Vorschau + + + offen + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index 5ac707e..abfe8ef 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -704,7 +704,7 @@ Διατήρηση - Privacy-First + Απόρρητο πρώτα Ανοιχτό @@ -1333,4 +1333,28 @@ συντήρηση + + {0} καρτέλα + + + {0} καρτέλες + + + {0} tell + + + {0} tells + + + {0} μην. + + + {0:0.0}k μην. + + + «Πρωταθλητής» Προεπισκόπηση + + + ανοιχτό + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index 46acd7d..dfb57c8 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -704,7 +704,7 @@ Mantener - Privacy-First + Privacidad primero Abierto @@ -1334,4 +1334,28 @@ mantenimiento + + {0} pestaña + + + {0} pestañas + + + {0} susurro + + + {0} susurros + + + {0} msj + + + {0:0.0}k msj + + + «Campeón» Vista previa + + + abierto + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index d80898d..2bdefc6 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -704,7 +704,7 @@ Säilytä - Privacy-First + Yksityisyys ensin Avoin @@ -1333,4 +1333,28 @@ ylläpito + + {0} välilehti + + + {0} välilehteä + + + {0} kuiskaus + + + {0} kuiskausta + + + {0} vie. + + + {0:0.0}k vie. + + + «Mestari» Esikatselu + + + auki + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index 2f17062..956f1e5 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -704,7 +704,7 @@ Conserver - Confidentialité + Confidentialité d'abord Ouvert @@ -1334,4 +1334,28 @@ maintenance + + {0} onglet + + + {0} onglets + + + {0} MP + + + {0} MP + + + {0} msg + + + {0:0.0}k msg + + + «Champion» Aperçu + + + ouvert + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index 43e6153..f47efee 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -704,7 +704,7 @@ Megtartás - Privacy-First + Adatvédelem elöl Nyitott @@ -1333,4 +1333,28 @@ karbantartás + + {0} lap + + + {0} lap + + + {0} suttogás + + + {0} suttogás + + + {0} üz. + + + {0:0.0}k üz. + + + «Bajnok» Előnézet + + + nyitva + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index 8880a1f..7a372ef 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -704,7 +704,7 @@ Mantieni - Privacy-First + Privacy prima Aperto @@ -1334,4 +1334,28 @@ manutenzione + + {0} scheda + + + {0} schede + + + {0} sussurro + + + {0} sussurri + + + {0} msg + + + {0:0.0}k msg + + + «Campione» Anteprima + + + aperto + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index f1beb26..a84ed43 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -704,7 +704,7 @@ 保持 - Privacy-First + プライバシー優先 オープン @@ -1334,4 +1334,28 @@ メンテナンス + + {0} タブ + + + {0} タブ + + + {0} テル + + + {0} テル + + + {0} 件 + + + {0:0.0}k 件 + + + «Champion» プレビュー + + + 開いています + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 3fb0e96..d6e4303 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -704,7 +704,7 @@ 유지 - Privacy-First + 개인정보 우선 전체 공개 @@ -1334,4 +1334,28 @@ 유지 관리 + + {0} 탭 + + + {0} 탭 + + + {0} 귓속말 + + + {0} 귓속말 + + + {0} 개 + + + {0:0.0}k 개 + + + «Champion» 미리보기 + + + 열림 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index e1654d5..d5f6a01 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -704,7 +704,7 @@ Behold - Privacy-First + Personvern først Åpen @@ -1333,4 +1333,28 @@ vedlikehold + + {0} fane + + + {0} faner + + + {0} tell + + + {0} tells + + + {0} meld. + + + {0:0.0}k meld. + + + «Champion» Forhåndsvisning + + + åpen + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 59bc189..08ac71a 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -704,7 +704,7 @@ Bewaren - Privacy-First + Privacy voorop Open @@ -1334,4 +1334,28 @@ onderhoud + + {0} tabblad + + + {0} tabbladen + + + {0} tell + + + {0} tells + + + {0} ber. + + + {0:0.0}k ber. + + + «Kampioen» Voorbeeld + + + open + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index 7a74f13..d88313d 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -704,7 +704,7 @@ Zachowaj - Privacy-First + Prywatność przede wszystkim Otwarty @@ -1333,4 +1333,28 @@ konserwacja + + {0} zakładka + + + {0} zakładki + + + {0} szept + + + {0} szepty + + + {0} wiad. + + + {0:0.0}k wiad. + + + «Mistrz» Podgląd + + + otwarte + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index 89fd3dd..b37e345 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -704,7 +704,7 @@ Manter - Privacy-First + Privacidade primeiro Aberto @@ -1334,4 +1334,28 @@ manutenção + + {0} aba + + + {0} abas + + + {0} sussurro + + + {0} sussurros + + + {0} msg + + + {0:0.0}k msg + + + «Campeão» Pré-visualização + + + aberto + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 8087376..1eb606a 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -704,7 +704,7 @@ Manter - Privacy-First + Privacidade primeiro Aberto @@ -1333,4 +1333,28 @@ manutenção + + {0} separador + + + {0} separadores + + + {0} sussurro + + + {0} sussurros + + + {0} msg + + + {0:0.0}k msg + + + «Campeão» Pré-visualização + + + aberto + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index 2f352d2..d6ebc8a 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1345,4 +1345,28 @@ maintenance + + {0} tab + + + {0} tabs + + + {0} tell + + + {0} tells + + + {0} msg + + + {0:0.0}k msg + + + «Champion» Vorschau + + + open + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index bc6d3eb..d7b6900 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -704,7 +704,7 @@ Păstrează - Privacy-First + Confidențialitate întâi Deschis @@ -1334,4 +1334,28 @@ întreținere + + {0} tab + + + {0} tab-uri + + + {0} tell + + + {0} tell-uri + + + {0} mes. + + + {0:0.0}k mes. + + + «Campion» Previzualizare + + + deschis + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index 891dae5..365e56d 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -704,7 +704,7 @@ Оставить - Privacy-First + Приватность прежде всего Открыто @@ -1334,4 +1334,28 @@ обслуживание + + {0} вкладка + + + {0} вкладок + + + {0} шёпот + + + {0} шёпотов + + + {0} сообщ. + + + {0:0.0}k сообщ. + + + «Чемпион» Предпросмотр + + + открыт + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index 2db63e2..276879b 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -704,7 +704,7 @@ Behåll - Privacy-First + Integritet först Öppen @@ -1334,4 +1334,28 @@ underhåll + + {0} flik + + + {0} flikar + + + {0} viskning + + + {0} viskningar + + + {0} medd. + + + {0:0.0}k medd. + + + «Mästare» Förhandsgranskning + + + öppen + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index eeeb1c6..3b310af 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -704,7 +704,7 @@ Koru - Privacy-First + Önce gizlilik Açık @@ -1333,4 +1333,28 @@ bakım + + {0} sekme + + + {0} sekme + + + {0} tell + + + {0} tell + + + {0} ileti + + + {0:0.0}k ileti + + + «Şampiyon» Önizleme + + + açık + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 74744c5..23e35f6 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -704,7 +704,7 @@ Залишити - Privacy-First + Приватність передусім Відкрито @@ -1333,4 +1333,28 @@ обслуговування + + {0} вкладка + + + {0} вкладок + + + {0} шепіт + + + {0} шепотів + + + {0} повід. + + + {0:0.0}k повід. + + + «Чемпіон» Перегляд + + + відкрито + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index f5ab8e6..66299f2 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -704,7 +704,7 @@ 保留 - Privacy-First + 隐私优先 开放 @@ -1334,4 +1334,28 @@ 维护 + + {0} 标签 + + + {0} 标签 + + + {0} 密语 + + + {0} 密语 + + + {0} 条 + + + {0:0.0}k 条 + + + «冠军» 预览 + + + 打开 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index 357b246..e000264 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -704,7 +704,7 @@ 保留 - Privacy-First + 隱私優先 開放 @@ -1334,4 +1334,28 @@ 維護 + + {0} 分頁 + + + {0} 分頁 + + + {0} 悄悄話 + + + {0} 悄悄話 + + + {0} 則 + + + {0:0.0}k 則 + + + «冠軍» 預覽 + + + 開啟 + \ No newline at end of file diff --git a/HellionChat/SelfTests/ExportRoundTripStep.cs b/HellionChat/SelfTests/ExportRoundTripStep.cs index be1ac17..7c9c0be 100644 --- a/HellionChat/SelfTests/ExportRoundTripStep.cs +++ b/HellionChat/SelfTests/ExportRoundTripStep.cs @@ -126,6 +126,13 @@ internal sealed class ExportRoundTripStep : ISelfTestStep if (File.Exists(path + ".part")) failures.Add("temporary file left behind after a successful export"); + // Bytes, not text. File.ReadAllText strips a byte order mark while + // detecting the encoding, so a BOM that breaks every strict JSON parser + // is invisible to the check below -- and it shipped exactly that way. + var head = File.ReadAllBytes(path); + if (head.Length >= 3 && head[0] == 0xEF && head[1] == 0xBB && head[2] == 0xBF) + failures.Add("export JSON starts with a byte order mark"); + // Parsed, not merely counted. The writer builds JSON by hand, and it // shipped a build where the chat relation kinds were interpolated as // enum names -- "source_kind":LocalPlayer -- which every parser diff --git a/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs b/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs index acf9984..815db7f 100644 --- a/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs +++ b/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs @@ -3,6 +3,7 @@ using System.Threading; using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility.Raii; +using HellionChat.Code; using HellionChat.Resources; using HellionChat.Themes; using HellionChat.Ui.StyleEngine; @@ -153,7 +154,7 @@ internal sealed class LivePreviewPanel : IDisposable // unchanged — but both paths now share one resolver. No truncation here: // the preview draws a fixed, centred "«Champion» Preview" string. var textAbgr = HonorificTitleColor.ResolveTitleAbgr(null, theme); - var title = "«Champion» Preview"; + var title = HellionStrings.Settings_Preview_TitleMock; var crownGlyph = FontAwesomeIcon.Crown.ToIconString(); // Crown is a FontAwesome glyph (matches the real header); measure + draw @@ -196,7 +197,14 @@ internal sealed class LivePreviewPanel : IDisposable // sat on different rows here, so the preview promised a layout the // sidebar never delivered -- and then the sidebar caught up. var borderAbgr = ColourUtil.RgbaToAbgr(theme.Colors.Border); - ReadOnlySpan labels = ["Linkshell", "Tell", "FC"]; + // Real channel names, not literals: the preview claims to show what the + // sidebar will look like, and the sidebar is localised. + ReadOnlySpan labels = + [ + ChatType.Linkshell1.Name(), + ChatType.TellIncoming.Name(), + ChatType.FreeCompany.Name(), + ]; for (var i = 0; i < 3; i++) { var rowMin = new Vector2(origin.X, origin.Y + i * rowHeight); @@ -300,7 +308,7 @@ internal sealed class LivePreviewPanel : IDisposable var pillMax = new Vector2(origin.X + pillWidth, max.Y - 4f); draw.AddRectFilled(pillMin, pillMax, ColourUtil.RgbaToAbgr(theme.Colors.Primary), 6f); - var pillLabel = "Say"; + var pillLabel = ChatType.Say.Name(); var pillLabelSize = ImGui.CalcTextSize(pillLabel); var pillTextPos = new Vector2( pillMin.X + ((pillMax.X - pillMin.X) - pillLabelSize.X) * 0.5f, @@ -353,7 +361,12 @@ internal sealed class LivePreviewPanel : IDisposable var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary); var pillY = origin.Y + (height - pillH) * 0.5f; - ReadOnlySpan slots = ["Say", "open", "3 tabs"]; + ReadOnlySpan slots = + [ + ChatType.Say.Name(), + HellionStrings.Settings_Preview_StatusOpen, + string.Format(HellionStrings.StatusBar_Tabs_Other, 3), + ]; ReadOnlySpan dots = [ theme.Colors.StatusSuccess, diff --git a/HellionChat/Ui/Components/Settings/ThemePicker.cs b/HellionChat/Ui/Components/Settings/ThemePicker.cs index c18bee8..6069633 100644 --- a/HellionChat/Ui/Components/Settings/ThemePicker.cs +++ b/HellionChat/Ui/Components/Settings/ThemePicker.cs @@ -9,22 +9,25 @@ namespace HellionChat.Ui.Components.Settings; internal sealed class ThemePicker { - private static readonly (string Category, string[] Slugs, bool DefaultExpanded)[] CategoryMap = - { - ( - "Hellion Brand", - new[] { "hellion-arctic", "hellion-spectrum", "forge-merchantman" }, - true - ), - ( - HellionStrings.Settings_Theme_Category_Cool, - new[] { "night-blue", "event-horizon", "indigo-violet", "crystal-nocturne" }, - false - ), - (HellionStrings.Settings_Theme_Category_Natural, new[] { "mint-grove" }, false), - (HellionStrings.Settings_Theme_Category_Classic, new[] { "chat2-classic" }, false), - (HellionStrings.Settings_Theme_Category_Retro, new[] { "synthwave-sunset" }, false), - }; + // Built per call, not once. As a static readonly array the category names + // froze at whatever language the plugin started in -- a runtime switch + // relabelled the whole window except these five. + private static (string Category, string[] Slugs, bool DefaultExpanded)[] CategoryMap => + [ + ( + "Hellion Brand", + new[] { "hellion-arctic", "hellion-spectrum", "forge-merchantman" }, + true + ), + ( + HellionStrings.Settings_Theme_Category_Cool, + new[] { "night-blue", "event-horizon", "indigo-violet", "crystal-nocturne" }, + false + ), + (HellionStrings.Settings_Theme_Category_Natural, new[] { "mint-grove" }, false), + (HellionStrings.Settings_Theme_Category_Classic, new[] { "chat2-classic" }, false), + (HellionStrings.Settings_Theme_Category_Retro, new[] { "synthwave-sunset" }, false), + ]; // T2 ThemePickerCategoryStep diffs this against ThemeRegistry.BuiltinSlugs // to enforce coverage. Kept on the static map so the test does not pierce instance state. diff --git a/HellionChat/Ui/Components/StatusBar.cs b/HellionChat/Ui/Components/StatusBar.cs index b0eaeb2..a83d6d6 100644 --- a/HellionChat/Ui/Components/StatusBar.cs +++ b/HellionChat/Ui/Components/StatusBar.cs @@ -56,9 +56,16 @@ internal sealed class StatusBar { var msgPart = messages >= 1000 - ? string.Format(CultureInfo.InvariantCulture, "{0:0.0}k msg", messages / 1000.0) - : $"{messages} msg"; - var tabsPart = $"{tabs} {(tabs == 1 ? "tab" : "tabs")}"; + ? string.Format( + CultureInfo.CurrentCulture, + HellionStrings.StatusBar_MessagesThousands, + messages / 1000.0 + ) + : string.Format(HellionStrings.StatusBar_Messages, messages); + var tabsPart = string.Format( + tabs == 1 ? HellionStrings.StatusBar_Tabs_One : HellionStrings.StatusBar_Tabs_Other, + tabs + ); return $"{tabsPart} · {msgPart}"; } @@ -66,7 +73,10 @@ internal sealed class StatusBar { if (count <= 0) return string.Empty; - return $"{count} {(count == 1 ? "tell" : "tells")}"; + return string.Format( + count == 1 ? HellionStrings.StatusBar_Tells_One : HellionStrings.StatusBar_Tells_Other, + count + ); } // Single-pass aggregator — same shape as the previous helper so the From b388dcb2dedcf08accc12ca8a9c796d0e85b0429 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 08:35:18 +0200 Subject: [PATCH 129/129] chore(release): close the v1.12.0 cycle Version to 1.12.0 in the csproj, with the changelog and roadmap entries for the local state. Not published: the public release stays at v1.5.6, repo.json keeps its 1.5.6.0 manifest and all three download links are untouched. The changelog leads with the storage change rather than burying it under fixes. Until this release the unknown-channel failsafe was applied to known channels too, so an installation that had unticked channels and left the failsafe on was storing more than the grid said. From here the grid decides, which means those installations store less than they did yesterday. Nothing already written is touched, but a user who compares the database against last week should find the reason in the notes and not have to guess. Config version 25 is recorded there for the same reason. There is no migration behind it -- the storage rule changed shape, and the stamp says so. Known issues carry the honest tail: the database viewer and the emoji picker are still English, 245 orphaned resource keys remain as the inventory of what the v1.6.0 rebuild lost, and the licence text still contradicts itself between the About tab and the translated resources. --- HellionChat/HellionChat.csproj | 2 +- docs/CHANGELOG.md | 73 ++++++++++++++++++++++++++++++++++ docs/ROADMAP.md | 10 +++-- 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj index e732c5d..f3af9c2 100644 --- a/HellionChat/HellionChat.csproj +++ b/HellionChat/HellionChat.csproj @@ -1,7 +1,7 @@ - 1.11.0 + 1.12.0 enable enable diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cbda130..7b11b20 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,79 @@ releases as an overview and links to the release pages for details. --- +## [1.12.0] — unreleased (local only) + +The reconnection cycle. A commit during the v1.6.0 window layer rebuild removed the old tab system, +and the rebuild wired back roughly two thirds of it — 433 of 824 translation keys reached no line of +code, and four whole features had a saved setting but no way in. This release puts them back and +translates the settings window. **Not published** — the public release stays at v1.5.6. + +### Added + +- Message export is reachable again. Markdown, JSON and CSV, per channel or the whole database, + written atomically so a failed write cannot leave half a file behind. +- Retroactive cleanup is back, with a preview that counts what a run would delete before anything + is removed, and the Ctrl+Shift confirm in front of the actual delete. +- Database maintenance — integrity check, VACUUM, index rebuild — and the manual retention run + both have a control again. +- The tab editor returns: create, rename, reorder and delete tabs, with the channel selector and + the activity filter attached to each one. +- Pinning has a way in and a way back out. A pinned tab survives a restart and reopens with its + history. +- Eleven settings that steered real behaviour and had no control at all are reachable again. +- The settings window is translated into all 25 languages: 435 keys, held against the glossary the + plugin already used, with the game client's own word for a tell in each locale rather than a new + invention. +- Map flags and item links can be inserted into the input again, and the right-click menu is back + on the input field. + +### Fixed + +- **The channel grid now decides what is stored.** Until this release the unknown-channel failsafe + was applied to known channels as well, so a channel the user had unticked was still written when + that failsafe was on. See the note under _Changed_ — this is a behaviour change, not only a fix. +- The retroactive cleanup could never be applied. The preview takes the database lock itself, and + its release was counted as a mutation, so every preview was stale the instant it finished and the + apply button never appeared. +- VACUUM ran against an open reader on the primary connection and failed, after which the plugin + reported that nothing had been deleted — while everything had. The reader sits on its own + connection now. +- Deleting messages left the full-text index behind, so search kept returning rows that no longer + existed. +- A tell arriving from a popped-out partner hijacked the main window's active tab. +- The arrow keys walk the sent-message history again. +- The export wrote a UTF-8 BOM into JSON, which strict parsers reject; CSV keeps it, because + without it Excel guesses the code page. Enum values interpolated into JSON wrote the member name + and produced invalid output. +- Five strings in the settings and privacy tabs were still drawn as English literals. + +### Changed + +- **Behaviour change in storage.** Anyone who had unticked channels and left the unknown-channel + failsafe on was storing more than the channel grid said. From v1.12.0 the grid is authoritative, + so those installations store less. Nothing already in the database is touched. +- Every long-running database operation now goes through one gate instead of seven near-identical + background workers, each of which had to remember to take the lock. One of them did not. +- The export reads message text from the chunk lists rather than the flattened field, so links and + formatted segments survive the round trip. +- Config version 25. There is no migration step behind it: the v24 migration switched the privacy + filter off where it was on with nothing selected, and 25 only records that the storage rule + changed shape. + +### Known issues + +- The database viewer (`/hellionView`) and the emoji picker are still English on every client, and + one export dialog still carries the old Chat 2 branding in its title. +- 145 `Language.*` and 100 `HellionStrings` keys still have no caller. That is an inventory of + features lost in the v1.6.0 rebuild, not a delete list — timestamp layouts, duplicate-message + collapsing, the About texts, the novice network button and the honorific glow are all in there. +- `AboutTab` states GPL-3.0-or-later while the translated resources say EUPL-1.2. +- The full-text index is emptied on every deletion and only rebuilt at the next plugin start; until + then the database viewer searches more slowly. +- The first-run wizard, the input bar buttons and the message list itself are still stock ImGui. + +--- + ## [1.11.0] — unreleased (local only) The settings window, rebuilt on the widgets v1.10.0 introduced, plus the defects the rebuild kept diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 44f21d0..1b0b2ee 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,7 +14,7 @@ be a poor fit for the plugin's privacy-first scope during brainstorming. The published release is **v1.5.6**. Development since then runs as a UI rebuild towards v2.0.0 and is not published: the whole window layer is being rewritten from ImGui defaults to custom drawing. -Versions v1.6.0 through v1.11.0 are local development states, and `repo.json` deliberately keeps +Versions v1.6.0 through v1.12.0 are local development states, and `repo.json` deliberately keeps its download links on v1.5.6 so nobody updates into a partial state. Where it stands: @@ -28,8 +28,12 @@ Where it stands: doing it: three settings with no control, a pinned tell tab that never loaded its history, six switches that changed nothing, and dropdowns listing blank rows. Contrast is measured against the surface now rather than taken from the theme. -- **v1.11.0 onwards** — settings window, then the sidebar moving from tab rows to channel rows, - then typography and message cards. +- **v1.12.0** — the reconnection pass. The v1.6.0 window rebuild had left export, the tab editor, + database maintenance and pinning unreachable, and 433 of 824 translation keys with no caller. All + four features are back, the settings window is translated into 25 languages, and the channel grid + is authoritative over what gets stored. +- **v1.13.0 onwards** — typography and the message list: the font size ladder, channel headers and + message cards, then the sidebar moving from tab rows to channel rows. A tester beta follows once the visual pass is complete.