From c4562dd6a0787a4635c836423cfe3b9db8ca99c9 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 15 Jun 2026 16:18:05 +0200 Subject: [PATCH 01/12] style(selftests): wrap HonorificTitleData ctor to satisfy csharpier Inherited csharpier drift from the 1.8.7 merge (72099c8); format-only, no behaviour change. --- HellionChat/SelfTests/HonorificHeaderRenderStep.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/HellionChat/SelfTests/HonorificHeaderRenderStep.cs b/HellionChat/SelfTests/HonorificHeaderRenderStep.cs index 8d7d704..1f10a32 100644 --- a/HellionChat/SelfTests/HonorificHeaderRenderStep.cs +++ b/HellionChat/SelfTests/HonorificHeaderRenderStep.cs @@ -56,7 +56,16 @@ internal sealed class HonorificHeaderRenderStep : ISelfTestStep _snapshotted = true; var valid = new HonorificTitleData("Champion", false, false, null, null, null, null, null); - var original = new HonorificTitleData("Champion", false, true, null, null, null, null, null); + var original = new HonorificTitleData( + "Champion", + false, + true, + null, + null, + null, + null, + null + ); // Draw at a deliberately wide 420px so the title never hits the truncation // clamp — LastTitleRendered then reflects the GATE outcome, not the width. From dcf089b8862303329ccd4d72b79693d4d9e3ea10 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 15 Jun 2026 16:18:05 +0200 Subject: [PATCH 02/12] chore(release): bump manifest to 1.8.8 for theme export, default-fill, font-apply --- HellionChat/HellionChat.csproj | 2 +- repo.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj index 87d8229..abf725d 100644 --- a/HellionChat/HellionChat.csproj +++ b/HellionChat/HellionChat.csproj @@ -1,7 +1,7 @@ - 1.8.7 + 1.8.8 enable enable diff --git a/repo.json b/repo.json index 98bf846..76c2019 100644 --- a/repo.json +++ b/repo.json @@ -3,7 +3,7 @@ "Author": "Jon Kazama (Hellion Forge)", "Name": "Hellion Chat", "InternalName": "HellionChat", - "AssemblyVersion": "1.8.7.0", + "AssemblyVersion": "1.8.8.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.7.0", + "TestingAssemblyVersion": "1.8.8.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 a2d8a9c223529f29f59eb571f40f860aa0f665f1 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 15 Jun 2026 16:28:34 +0200 Subject: [PATCH 03/12] feat(themes): add an export button for the active theme --- .../Settings/ThemeImportExportRow.cs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs b/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs index f76db1d..25045d3 100644 --- a/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs +++ b/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs @@ -37,6 +37,12 @@ internal sealed class ThemeImportExportRow OpenThemesFolder(); } + ImGui.SameLine(); + if (ImGui.Button("Export active theme…")) + { + ExportActive(); + } + ImGui.SetNextItemWidth(-1); ImGui.InputTextWithHint( "##theme-import-path", @@ -293,4 +299,43 @@ internal sealed class ThemeImportExportRow _logger.LogWarning(ex, "Could not open themes folder {Dir}", dir); } } + + private void ExportActive() + { + // Capture the active theme now, not in the async dialog callback — the user + // could switch themes while the dialog is open. + var theme = _themes.Active; + var defaultName = $"{theme.Slug}.json"; + + Plugin.FileDialogManager.SaveFileDialog( + "Export theme", + ".json", + defaultName, + ".json", + (ok, path) => + { + if (ok) + { + ExportTo(theme, path); + } + }, + null, + isModal: true + ); + } + + private void ExportTo(Theme theme, string path) + { + try + { + var json = ThemeJsonWriter.Serialize(theme); + File.WriteAllText(path, json); + _logger.LogInformation("Exported theme {Slug} to {Path}", theme.Slug, path); + } + catch (Exception ex) + when (ex is IOException or UnauthorizedAccessException or SecurityException) + { + _logger.LogWarning(ex, "Theme export to {Path} failed", path); + } + } } From 512533ed3a49e02618da220747a67f1051043a74 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 15 Jun 2026 16:46:13 +0200 Subject: [PATCH 04/12] feat(themes): default-fill missing colour/layout slots on theme load --- HellionChat/Themes/ThemeJsonLoader.cs | 161 ++++++++++++++---- HellionChat/Themes/ThemeRegistry.cs | 2 +- .../Settings/ThemeImportExportRow.cs | 2 +- 3 files changed, 126 insertions(+), 39 deletions(-) diff --git a/HellionChat/Themes/ThemeJsonLoader.cs b/HellionChat/Themes/ThemeJsonLoader.cs index 549caa8..b61db75 100644 --- a/HellionChat/Themes/ThemeJsonLoader.cs +++ b/HellionChat/Themes/ThemeJsonLoader.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using HellionChat.Themes.Builtin; using HellionChat.Util; +using Microsoft.Extensions.Logging; namespace HellionChat.Themes; @@ -11,7 +13,8 @@ internal static class ThemeJsonLoader // policy from the v2.x style refactor: v1 user themes are not migrated, // they're silently ignored so the loader stays free of legacy mapping // code. Any other malformed input still throws FormatException. - public static Theme? LoadFromString(string json) + // B4b-2: callers must pass the logger or the default-fill warnings go silent. + public static Theme? LoadFromString(string json, ILogger? logger = null) { if (string.IsNullOrWhiteSpace(json)) throw new FormatException("Theme JSON is empty"); @@ -43,8 +46,22 @@ internal static class ThemeJsonLoader var author = ReadString(root, "author"); var description = ReadString(root, "description"); - var colors = ReadColors(root.GetProperty("colors")); - var layout = ReadLayout(root.GetProperty("layout")); + // Missing colours/layout object stays fatal, but as FormatException so the + // import path catches it — GetProperty's KeyNotFoundException would crash. + if ( + !root.TryGetProperty("colors", out var colorsEl) + || colorsEl.ValueKind != JsonValueKind.Object + ) + throw new FormatException("Theme JSON missing 'colors' object"); + if ( + !root.TryGetProperty("layout", out var layoutEl) + || layoutEl.ValueKind != JsonValueKind.Object + ) + throw new FormatException("Theme JSON missing 'layout' object"); + + var fallback = HellionArctic.Build(); + var colors = ReadColors(colorsEl, fallback.Colors, logger); + var layout = ReadLayout(layoutEl, fallback.Layout, logger); var typography = ReadTypography(root); ThemeChatColors? chatColors = null; @@ -93,52 +110,72 @@ internal static class ThemeJsonLoader return new ThemeChatColors(dict); } - public static Theme? LoadFromFile(string path) + public static Theme? LoadFromFile(string path, ILogger? logger = null) { // FileShare.Read lets concurrent readers and well-behaved editors share // the handle; atomic-replace editors still raise IOException, caught upstream. using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); using var reader = new StreamReader(stream); var json = reader.ReadToEnd(); - return LoadFromString(json); + return LoadFromString(json, logger); } - private static ThemeColors ReadColors(JsonElement el) => + private static ThemeColors ReadColors(JsonElement el, ThemeColors fallback, ILogger? logger) => new( - PrimaryDark: ColourUtil.HexToRgba(ReadString(el, "primaryDark")), - Primary: ColourUtil.HexToRgba(ReadString(el, "primary")), - PrimaryLight: ColourUtil.HexToRgba(ReadString(el, "primaryLight")), - PrimaryGlow: ColourUtil.HexToRgba(ReadString(el, "primaryGlow")), - AccentDark: ColourUtil.HexToRgba(ReadString(el, "accentDark")), - Accent: ColourUtil.HexToRgba(ReadString(el, "accent")), - AccentLight: ColourUtil.HexToRgba(ReadString(el, "accentLight")), - Identity: ColourUtil.HexToRgba(ReadString(el, "identity")), - WindowBg: ColourUtil.HexToRgba(ReadString(el, "windowBg")), - ChildBg: ColourUtil.HexToRgba(ReadString(el, "childBg")), - FrameBg: ColourUtil.HexToRgba(ReadString(el, "frameBg")), - Surface: ColourUtil.HexToRgba(ReadString(el, "surface")), - SurfaceHover: ColourUtil.HexToRgba(ReadString(el, "surfaceHover")), - Border: ColourUtil.HexToRgba(ReadString(el, "border")), - TextPrimary: ColourUtil.HexToRgba(ReadString(el, "textPrimary")), - TextMuted: ColourUtil.HexToRgba(ReadString(el, "textMuted")), - TextDim: ColourUtil.HexToRgba(ReadString(el, "textDim")), - StatusSuccess: ColourUtil.HexToRgba(ReadString(el, "statusSuccess")), - StatusDanger: ColourUtil.HexToRgba(ReadString(el, "statusDanger")), - StatusWarning: ColourUtil.HexToRgba(ReadString(el, "statusWarning")), - StatusInfo: ColourUtil.HexToRgba(ReadString(el, "statusInfo")) + PrimaryDark: ReadColorOrDefault(el, "primaryDark", fallback.PrimaryDark, logger), + Primary: ReadColorOrDefault(el, "primary", fallback.Primary, logger), + PrimaryLight: ReadColorOrDefault(el, "primaryLight", fallback.PrimaryLight, logger), + PrimaryGlow: ReadColorOrDefault(el, "primaryGlow", fallback.PrimaryGlow, logger), + AccentDark: ReadColorOrDefault(el, "accentDark", fallback.AccentDark, logger), + Accent: ReadColorOrDefault(el, "accent", fallback.Accent, logger), + AccentLight: ReadColorOrDefault(el, "accentLight", fallback.AccentLight, logger), + Identity: ReadColorOrDefault(el, "identity", fallback.Identity, logger), + WindowBg: ReadColorOrDefault(el, "windowBg", fallback.WindowBg, logger), + ChildBg: ReadColorOrDefault(el, "childBg", fallback.ChildBg, logger), + FrameBg: ReadColorOrDefault(el, "frameBg", fallback.FrameBg, logger), + Surface: ReadColorOrDefault(el, "surface", fallback.Surface, logger), + SurfaceHover: ReadColorOrDefault(el, "surfaceHover", fallback.SurfaceHover, logger), + Border: ReadColorOrDefault(el, "border", fallback.Border, logger), + TextPrimary: ReadColorOrDefault(el, "textPrimary", fallback.TextPrimary, logger), + TextMuted: ReadColorOrDefault(el, "textMuted", fallback.TextMuted, logger), + TextDim: ReadColorOrDefault(el, "textDim", fallback.TextDim, logger), + StatusSuccess: ReadColorOrDefault(el, "statusSuccess", fallback.StatusSuccess, logger), + StatusDanger: ReadColorOrDefault(el, "statusDanger", fallback.StatusDanger, logger), + StatusWarning: ReadColorOrDefault(el, "statusWarning", fallback.StatusWarning, logger), + StatusInfo: ReadColorOrDefault(el, "statusInfo", fallback.StatusInfo, logger) ); - private static ThemeLayout ReadLayout(JsonElement el) => + private static ThemeLayout ReadLayout(JsonElement el, ThemeLayout fallback, ILogger? logger) => new( - WindowRounding: ReadFloat(el, "windowRounding"), - ChildRounding: ReadFloat(el, "childRounding"), - PopupRounding: ReadFloat(el, "popupRounding"), - FrameRounding: ReadFloat(el, "frameRounding"), - GrabRounding: ReadFloat(el, "grabRounding"), - TabRounding: ReadFloat(el, "tabRounding"), - ScrollbarRounding: ReadFloat(el, "scrollbarRounding"), - WindowBorderSize: ReadFloat(el, "windowBorderSize"), - FrameBorderSize: ReadFloat(el, "frameBorderSize") + WindowRounding: ReadFloatOrDefault( + el, + "windowRounding", + fallback.WindowRounding, + logger + ), + ChildRounding: ReadFloatOrDefault(el, "childRounding", fallback.ChildRounding, logger), + PopupRounding: ReadFloatOrDefault(el, "popupRounding", fallback.PopupRounding, logger), + FrameRounding: ReadFloatOrDefault(el, "frameRounding", fallback.FrameRounding, logger), + GrabRounding: ReadFloatOrDefault(el, "grabRounding", fallback.GrabRounding, logger), + TabRounding: ReadFloatOrDefault(el, "tabRounding", fallback.TabRounding, logger), + ScrollbarRounding: ReadFloatOrDefault( + el, + "scrollbarRounding", + fallback.ScrollbarRounding, + logger + ), + WindowBorderSize: ReadFloatOrDefault( + el, + "windowBorderSize", + fallback.WindowBorderSize, + logger + ), + FrameBorderSize: ReadFloatOrDefault( + el, + "frameBorderSize", + fallback.FrameBorderSize, + logger + ) ); // Optional in v2 — themes without a typography block default to the @@ -186,4 +223,54 @@ internal static class ThemeJsonLoader throw new FormatException($"Theme JSON property '{name}' must be a number or null"); return (float)v.GetDouble(); } + + // Missing / wrong-typed / unparseable colour slot -> built-in default + one warning. + private static uint ReadColorOrDefault( + JsonElement el, + string name, + uint fallback, + ILogger? logger + ) + { + if (!el.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.String) + { + logger?.LogWarning( + "Theme JSON colour slot '{Slot}' missing or not a string, using built-in default", + name + ); + return fallback; + } + + try + { + return ColourUtil.HexToRgba(v.GetString()!); + } + catch (FormatException) + { + logger?.LogWarning( + "Theme JSON colour slot '{Slot}' has an invalid hex value, using built-in default", + name + ); + return fallback; + } + } + + private static float ReadFloatOrDefault( + JsonElement el, + string name, + float fallback, + ILogger? logger + ) + { + if (!el.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.Number) + { + logger?.LogWarning( + "Theme JSON layout slot '{Slot}' missing or not a number, using built-in default", + name + ); + return fallback; + } + + return (float)v.GetDouble(); + } } diff --git a/HellionChat/Themes/ThemeRegistry.cs b/HellionChat/Themes/ThemeRegistry.cs index f86c5bb..5089c6d 100644 --- a/HellionChat/Themes/ThemeRegistry.cs +++ b/HellionChat/Themes/ThemeRegistry.cs @@ -626,7 +626,7 @@ public sealed class ThemeRegistry { try { - theme = ThemeJsonLoader.LoadFromFile(path); + theme = ThemeJsonLoader.LoadFromFile(path, _logger); // null = hard-cut policy skipped a legacy v1 file. Leave // theme null so the yield-guard below drops the entry. if (theme is not null) diff --git a/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs b/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs index 25045d3..17a9743 100644 --- a/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs +++ b/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs @@ -150,7 +150,7 @@ internal sealed class ThemeImportExportRow Theme? theme; try { - theme = ThemeJsonLoader.LoadFromString(json); + theme = ThemeJsonLoader.LoadFromString(json, _logger); } catch (FormatException) { From ec92cf2c04ae8fb6c0213554bb8aa1404d339149 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 15 Jun 2026 18:03:39 +0200 Subject: [PATCH 05/12] feat(themes): list custom themes in the theme picker --- .../Ui/Components/Settings/ThemePicker.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/HellionChat/Ui/Components/Settings/ThemePicker.cs b/HellionChat/Ui/Components/Settings/ThemePicker.cs index 613cdbc..89a6407 100644 --- a/HellionChat/Ui/Components/Settings/ThemePicker.cs +++ b/HellionChat/Ui/Components/Settings/ThemePicker.cs @@ -57,6 +57,25 @@ internal sealed class ThemePicker } } } + + // Restore (1.5.6 Appearance.cs:79-88): list custom themes so forked/ + // imported themes are selectable, not just built-ins. + var customs = _themes.AllCustom().ToList(); + if (customs.Count > 0) + { + if ( + ImGui.CollapsingHeader( + $"Custom ({customs.Count})", + ImGuiTreeNodeFlags.DefaultOpen + ) + ) + { + foreach (var theme in customs) + { + DrawCard(theme.Slug); + } + } + } } if (locked && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) From fe0414dd2ba140263f18162fda8172659ce463e4 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 15 Jun 2026 18:19:08 +0200 Subject: [PATCH 06/12] feat(themes): apply theme typography font-size overrides on every activation path --- HellionChat/FontManager.cs | 52 ++++++++++++++++--- HellionChat/FontSizeResolver.cs | 18 +++++++ .../Hosting/InitHostedServices.cs | 16 ++++-- HellionChat/PluginHostFactory.cs | 3 +- HellionChat/Themes/ThemeRegistry.cs | 49 +++++++++++------ HellionChat/Themes/ThemeTypography.cs | 1 + 6 files changed, 110 insertions(+), 29 deletions(-) create mode 100644 HellionChat/FontSizeResolver.cs diff --git a/HellionChat/FontManager.cs b/HellionChat/FontManager.cs index e082e27..8b7fb6d 100644 --- a/HellionChat/FontManager.cs +++ b/HellionChat/FontManager.cs @@ -6,6 +6,7 @@ using Dalamud.Interface.GameFonts; using Dalamud.Interface.ManagedFontAtlas; using Dalamud.Interface.Utility; using Dalamud.Plugin; +using HellionChat.Themes; namespace HellionChat; @@ -39,6 +40,12 @@ public sealed class FontManager : IDisposable internal IFontHandle? RegularFont; internal IFontHandle? ItalicFont; + // Wired post-build (B4b-3); a Func keeps FontManager off the theme layer. + private Func? _typographySource; + + // Lets RebuildDelegateFontsIfChanged skip rebuilds when the size is unchanged. + private (float Global, float Symbols) _lastBuiltFingerprint; + // True once every required atlas-owned handle reports Available. Components // gate their first-frame draw on this — without it the layout math would // run against placeholder font metrics and snap when the real atlas @@ -104,6 +111,9 @@ public sealed class FontManager : IDisposable if (Plugin.Config.ItalicEnabled) ItalicFont = BuildItalicFontHandle(atlas); } + + // Source is still null here, so this is the config-only baseline. + _lastBuiltFingerprint = EffectiveFontFingerprint(); } // Called from the settings save path when one of the font-related @@ -125,6 +135,37 @@ public sealed class FontManager : IDisposable ItalicFont?.Dispose(); ItalicFont = Plugin.Config.ItalicEnabled ? BuildItalicFontHandle(atlas) : null; + + _lastBuiltFingerprint = EffectiveFontFingerprint(); + } + + public void SetTypographySource(Func source) => _typographySource = source; + + internal float ResolveGlobalFontPt() => + FontSizeResolver.ResolveGlobalPt( + _typographySource?.Invoke(), + Plugin.Config.UseHellionFont, + Plugin.Config.FontSizeV2, + Plugin.Config.GlobalFontV2.SizePt + ); + + internal float ResolveSymbolsFontPt() => + FontSizeResolver.ResolveSymbolsPt( + _typographySource?.Invoke(), + Plugin.Config.SymbolsFontSizeV2 + ); + + internal (float Global, float Symbols) EffectiveFontFingerprint() => + (ResolveGlobalFontPt(), ResolveSymbolsFontPt()); + + // Rebuilds only when the effective size changed (live fingerprint, TOCTOU-free). + // The atlas rebuild must run on the framework/draw thread — callers ensure that. + internal void RebuildDelegateFontsIfChanged() + { + if (EffectiveFontFingerprint() != _lastBuiltFingerprint) + { + RebuildDelegateFonts(); + } } // Instance method so Ranges / JpRange are reachable without parameter @@ -133,12 +174,7 @@ public sealed class FontManager : IDisposable atlas.NewDelegateFontHandle(e => e.OnPreBuild(tk => { - // UseHellionFont swaps the source font but keeps the size - // selector tied to FontSizeV2 (the bundled font ships as - // a single weight). - var basePt = Plugin.Config.UseHellionFont - ? Plugin.Config.FontSizeV2 - : Plugin.Config.GlobalFontV2.SizePt; + var basePt = ResolveGlobalFontPt(); var config = new SafeFontConfig { SizePt = basePt, GlyphRanges = Ranges }; // Missing embedded resource falls back to the configured // system font instead of taking the whole UiBuilder down. @@ -164,7 +200,7 @@ public sealed class FontManager : IDisposable "noto-cjk-fallback" ); - config.SizePt = Plugin.Config.SymbolsFontSizeV2; + config.SizePt = ResolveSymbolsFontPt(); tk.AddGameSymbol(config); tk.Font = config.MergeFont; @@ -201,7 +237,7 @@ public sealed class FontManager : IDisposable "noto-cjk-fallback" ); - config.SizePt = Plugin.Config.SymbolsFontSizeV2; + config.SizePt = ResolveSymbolsFontPt(); tk.AddGameSymbol(config); tk.Font = config.MergeFont; diff --git a/HellionChat/FontSizeResolver.cs b/HellionChat/FontSizeResolver.cs new file mode 100644 index 0000000..60d1dbd --- /dev/null +++ b/HellionChat/FontSizeResolver.cs @@ -0,0 +1,18 @@ +using HellionChat.Themes; + +namespace HellionChat; + +// Pure size resolution, split out of FontManager so it is unit-testable without +// building the font atlas. A typography override wins; null falls back to config. +internal static class FontSizeResolver +{ + internal static float ResolveGlobalPt( + ThemeTypography? typography, + bool useHellionFont, + float fontSizeV2, + float globalSizePt + ) => typography?.OverrideGlobalFontSizePt ?? (useHellionFont ? fontSizeV2 : globalSizePt); + + internal static float ResolveSymbolsPt(ThemeTypography? typography, float symbolsSizePt) => + typography?.OverrideSymbolsFontSizePt ?? symbolsSizePt; +} diff --git a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs index bc837ea..3929753 100644 --- a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs +++ b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs @@ -16,16 +16,26 @@ namespace HellionChat.Infrastructure.Hosting; // at Build, which runs the service ctor (IPC subscribe etc.) right then // instead of lazily on first GetRequiredService. -internal sealed class ThemeRegistryInitHostedService(ThemeRegistry registry) : IHostedService +internal sealed class ThemeRegistryInitHostedService( + ThemeRegistry registry, + FontManager fontManager +) : IHostedService { - public Task StartAsync(CancellationToken cancellationToken) + public async Task StartAsync(CancellationToken cancellationToken) { // Materialise the lazy AllCustom enumerable so the slug lookup hits a // warm cache; otherwise the first Switch falls through to the built-in // default when Config.Theme points at a custom slug. foreach (var _ in registry.AllCustom()) { } registry.SwitchSilent(Plugin.Config.Theme); - return Task.CompletedTask; + + // B4b-3: point font sizes at the active theme's typography, wire future + // theme switches to the atlas rebuild, and apply the boot theme's override. + fontManager.SetTypographySource(() => registry.Active.Typography); + registry.SetActiveChangedCallback(() => fontManager.RebuildDelegateFontsIfChanged()); + await Plugin.Framework.RunOnFrameworkThread(() => + fontManager.RebuildDelegateFontsIfChanged() + ); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 3c0a7f1..9d53904 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -334,7 +334,8 @@ internal static class PluginHostFactory // does not need one — its ctor runs the init inline inside a single // SuppressAutoRebuild block on eager resolve. services.AddHostedService(sp => new ThemeRegistryInitHostedService( - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddHostedService(sp => new IpcManagerInitHostedService( sp.GetRequiredService() diff --git a/HellionChat/Themes/ThemeRegistry.cs b/HellionChat/Themes/ThemeRegistry.cs index 5089c6d..f8cb3f2 100644 --- a/HellionChat/Themes/ThemeRegistry.cs +++ b/HellionChat/Themes/ThemeRegistry.cs @@ -47,6 +47,12 @@ public sealed class ThemeRegistry public Theme? EditingThemeBuffer => _editingThemeBuffer; public event Action? OnEditingBufferChanged; + // Fired after _active changes (Switch / RefreshActiveIfStale); the init host + // wires it to the font-atlas rebuild. NOT fired by SwitchSilent (boot handles that). + private Action? _onActiveChanged; + + internal void SetActiveChangedCallback(Action callback) => _onActiveChanged = callback; + // Shared slug guard for any code path that turns a slug into a filename. // Both SaveEditingBuffer (F1) and ImportFromPath (M6) call this so the // path-traversal/invalid-char rules live in exactly one place. @@ -190,27 +196,32 @@ public sealed class ThemeRegistry _active = builtin; _active.RecomputeAbgrCache(); _activeCustomPath = null; - return; } - - var customTheme = LoadCustomBySlug(slug, out var customPath); - if (customTheme is not null) + else { - _active = customTheme; - // Defensive — ensures any future theme source always gets a populated cache. - _active.RecomputeAbgrCache(); - _activeCustomPath = customPath; - // Force a first-tick reload-check after the switch so the stamp - // baseline is established on the next RefreshActiveIfStale call. - _lastActiveStamp = DateTime.MinValue; - return; + var customTheme = LoadCustomBySlug(slug, out var customPath); + if (customTheme is not null) + { + _active = customTheme; + // Defensive — ensures any future theme source always gets a populated cache. + _active.RecomputeAbgrCache(); + _activeCustomPath = customPath; + // Force a first-tick reload-check after the switch so the stamp + // baseline is established on the next RefreshActiveIfStale call. + _lastActiveStamp = DateTime.MinValue; + } + else + { + // Fallback: neither built-in nor custom matched. Drop to default + // and clear the active custom path so RefreshActiveIfStale stays idle. + _active = _builtIns[DefaultSlug]; + _active.RecomputeAbgrCache(); + _activeCustomPath = null; + } } - // Fallback: neither built-in nor custom matched. Drop to default - // and clear the active custom path so RefreshActiveIfStale stays idle. - _active = _builtIns[DefaultSlug]; - _active.RecomputeAbgrCache(); - _activeCustomPath = null; + // Notify listeners (the init host wires the font-atlas rebuild here). + _onActiveChanged?.Invoke(); } // SwitchSilent is the plugin-load init path -- identical to Switch @@ -426,6 +437,9 @@ public sealed class ThemeRegistry { reloaded.RecomputeAbgrCache(); _active = reloaded; + // Same-slug save bypasses Switch's notify (it noop'd on same slug); + // fire here so a typography change applies (no-op if size unchanged). + _onActiveChanged?.Invoke(); } } @@ -568,6 +582,7 @@ public sealed class ThemeRegistry // RecomputeAbgrCache happens inside RefreshCustomCache on cache miss. var reloaded = Get(_active.Slug); _active = reloaded; + _onActiveChanged?.Invoke(); } // 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION. diff --git a/HellionChat/Themes/ThemeTypography.cs b/HellionChat/Themes/ThemeTypography.cs index 9f7a981..b889f56 100644 --- a/HellionChat/Themes/ThemeTypography.cs +++ b/HellionChat/Themes/ThemeTypography.cs @@ -1,6 +1,7 @@ namespace HellionChat.Themes; // Optional per-theme; reserved as an extension point for future theme slots. +// Italic body-size override intentionally omitted (v1.9.0 Typography-Polish). public sealed record ThemeTypography( float? OverrideGlobalFontSizePt = null, float? OverrideSymbolsFontSizePt = null From 18834cddae3715f9d527129d1196eb57c374b572 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 15 Jun 2026 18:41:03 +0200 Subject: [PATCH 07/12] feat(fonts): restore the font-selection UI in the appearance tab --- HellionChat/PluginHostFactory.cs | 7 +- .../Ui/Components/Settings/FontsSection.cs | 206 ++++++++++++++++++ .../Components/Settings/Tabs/AppearanceTab.cs | 7 +- 3 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 HellionChat/Ui/Components/Settings/FontsSection.cs diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 9d53904..09186f2 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -173,11 +173,16 @@ internal static class PluginHostFactory sp.GetRequiredService(), sp.GetRequiredService>() )); + services.AddSingleton(sp => new Ui.Components.Settings.FontsSection( + sp.GetRequiredService(), + sp.GetRequiredService() + )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.AppearanceTab( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.GeneralTab( sp.GetRequiredService() diff --git a/HellionChat/Ui/Components/Settings/FontsSection.cs b/HellionChat/Ui/Components/Settings/FontsSection.cs new file mode 100644 index 0000000..ce6c604 --- /dev/null +++ b/HellionChat/Ui/Components/Settings/FontsSection.cs @@ -0,0 +1,206 @@ +using Dalamud; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.FontIdentifier; +using HellionChat.Resources; +using HellionChat.Util; + +namespace HellionChat.Ui.Components.Settings; + +// Restores the 1.5.6 font-selection UI (1d3b429:Appearance.cs:249-405): pick the +// bundled Hellion font vs a custom global/Japanese/italic font, sizes, and extra +// glyph ranges. v1.6.0 saves live, so any change persists and rebuilds the atlas +// at once (RebuildDelegateFonts, unconditional — a face change keeps the size, so +// the size-gated IfChanged path would miss it). +internal sealed class FontsSection +{ + private readonly Plugin _plugin; + private readonly FontManager _fontManager; + + public FontsSection(Plugin plugin, FontManager fontManager) + { + _plugin = plugin; + _fontManager = fontManager; + } + + private void Apply() + { + _plugin.SaveConfig(); + _fontManager.RebuildDelegateFonts(); + } + + public void Draw() + { + if (!ImGui.CollapsingHeader(HellionStrings.Settings_Section_Fonts)) + return; + + // Readout so the user can see which font is actually active. + var active = + Plugin.Config.UseHellionFont ? "Hellion Inter (bundled)" + : Plugin.Config.FontsEnabled + ? $"Global: {Plugin.Config.GlobalFontV2.FontId.Family.EnglishName}" + : "FFXIV game font"; + ImGui.TextDisabled($"Active: {active}"); + ImGui.Spacing(); + + if ( + ImGui.Checkbox( + HellionStrings.Theme_UseHellionFont_Name, + ref Plugin.Config.UseHellionFont + ) + ) + { + if (Plugin.Config.UseHellionFont) + Plugin.Config.FontsEnabled = false; + Apply(); + } + ImGuiUtil.HelpMarker(HellionStrings.Theme_UseHellionFont_Description); + ImGui.Spacing(); + + if (Plugin.Config.UseHellionFont) + { + DrawSizeCombo(Language.Options_FontSize_Name, ref Plugin.Config.FontSizeV2); + ImGui.Spacing(); + } + else if (ImGui.Checkbox(Language.Options_FontsEnabled, ref Plugin.Config.FontsEnabled)) + { + Apply(); + } + + var unused = false; + if (!Plugin.Config.UseHellionFont && !Plugin.Config.FontsEnabled) + { + DrawSizeCombo(Language.Options_FontSize_Name, ref Plugin.Config.FontSizeV2); + } + else if (!Plugin.Config.UseHellionFont) + { + DrawFontChooser( + Language.Options_Font_Name, + Plugin.Config.GlobalFontV2, + false, + ref unused, + spec => Plugin.Config.GlobalFontV2 = spec, + () => Plugin.Config.GlobalFontV2 = DefaultFont(DalamudAsset.NotoSansCjkRegular), + "global" + ); + ImGuiUtil.HelpMarker( + string.Format(Language.Options_Font_Description, Plugin.PluginName) + ); + ImGuiUtil.WarningText(Language.Options_Font_Warning); + ImGui.Spacing(); + + DrawFontChooser( + Language.Options_JapaneseFont_Name, + Plugin.Config.JapaneseFontV2, + false, + ref unused, + spec => Plugin.Config.JapaneseFontV2 = spec, + () => Plugin.Config.JapaneseFontV2 = DefaultFont(DalamudAsset.NotoSansCjkMedium), + "japanese", + id => !id.LocaleNames?.ContainsKey("ja-jp") ?? false, + "いろはにほへと ちりぬるを" + ); + ImGuiUtil.HelpMarker( + string.Format(Language.Options_JapaneseFont_Description, Plugin.PluginName) + ); + ImGui.Spacing(); + + DrawFontChooser( + Language.Options_ItalicFont_Name, + Plugin.Config.ItalicFontV2, + true, + ref Plugin.Config.ItalicEnabled, + spec => Plugin.Config.ItalicFontV2 = spec, + () => + { + Plugin.Config.ItalicEnabled = false; + Plugin.Config.ItalicFontV2 = DefaultFont(DalamudAsset.NotoSansCjkRegular); + }, + "italic" + ); + ImGuiUtil.HelpMarker( + string.Format(Language.Options_Italic_Description, Plugin.PluginName) + ); + ImGui.Spacing(); + } + + // 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)) + { + ImGuiUtil.HelpMarker( + string.Format(Language.Options_ExtraGlyphs_Description, Plugin.PluginName) + ); + + var range = (int)Plugin.Config.ExtraGlyphRanges; + var changed = false; + foreach (var extra in Enum.GetValues()) + changed |= ImGui.CheckboxFlags(extra.Name(), ref range, (int)extra); + + if (changed) + { + Plugin.Config.ExtraGlyphRanges = (ExtraGlyphRanges)range; + Apply(); + } + } + + DrawSizeCombo(Language.Options_SymbolsFontSize_Name, ref Plugin.Config.SymbolsFontSizeV2); + ImGuiUtil.HelpMarker(Language.Options_SymbolsFontSize_Description); + ImGui.Spacing(); + } + + private void DrawSizeCombo(string label, ref float size) + { + var before = size; + ImGuiUtil.FontSizeCombo(label, ref size); + if (!size.Equals(before)) + Apply(); + } + + private void DrawFontChooser( + string label, + SingleFontSpec font, + bool checkbox, + ref bool checkboxValue, + Action set, + Action reset, + string resetId, + Predicate? exclusion = null, + string? preview = null + ) + { + var prevCheckbox = checkboxValue; + var chooser = ImGuiUtil.FontChooser( + label, + font, + checkbox, + ref checkboxValue, + exclusion, + preview + ); + if (checkbox && checkboxValue != prevCheckbox) + Apply(); + + // The chooser dialog resolves on a worker thread; marshal the result back + // onto the framework thread before touching config + the font atlas. + chooser?.ResultTask.ContinueWith(r => + { + if (r.IsCompletedSuccessfully) + Plugin.Framework.Run(() => + { + set(r.Result); + Apply(); + }); + }); + + ImGui.SameLine(); + if (ImGui.Button($"Reset##{resetId}")) + { + reset(); + Apply(); + } + } + + private static SingleFontSpec DefaultFont(DalamudAsset asset) => + new() { FontId = new DalamudAssetFontAndFamilyId(asset), SizePt = 12.75f }; +} diff --git a/HellionChat/Ui/Components/Settings/Tabs/AppearanceTab.cs b/HellionChat/Ui/Components/Settings/Tabs/AppearanceTab.cs index 1782df3..5797e79 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/AppearanceTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/AppearanceTab.cs @@ -11,18 +11,21 @@ internal sealed class AppearanceTab private readonly ColorPicker _color; private readonly LivePreviewPanel _preview; private readonly ThemeImportExportRow _importExport; + private readonly FontsSection _fonts; public AppearanceTab( ThemePicker picker, ColorPicker color, LivePreviewPanel preview, - ThemeImportExportRow importExport + ThemeImportExportRow importExport, + FontsSection fonts ) { _picker = picker; _color = color; _preview = preview; _importExport = importExport; + _fonts = fonts; } public void Draw() @@ -38,6 +41,8 @@ internal sealed class AppearanceTab ImGui.Spacing(); _importExport.Draw(); ImGui.Separator(); + _fonts.Draw(); + ImGui.Separator(); _color.Draw(); } } From 8cbf9c554ff6d1f81afb2d0a765b5e36d571aaf1 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 15 Jun 2026 18:49:45 +0200 Subject: [PATCH 08/12] feat(themes): restore the per-card theme preview mockup in the picker --- .../Ui/Components/Settings/ThemeMockup.cs | 82 ++++++++++ .../Ui/Components/Settings/ThemePicker.cs | 144 +++++++++++------- 2 files changed, 170 insertions(+), 56 deletions(-) create mode 100644 HellionChat/Ui/Components/Settings/ThemeMockup.cs diff --git a/HellionChat/Ui/Components/Settings/ThemeMockup.cs b/HellionChat/Ui/Components/Settings/ThemeMockup.cs new file mode 100644 index 0000000..92c3d66 --- /dev/null +++ b/HellionChat/Ui/Components/Settings/ThemeMockup.cs @@ -0,0 +1,82 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using HellionChat.Themes; +using HellionChat.Util; + +namespace HellionChat.Ui.Components.Settings; + +// Mini chat-window mockup drawn straight into the WindowDrawList (restored from +// 1.5.6 ThemeMockup). No textures, no per-frame allocations — pure rect/text. +internal static class ThemeMockup +{ + public static void Draw(Vector2 origin, Vector2 size, Theme theme) + { + var draw = ImGui.GetWindowDrawList(); + var c = theme.Colors; + + draw.AddRectFilled( + origin, + origin + size, + ColourUtil.RgbaToAbgr(c.WindowBg | 0xFFu), + theme.Layout.WindowRounding + ); + + var titleHeight = 14f; + draw.AddRectFilled( + origin, + new Vector2(origin.X + size.X, origin.Y + titleHeight), + ColourUtil.RgbaToAbgr(c.Identity), + theme.Layout.WindowRounding + ); + + var tabY = origin.Y + titleHeight + 4f; + var tabHeight = 12f; + for (var i = 0; i < 3; i++) + { + var tabX = origin.X + 6f + i * 28f; + var color = i == 0 ? c.FrameBg : c.ChildBg; + draw.AddRectFilled( + new Vector2(tabX, tabY), + new Vector2(tabX + 26f, tabY + tabHeight), + ColourUtil.RgbaToAbgr(color), + theme.Layout.TabRounding + ); + + if (i == 0) + { + draw.AddRectFilled( + new Vector2(tabX, tabY + tabHeight - 2f), + new Vector2(tabX + 26f, tabY + tabHeight), + ColourUtil.RgbaToAbgr(c.Primary) + ); + } + } + + var rowY = tabY + tabHeight + 6f; + var rowHeight = 18f; + draw.AddRectFilled( + new Vector2(origin.X + 6f, rowY), + new Vector2(origin.X + size.X - 6f, rowY + rowHeight), + ColourUtil.RgbaToAbgr(c.Surface), + 2f + ); + + var btnW = 28f; + var btnH = 10f; + var btnX = origin.X + size.X - btnW - 6f; + var btnY = origin.Y + size.Y - btnH - 6f; + draw.AddRectFilled( + new Vector2(btnX, btnY), + new Vector2(btnX + btnW, btnY + btnH), + ColourUtil.RgbaToAbgr(c.Accent), + theme.Layout.FrameRounding + ); + + draw.AddRect( + origin, + origin + size, + ColourUtil.RgbaToAbgr(c.Border), + theme.Layout.WindowRounding + ); + } +} diff --git a/HellionChat/Ui/Components/Settings/ThemePicker.cs b/HellionChat/Ui/Components/Settings/ThemePicker.cs index 89a6407..eab7a23 100644 --- a/HellionChat/Ui/Components/Settings/ThemePicker.cs +++ b/HellionChat/Ui/Components/Settings/ThemePicker.cs @@ -29,6 +29,8 @@ internal sealed class ThemePicker // to enforce coverage. Kept on the static map so the test does not pierce instance state. internal static IEnumerable CategoryMapSlugs => CategoryMap.SelectMany(c => c.Slugs); + private const float CardHeight = 132f; + private readonly ThemeRegistry _themes; private readonly Plugin _plugin; @@ -51,10 +53,7 @@ internal sealed class ThemePicker : ImGuiTreeNodeFlags.None; if (ImGui.CollapsingHeader(category, flags)) { - foreach (var slug in slugs) - { - DrawCard(slug); - } + DrawThemeGrid(Resolve(slugs)); } } @@ -70,10 +69,7 @@ internal sealed class ThemePicker ) ) { - foreach (var theme in customs) - { - DrawCard(theme.Slug); - } + DrawThemeGrid(customs); } } } @@ -84,61 +80,97 @@ internal sealed class ThemePicker } } - private void DrawCard(string slug) + private IEnumerable Resolve(IEnumerable slugs) { - if (!_themes.TryGet(slug, out var theme)) - { + foreach (var slug in slugs) + if (_themes.TryGet(slug, out var theme)) + yield return theme; + } + + // Grid of theme cards, each carrying a mini chat mockup (restored from 1.5.6 + // DrawThemeGrid + ThemeMockup). Column count adapts to the available width. + private void DrawThemeGrid(IEnumerable themes) + { + var list = themes.ToList(); + if (list.Count == 0) return; + + var avail = ImGui.GetContentRegionAvail().X; + var columns = avail >= 460f ? 2 : 1; + var cardWidth = columns > 1 ? (avail - (columns - 1) * 8f) / columns : avail; + + for (var i = 0; i < list.Count; i++) + { + DrawThemeCard(list[i], cardWidth, CardHeight); + if ((i + 1) % columns != 0 && i != list.Count - 1) + ImGui.SameLine(); + } + } + + private void DrawThemeCard(Theme theme, float w, float h) + { + ImGui.BeginGroup(); + + var isActive = string.Equals( + theme.Slug, + _themes.Active.Slug, + StringComparison.OrdinalIgnoreCase + ); + var origin = ImGui.GetCursorScreenPos(); + var clicked = ImGui.InvisibleButton($"##theme-card-{theme.Slug}", new Vector2(w, h)); + var hovered = ImGui.IsItemHovered(); + + var draw = ImGui.GetWindowDrawList(); + draw.AddRectFilled( + origin, + origin + new Vector2(w, h), + ColourUtil.RgbaToAbgr(theme.Colors.WindowBg | 0xFFu), + 4f + ); + + if (isActive) + { + draw.AddRect( + origin, + origin + new Vector2(w, h), + ColourUtil.RgbaToAbgr(theme.Colors.Primary), + 4f, + ImDrawFlags.None, + 2f + ); + } + else if (hovered) + { + draw.AddRect( + origin, + origin + new Vector2(w, h), + ColourUtil.RgbaToAbgr(theme.Colors.PrimaryLight & 0xFFFFFF99u), + 4f, + ImDrawFlags.None, + 1f + ); } - var active = _themes.Active.Slug == slug; - var label = $"{theme.Name} — {theme.Author}##theme-card-{slug}"; + ThemeMockup.Draw(origin + new Vector2(12f, 12f), new Vector2(w - 24f, 60f), theme); - // Selectable uses ImGui's default Header colour, not the theme's Surface. - // Swatch overlay below carries the theme cue; v1.7.x-polish if testers flag the mismatch. - if (ImGui.Selectable(label, active, ImGuiSelectableFlags.None, new Vector2(0, 40))) + draw.AddText( + origin + new Vector2(12f, 80f), + ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary), + theme.Name + ); + draw.AddText( + origin + new Vector2(12f, 100f), + ColourUtil.RgbaToAbgr(theme.Colors.TextMuted), + theme.Author + ); + + ImGui.EndGroup(); + + if (clicked) { - _themes.Switch(slug); - Plugin.Config.Theme = slug; + _themes.Switch(theme.Slug); + Plugin.Config.Theme = theme.Slug; _plugin.SaveConfig(); } - - // Mini-Preview-Swatch overlay (3 ABGR boxes on the right side of the card). - // Selectable owns the hit-box; the DrawList overlay is decorative — full card area - // remains the click target, not just the swatch. - // - // RGBA-vs-ABGR-Disziplin: ThemeColors slots hold uint values in 0xRRGGBBAA layout - // (see ThemeColors.cs header comment). ImGui draw calls expect ABGR (native byte order) - // — pass theme colours through ColourUtil.RgbaToAbgr before any AddRectFilled / AddText - // / AddLine. The repo-wide pattern is "swap at the boundary" (see InputBar swap-at- - // boundary pattern). Forgetting the swap renders Red and Blue channels swapped and - // shifts the alpha byte into the green slot. - var draw = ImGui.GetWindowDrawList(); - var max = ImGui.GetItemRectMax(); - var min = ImGui.GetItemRectMin(); - var swatchY = min.Y + 12; - DrawSwatch( - draw, - new Vector2(max.X - 60, swatchY), - ColourUtil.RgbaToAbgr(theme.Colors.Surface) - ); - DrawSwatch( - draw, - new Vector2(max.X - 42, swatchY), - ColourUtil.RgbaToAbgr(theme.Colors.Primary) - ); - DrawSwatch( - draw, - new Vector2(max.X - 24, swatchY), - ColourUtil.RgbaToAbgr(theme.Colors.Accent) - ); - } - - // Caller contract: `colorAbgr` is already byte-swapped from the ThemeColors RGBA backing - // field via ColourUtil.RgbaToAbgr. Passing a raw RGBA value here renders with the wrong - // channel order. - private static void DrawSwatch(ImDrawListPtr draw, Vector2 topLeft, uint colorAbgr) - { - draw.AddRectFilled(topLeft, topLeft + new Vector2(14, 14), colorAbgr, 2f); } } From 0352e6c1997240dbd5a704277cfe211da59ec5d9 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 15 Jun 2026 19:12:09 +0200 Subject: [PATCH 09/12] feat(themes): restore the chat-channel colour editor in the appearance tab Brings back the 1.5.6 per-channel colour editor lost in the v1.6.0 rewrite: presets (incl. brand styling), per-ChatType reset/import-from-game/colour-edit over Config.ChatColours, the colour-selected-input-channel toggle, and the theme adopt-banner. The colour-edit drag recolours live but defers SaveConfig to release (IsItemDeactivatedAfterEdit) to avoid a per-frame full-config write. --- HellionChat/PluginHostFactory.cs | 7 +- .../Components/Settings/ChatColourPicker.cs | 242 ++++++++++++++++++ .../Components/Settings/Tabs/AppearanceTab.cs | 8 +- 3 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 HellionChat/Ui/Components/Settings/ChatColourPicker.cs diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 09186f2..e26d9aa 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -177,12 +177,17 @@ internal static class PluginHostFactory sp.GetRequiredService(), sp.GetRequiredService() )); + services.AddSingleton(sp => new Ui.Components.Settings.ChatColourPicker( + sp.GetRequiredService(), + sp.GetRequiredService() + )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.AppearanceTab( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.GeneralTab( sp.GetRequiredService() diff --git a/HellionChat/Ui/Components/Settings/ChatColourPicker.cs b/HellionChat/Ui/Components/Settings/ChatColourPicker.cs new file mode 100644 index 0000000..62f1e24 --- /dev/null +++ b/HellionChat/Ui/Components/Settings/ChatColourPicker.cs @@ -0,0 +1,242 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using HellionChat.Code; +using HellionChat.Resources; +using HellionChat.Themes; +using HellionChat.Util; + +namespace HellionChat.Ui.Components.Settings; + +// Restores the 1.5.6 chat-channel colour editor (1d3b429:Appearance.cs:189-243, +// 409-534): presets, the per-ChatType ColorEdit3 over Config.ChatColours (which +// ChunkRenderer consumes), reset/import-game-colour buttons, and the apply-banner +// that adopts the active theme's chatChannels. v1.6.0 saves live + refreshes cache. +internal sealed class ChatColourPicker +{ + private readonly Plugin _plugin; + private readonly ThemeRegistry _themes; + private string? _applyDismissedFor; + private string? _lastSeenSlug; + + public ChatColourPicker(Plugin plugin, ThemeRegistry themes) + { + _plugin = plugin; + _themes = themes; + } + + // Drawn directly under the theme picker (1.5.6 placement) so the adopt prompt + // sits next to the theme that triggered it, not at the bottom of the tab. + public void DrawThemeAdoptBanner() => DrawApplyBanner(_themes.Active); + + public void Draw() + { + if (!ImGui.CollapsingHeader(HellionStrings.Settings_Section_Colours)) + return; + + DrawPresetButtons(); + ImGui.TextDisabled(HellionStrings.Settings_Appearance_Colours_PresetsHint); + ImGui.Spacing(); + 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 + // a full-config disk write every frame. + var commit = false; + var liveOnly = false; + foreach (var (_, types) in ChatTypeExt.SortOrder) + { + foreach (var type in types) + { + if ( + ImGuiUtil.IconButton( + FontAwesomeIcon.UndoAlt, + $"{type}", + Language.Options_ChatColours_Reset + ) + ) + { + Plugin.Config.ChatColours.Remove(type); + commit = true; + } + + ImGui.SameLine(); + + if ( + ImGuiUtil.IconButton( + FontAwesomeIcon.LongArrowAltDown, + $"{type}", + Language.Options_ChatColours_Import + ) + ) + { + var gameColour = _plugin.Functions.Chat.GetChannelColor(type); + Plugin.Config.ChatColours[type] = gameColour ?? type.DefaultColor() ?? 0; + commit = true; + } + + ImGui.SameLine(); + + var vec = Plugin.Config.ChatColours.TryGetValue(type, out var colour) + ? ColourUtil.RgbaToVector3(colour) + : ColourUtil.RgbaToVector3(type.DefaultColor() ?? 0); + if (ImGui.ColorEdit3(type.Name(), ref vec, ImGuiColorEditFlags.NoInputs)) + { + Plugin.Config.ChatColours[type] = ColourUtil.Vector3ToRgba(vec); + liveOnly = true; + } + if (ImGui.IsItemDeactivatedAfterEdit()) + commit = true; + } + } + + if (commit) + ApplyChatColourChange(); + else if (liveOnly) + GlobalParametersCache.Refresh(); + + ImGui.Spacing(); + } + + private void DrawPresetButtons() + { + var first = true; + foreach (var (_, preset) in ChatColourPresets.All) + { + if (!first) + ImGui.SameLine(); + first = false; + + var brand = preset.IsBrandPreset; + if (brand) + { + var border = ColourUtil.RgbaToVector3(ColourUtil.ComponentsToRgba(255, 128, 200)); + var btn = ColourUtil.RgbaToVector3(ColourUtil.ComponentsToRgba(74, 42, 106)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(border, 1f)); + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(btn, 1f)); + ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.5f); + } + + if (ImGui.Button(GetPresetLabel(preset))) + ApplyPreset(preset); + + if (brand) + { + ImGui.PopStyleVar(); + ImGui.PopStyleColor(2); + } + } + } + + private static string GetPresetLabel(ChatColourPreset preset) + { + var localized = HellionStrings.ResourceManager.GetString( + preset.LocalizationKey, + HellionStrings.Culture + ); + return string.IsNullOrEmpty(localized) ? preset.DisplayName : localized; + } + + private void ApplyPreset(ChatColourPreset preset) + { + foreach (var (channel, colour) in preset.Colours) + Plugin.Config.ChatColours[channel] = colour; + ApplyChatColourChange(); + } + + private void ApplyChatColourChange() + { + _plugin.SaveConfig(); + GlobalParametersCache.Refresh(); + } + + // Offers to adopt the active theme's chatChannels into Config.ChatColours when + // they differ; dismissable per theme slug (matches 1.5.6 banner behaviour). + private void DrawApplyBanner(Theme active) + { + // Clear the per-theme dismiss whenever the active theme changes, so leaving + // a theme and returning re-offers the prompt (1.5.6 reset this on every switch). + if (active.Slug != _lastSeenSlug) + { + _applyDismissedFor = null; + _lastSeenSlug = active.Slug; + } + + if (active.ChatColors is not { Channels.Count: > 0 } themeChatColors) + return; + if (_applyDismissedFor == active.Slug) + return; + + var alreadyMatching = themeChatColors.Channels.All(kvp => + Plugin.Config.ChatColours.TryGetValue(kvp.Key, out var current) && current == kvp.Value + ); + if (alreadyMatching) + return; + + ImGui.Spacing(); + var border = ColourUtil.RgbaToAbgr(active.Colors.Primary); + var bgFill = ColourUtil.RgbaToAbgr((active.Colors.Surface & 0xFFFFFF00u) | 0xCCu); + var origin = ImGui.GetCursorScreenPos(); + var width = ImGui.GetContentRegionAvail().X; + const float height = 64f; + var draw = ImGui.GetWindowDrawList(); + draw.AddRectFilled(origin, origin + new Vector2(width, height), bgFill, 4f); + draw.AddRect(origin, origin + new Vector2(width, height), border, 4f, ImDrawFlags.None, 1f); + draw.AddText( + origin + new Vector2(12f, 10f), + ColourUtil.RgbaToAbgr(active.Colors.TextPrimary), + HellionStrings.Settings_Themes_ApplyChatColors_Hint + ); + + using ( + ImRaii.PushColor( + ImGuiCol.Button, + new Vector4(ColourUtil.RgbaToVector3(active.Colors.Primary), 1f) + ) + ) + using ( + ImRaii.PushColor( + ImGuiCol.ButtonHovered, + new Vector4(ColourUtil.RgbaToVector3(active.Colors.PrimaryLight), 1f) + ) + ) + using ( + ImRaii.PushColor( + ImGuiCol.ButtonActive, + new Vector4(ColourUtil.RgbaToVector3(active.Colors.PrimaryDark), 1f) + ) + ) + { + ImGui.SetCursorScreenPos(origin + new Vector2(12f, 32f)); + if (ImGui.Button(HellionStrings.Settings_Themes_ApplyChatColors_Apply)) + { + foreach (var kvp in themeChatColors.Channels) + Plugin.Config.ChatColours[kvp.Key] = kvp.Value; + _applyDismissedFor = active.Slug; + ApplyChatColourChange(); + } + } + + ImGui.SameLine(); + if (ImGui.Button(HellionStrings.Settings_Themes_ApplyChatColors_Keep)) + _applyDismissedFor = active.Slug; + + ImGui.SetCursorScreenPos(origin + new Vector2(0f, height + 8f)); + ImGui.Spacing(); + } +} diff --git a/HellionChat/Ui/Components/Settings/Tabs/AppearanceTab.cs b/HellionChat/Ui/Components/Settings/Tabs/AppearanceTab.cs index 5797e79..b0035ed 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/AppearanceTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/AppearanceTab.cs @@ -12,13 +12,15 @@ internal sealed class AppearanceTab private readonly LivePreviewPanel _preview; private readonly ThemeImportExportRow _importExport; private readonly FontsSection _fonts; + private readonly ChatColourPicker _chatColours; public AppearanceTab( ThemePicker picker, ColorPicker color, LivePreviewPanel preview, ThemeImportExportRow importExport, - FontsSection fonts + FontsSection fonts, + ChatColourPicker chatColours ) { _picker = picker; @@ -26,6 +28,7 @@ internal sealed class AppearanceTab _preview = preview; _importExport = importExport; _fonts = fonts; + _chatColours = chatColours; } public void Draw() @@ -38,12 +41,15 @@ internal sealed class AppearanceTab if (left.Success) { _picker.Draw(); + _chatColours.DrawThemeAdoptBanner(); ImGui.Spacing(); _importExport.Draw(); ImGui.Separator(); _fonts.Draw(); ImGui.Separator(); _color.Draw(); + ImGui.Separator(); + _chatColours.Draw(); } } ImGui.SameLine(); From 6813b80d5834071cfbbbe1acd5d01b218295d56c Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 15 Jun 2026 19:53:31 +0200 Subject: [PATCH 10/12] feat(themes): restore the header theme/tab quick-picker Brings back the 1.5.4 quick-picker lost in the v1.6.0 rewrite: a palette button in the input-bar button row (left of the cog) opens a popup that switches the theme (built-in + custom, active row checked) and jumps between chat tabs without opening settings. Theme switch mirrors the settings ThemePicker; the tab jump routes through a new MainWindow.ActivateTab that replays the click path (previous -> set -> OnTabActivated) so tell/unread handling is unchanged. Main window only -- pop-out InputBars get a null picker. Adds QuickPickerSelfTestStep. --- HellionChat/Plugin.cs | 1 + HellionChat/PluginHostFactory.cs | 7 +- .../SelfTests/QuickPickerSelfTestStep.cs | 45 ++++++ HellionChat/Ui/Components/InputBar.cs | 24 ++- HellionChat/Ui/Components/ThemeQuickPicker.cs | 143 ++++++++++++++++++ HellionChat/Ui/Windows/MainWindow.cs | 13 ++ 6 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 HellionChat/SelfTests/QuickPickerSelfTestStep.cs create mode 100644 HellionChat/Ui/Components/ThemeQuickPicker.cs diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 96d278d..c38232e 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -382,6 +382,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.SidebarModeAutoSwitchStep(this), new SelfTests.ColorEditorBufferStep(this), new SelfTests.ThemePickerCategoryStep(this), + new SelfTests.QuickPickerSelfTestStep(this), new SelfTests.SettingsWindowOpenStep(this), new SelfTests.OnOpenMainUiRoutesMainWindowStep(this), new SelfTests.TypingIpcStateStep(this), diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index e26d9aa..3c6d0d4 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -144,6 +144,10 @@ internal static class PluginHostFactory sp.GetRequiredService() )); services.AddSingleton(_ => new Ui.Components.SymbolPicker()); + services.AddSingleton(sp => new Ui.Components.ThemeQuickPicker( + sp.GetRequiredService(), + sp.GetRequiredService() + )); services.AddSingleton(sp => new Ui.Components.InputBar( sp.GetRequiredService(), sp.GetRequiredService(), @@ -151,7 +155,8 @@ internal static class PluginHostFactory sp.GetRequiredService(), sp.GetRequiredService>(), () => sp.GetRequiredService().SettingsWindow.Toggle(), - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.TabSidebar( sp.GetRequiredService() diff --git a/HellionChat/SelfTests/QuickPickerSelfTestStep.cs b/HellionChat/SelfTests/QuickPickerSelfTestStep.cs new file mode 100644 index 0000000..2564f26 --- /dev/null +++ b/HellionChat/SelfTests/QuickPickerSelfTestStep.cs @@ -0,0 +1,45 @@ +using System.Linq; +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Resources; + +namespace HellionChat.SelfTests; + +// Guards the header quick-picker's data contract: its three section/tooltip +// strings must resolve and there must be at least one theme to switch to. The +// render path itself (FontAwesome push) can't run headless, so this checks the +// data the popup depends on, not the draw. +internal sealed class QuickPickerSelfTestStep : ISelfTestStep +{ + private readonly Plugin _plugin; + + public QuickPickerSelfTestStep(Plugin plugin) + { + _plugin = plugin; + } + + public string Name => "Hellion Chat - Theme quick-picker contract"; + + public SelfTestStepResult RunStep() + { + if ( + string.IsNullOrEmpty(HellionStrings.Settings_QuickPicker_Tooltip) + || string.IsNullOrEmpty(HellionStrings.Settings_QuickPicker_Themes_Header) + || string.IsNullOrEmpty(HellionStrings.Settings_QuickPicker_Tabs_Header) + ) + { + ImGui.Text("Quick-picker strings did not resolve."); + return SelfTestStepResult.Fail; + } + + if (!_plugin.ThemeRegistry.BuiltinSlugs.Any()) + { + ImGui.Text("No built-in themes available for the quick-picker."); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/Ui/Components/InputBar.cs b/HellionChat/Ui/Components/InputBar.cs index 71b248d..1552b66 100644 --- a/HellionChat/Ui/Components/InputBar.cs +++ b/HellionChat/Ui/Components/InputBar.cs @@ -40,6 +40,11 @@ internal sealed class InputBar private readonly Action _onOpenSettings; private readonly CommandHelpWindow _commandHelpWindow; + // Null in pop-out windows: the theme/tab quick-picker only belongs in the + // main window (1.5.4 had no pop-outs, and a tab jump from a channel-bound + // pop-out would be confusing). The main window's InputBar gets the instance. + private readonly ThemeQuickPicker? _themeQuickPicker; + private string _pendingMessage = string.Empty; private bool _isFocused; private bool _wasInputTextHovered; @@ -75,7 +80,8 @@ internal sealed class InputBar TokenResolver resolver, ILogger logger, Action onOpenSettings, - CommandHelpWindow commandHelpWindow + CommandHelpWindow commandHelpWindow, + ThemeQuickPicker? themeQuickPicker = null ) { _symbolPicker = symbolPicker; @@ -85,6 +91,7 @@ internal sealed class InputBar _logger = logger; _onOpenSettings = onOpenSettings; _commandHelpWindow = commandHelpWindow; + _themeQuickPicker = themeQuickPicker; } public string PendingMessage => _pendingMessage; @@ -188,6 +195,9 @@ internal sealed class InputBar if (inserted is not null && _pendingMessage.Length + inserted.Length <= BufferCapacity) _pendingMessage += inserted; + // Theme/tab quick-picker popup (main window only; null in pop-outs). + _themeQuickPicker?.Draw(); + // Auto-translate popup runs after all other popups so the OpenPopup // anchor lands on the InputText item we just drew. DrawAutoCompletePopup(); @@ -507,6 +517,18 @@ internal sealed class InputBar ImGui.SetTooltip("Insert symbol"); } + if (_themeQuickPicker is not null) + { + ImGui.SameLine(); + if (ImGui.Button(FontAwesomeIcon.Palette.ToIconString())) + _themeQuickPicker.OpenPopup(); + if (ImGui.IsItemHovered()) + { + using (ImRaii.DefaultFont()) + ImGui.SetTooltip(HellionStrings.Settings_QuickPicker_Tooltip); + } + } + ImGui.SameLine(); if (ImGui.Button(FontAwesomeIcon.Cog.ToIconString())) { diff --git a/HellionChat/Ui/Components/ThemeQuickPicker.cs b/HellionChat/Ui/Components/ThemeQuickPicker.cs new file mode 100644 index 0000000..3cb763d --- /dev/null +++ b/HellionChat/Ui/Components/ThemeQuickPicker.cs @@ -0,0 +1,143 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using HellionChat.Resources; +using HellionChat.Themes; +using HellionChat.Ui.Components.Settings; + +namespace HellionChat.Ui.Components; + +// Restores the 1.5.4 header quick-picker (a46d89c:ChatLogWindow.cs:481-558): a +// palette button in the input-bar button row opening a popup that switches the +// theme (built-in + custom) and jumps between chat tabs without opening settings. +// Switch path mirrors the settings ThemePicker exactly; the tab jump routes +// through MainWindow.ActivateTab so tell/unread handling matches a real tab click. +internal sealed class ThemeQuickPicker +{ + private const string PopupId = "##hellion-quick-picker"; + private const float SectionWidth = 220f; + private const float RowHeight = 22f; + private const float MaxSectionHeight = 200f; + + private readonly ThemeRegistry _themes; + private readonly Plugin _plugin; + + public ThemeQuickPicker(ThemeRegistry themes, Plugin plugin) + { + _themes = themes; + _plugin = plugin; + } + + public void OpenPopup() => ImGui.OpenPopup(PopupId); + + public void Draw() + { + using var popup = ImRaii.Popup(PopupId); + if (!popup) + return; + + DrawThemeSection(); + ImGui.Spacing(); + DrawTabSection(); + } + + private void DrawThemeSection() + { + ImGui.TextDisabled(HellionStrings.Settings_QuickPicker_Themes_Header); + ImGui.Separator(); + + var themes = AllThemes(); + var height = MathF.Min(themes.Count * RowHeight, MaxSectionHeight); + using var child = ImRaii.Child( + "##hellion-quick-picker-themes", + new Vector2(SectionWidth, height) + ); + if (!child) + return; + + var activeSlug = _themes.Active.Slug; + foreach (var theme in themes) + { + var isActive = string.Equals( + theme.Slug, + activeSlug, + StringComparison.OrdinalIgnoreCase + ); + DrawGlyph(isActive); + if ( + ImGui.Selectable( + $"{theme.Name}##quick-theme-{theme.Slug}", + isActive, + ImGuiSelectableFlags.DontClosePopups + ) && !isActive + ) + { + _themes.Switch(theme.Slug); + Plugin.Config.Theme = theme.Slug; + _plugin.SaveConfig(); + } + } + } + + private void DrawTabSection() + { + ImGui.TextDisabled(HellionStrings.Settings_QuickPicker_Tabs_Header); + ImGui.Separator(); + + // Snapshot so a worker-thread temp-tab strip can't shift the list mid-loop. + var tabs = Plugin.Config.Tabs.ToList(); + var height = MathF.Min(tabs.Count * RowHeight, MaxSectionHeight); + using var child = ImRaii.Child( + "##hellion-quick-picker-tabs", + new Vector2(SectionWidth, height) + ); + if (!child) + return; + + var window = _plugin.MainWindow; + var active = window?.ActiveTab; + for (var i = 0; i < tabs.Count; i++) + { + var tab = tabs[i]; + var isActive = ReferenceEquals(tab, active); + DrawGlyph(isActive); + if ( + ImGui.Selectable( + $"{tab.Name}##quick-tab-{i}", + isActive, + ImGuiSelectableFlags.DontClosePopups + ) && !isActive + ) + { + window?.ActivateTab(tab); + } + } + } + + // Leading check glyph for the active row; inactive rows reserve an equal-width + // blank so labels stay aligned. The FontAwesome font is pushed on its own line + // then SameLine() so it doesn't bleed into the body-font label (1.5.4 trick). + private void DrawGlyph(bool isActive) + { + var check = FontAwesomeIcon.Check.ToIconString(); + using (_plugin.FontManager.FontAwesome.Push()) + { + if (isActive) + ImGui.TextUnformatted(check); + else + ImGui.Dummy(new Vector2(ImGui.CalcTextSize(check).X, ImGui.GetTextLineHeight())); + } + ImGui.SameLine(); + } + + private List AllThemes() + { + var all = new List(); + foreach (var slug in ThemePicker.CategoryMapSlugs) + if (_themes.TryGet(slug, out var theme)) + all.Add(theme); + all.AddRange(_themes.AllCustom()); + return all; + } +} diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index 2dd3cbf..90b1032 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -135,6 +135,19 @@ internal sealed class MainWindow : Window TabLifecycleHelpers.OnTabActivated(next, removed); } + // Programmatic tab activation for the header quick-picker. Mirrors the click + // path in TopTabBar/Sidebar exactly (previous → set → OnTabActivated) so a + // header pick strips tell-state and resets unread the way a real click does. + internal void ActivateTab(Tab tab) + { + if (ReferenceEquals(_activeTab, tab)) + return; + + var previous = _activeTab; + _activeTab = tab; + TabLifecycleHelpers.OnTabActivated(tab, previous); + } + // Internal accessors for self-tests so the probes can reach the live // component without exposing them as public surface. internal Components.Sidebar GetSidebarForSelfTest() => _sidebar; From b397591ba4cfb4ce40bce675b1e7f45f226cd01c Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 15 Jun 2026 20:19:59 +0200 Subject: [PATCH 11/12] feat(window): restore the title-bar, hide-button, and 24-hour-clock toggles Re-wires four 1.5.6 settings that survived the v1.6.0 rewrite as dormant config fields but lost their UI + consumers: - ShowTitleBar / ShowPopOutTitleBar: gate ImGuiWindowFlags.NoTitleBar on the main window (ResolveFlags) and pop-out windows (new PreDraw). Inverted logic matches 1.5.6 (flag set only when the toggle is off). - ShowHideButton: gate the input-bar hide button on the toggle. - Use24HourClock: add the toggle (MessageList already consumes the field). New 'Window style' section in WindowTab; Use24HourClock in ChatTab display modes. MainWindowFlagsStep extended with the NoTitleBar fresh-base contract. --- HellionChat/SelfTests/MainWindowFlagsStep.cs | 32 ++++++++++++++++--- HellionChat/Ui/Components/InputBar.cs | 22 ++++++++----- .../Ui/Components/Settings/Tabs/ChatTab.cs | 5 +++ .../Ui/Components/Settings/Tabs/WindowTab.cs | 19 +++++++++++ HellionChat/Ui/Windows/ChannelPopoutWindow.cs | 11 +++++++ HellionChat/Ui/Windows/MainWindow.cs | 22 ++++++++----- 6 files changed, 90 insertions(+), 21 deletions(-) diff --git a/HellionChat/SelfTests/MainWindowFlagsStep.cs b/HellionChat/SelfTests/MainWindowFlagsStep.cs index 7a96ab9..9462c39 100644 --- a/HellionChat/SelfTests/MainWindowFlagsStep.cs +++ b/HellionChat/SelfTests/MainWindowFlagsStep.cs @@ -34,7 +34,11 @@ internal sealed class MainWindowFlagsStep : ISelfTestStep // value for the live config. No state mutation needed. var savedFlags = window.Flags; window.PreDraw(); - var expected = MainWindow.ResolveFlags(Plugin.Config.CanMove, Plugin.Config.CanResize); + var expected = MainWindow.ResolveFlags( + Plugin.Config.CanMove, + Plugin.Config.CanResize, + Plugin.Config.ShowTitleBar + ); if (window.Flags != expected) { ImGui.Text($"PreDraw set Flags {window.Flags}, expected ResolveFlags = {expected}"); @@ -43,7 +47,7 @@ internal sealed class MainWindowFlagsStep : ISelfTestStep } // Fresh-base contract: locked window carries NoMove|NoResize ... - var locked = MainWindow.ResolveFlags(false, false); + var locked = MainWindow.ResolveFlags(false, false, true); if ( !locked.HasFlag(ImGuiWindowFlags.NoMove) || !locked.HasFlag(ImGuiWindowFlags.NoResize) @@ -51,17 +55,35 @@ internal sealed class MainWindowFlagsStep : ISelfTestStep ) { ImGui.Text( - $"ResolveFlags(false,false) = {locked}, missing NoMove/NoResize/NoScrollbar" + $"ResolveFlags(false,false,true) = {locked}, missing NoMove/NoResize/NoScrollbar" ); window.Flags = savedFlags; return SelfTestStepResult.Fail; } // ... and re-enabling both CLEARS NoMove|NoResize (no accumulation). - var free = MainWindow.ResolveFlags(true, true); + var free = MainWindow.ResolveFlags(true, true, true); if (free.HasFlag(ImGuiWindowFlags.NoMove) || free.HasFlag(ImGuiWindowFlags.NoResize)) { - ImGui.Text($"ResolveFlags(true,true) = {free}, NoMove/NoResize stuck after re-enable"); + ImGui.Text( + $"ResolveFlags(true,true,true) = {free}, NoMove/NoResize stuck after re-enable" + ); + window.Flags = savedFlags; + return SelfTestStepResult.Fail; + } + + // P7 title-bar contract: ShowTitleBar=false adds NoTitleBar from the + // fresh base, true clears it (same no-accumulation guarantee). + var barHidden = MainWindow.ResolveFlags(true, true, false); + var barShown = MainWindow.ResolveFlags(true, true, true); + if ( + !barHidden.HasFlag(ImGuiWindowFlags.NoTitleBar) + || barShown.HasFlag(ImGuiWindowFlags.NoTitleBar) + ) + { + ImGui.Text( + $"NoTitleBar wiring wrong: hidden={barHidden} (want NoTitleBar), shown={barShown} (want none)" + ); window.Flags = savedFlags; return SelfTestStepResult.Fail; } diff --git a/HellionChat/Ui/Components/InputBar.cs b/HellionChat/Ui/Components/InputBar.cs index 1552b66..f3c13e4 100644 --- a/HellionChat/Ui/Components/InputBar.cs +++ b/HellionChat/Ui/Components/InputBar.cs @@ -540,15 +540,21 @@ internal sealed class InputBar ImGui.SetTooltip("Settings"); } - ImGui.SameLine(); - var hidden = Plugin.Config.HideChat; - var visIcon = hidden ? FontAwesomeIcon.EyeSlash : FontAwesomeIcon.Eye; - if (ImGui.Button(visIcon.ToIconString())) - Plugin.Config.HideChat = !hidden; - if (ImGui.IsItemHovered()) + // Hide button gated on ShowHideButton (1.5.6 parity). It is the last + // button in the row, so skipping it (with its leading SameLine) leaves + // no dangling SameLine. Shared by main + pop-out InputBars. + if (Plugin.Config.ShowHideButton) { - using (ImRaii.DefaultFont()) - ImGui.SetTooltip(hidden ? "Unhide chat" : "Hide chat"); + ImGui.SameLine(); + var hidden = Plugin.Config.HideChat; + var visIcon = hidden ? FontAwesomeIcon.EyeSlash : FontAwesomeIcon.Eye; + if (ImGui.Button(visIcon.ToIconString())) + Plugin.Config.HideChat = !hidden; + if (ImGui.IsItemHovered()) + { + using (ImRaii.DefaultFont()) + ImGui.SetTooltip(hidden ? "Unhide chat" : "Hide chat"); + } } } } diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs index 96e8e01..647d11a 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs @@ -38,6 +38,11 @@ internal sealed class ChatTab () => Plugin.Config.HideSameTimestamps, v => Plugin.Config.HideSameTimestamps = v ); + DrawToggle( + "24-hour clock", + () => Plugin.Config.Use24HourClock, + v => Plugin.Config.Use24HourClock = v + ); DrawWorldSuffixCombo(); DrawNameFormCombo(); } diff --git a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs index 17638e0..e108486 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs @@ -28,6 +28,25 @@ internal sealed class WindowTab } } + if (ImGui.CollapsingHeader("Window style", ImGuiTreeNodeFlags.DefaultOpen)) + { + DrawToggle( + "Show title bar", + () => Plugin.Config.ShowTitleBar, + v => Plugin.Config.ShowTitleBar = v + ); + DrawToggle( + "Show title bar for pop-outs", + () => Plugin.Config.ShowPopOutTitleBar, + v => Plugin.Config.ShowPopOutTitleBar = v + ); + DrawToggle( + "Show hide button", + () => Plugin.Config.ShowHideButton, + v => Plugin.Config.ShowHideButton = v + ); + } + if (ImGui.CollapsingHeader("Opacity", ImGuiTreeNodeFlags.DefaultOpen)) { DrawSlider( diff --git a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs index 0a37d21..606f82c 100644 --- a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs +++ b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs @@ -75,6 +75,17 @@ internal sealed class ChannelPopoutWindow : Window IsOpen = false; } + public override void PreDraw() + { + // Gate the native title bar on the user toggle (1.5.6 parity). DrawHeader + // carries the close button in-body regardless, so hiding the title bar + // never strands the pop-out. Reset from a fresh base each frame so + // toggling the bar back on clears NoTitleBar. + Flags = Plugin.Config.ShowPopOutTitleBar + ? ImGuiWindowFlags.None + : ImGuiWindowFlags.NoTitleBar; + } + public override void Draw() { if (Bound is null) diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index 90b1032..310c8e0 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -77,19 +77,21 @@ internal sealed class MainWindow : Window internal float ResolveBgAlpha(bool isFocused) => isFocused ? Plugin.Config.WindowOpacity : Plugin.Config.WindowOpacityInactive; - // B1-2: rebuild flags from a fresh base every frame so toggling CanMove/ - // CanResize back on actually CLEARS NoMove/NoResize (not accumulating). - // Move/resize toggle logic as 1.5.6 (ChatLogWindow.PreOpenCheck - // 1d3b429:703-707); base flags = today's MainWindow set (NoScrollbar| - // NoScrollWithMouse — the message list owns its own scroll; 1.5.6's - // NoFocusOnAppearing/NoTitleBar are deliberately not restored). - internal static ImGuiWindowFlags ResolveFlags(bool canMove, bool canResize) + // B1-2 / P7: rebuild flags from a fresh base every frame so toggling + // CanMove/CanResize/ShowTitleBar back on actually CLEARS NoMove/NoResize/ + // NoTitleBar (not accumulating). Move/resize/title-bar logic as 1.5.6 + // (ChatLogWindow.PreOpenCheck 1d3b429:703-710); base flags = today's + // MainWindow set (NoScrollbar|NoScrollWithMouse — the message list owns its + // own scroll; 1.5.6's NoFocusOnAppearing is deliberately not restored). + internal static ImGuiWindowFlags ResolveFlags(bool canMove, bool canResize, bool showTitleBar) { var flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse; if (!canMove) flags |= ImGuiWindowFlags.NoMove; if (!canResize) flags |= ImGuiWindowFlags.NoResize; + if (!showTitleBar) + flags |= ImGuiWindowFlags.NoTitleBar; return flags; } @@ -114,7 +116,11 @@ internal sealed class MainWindow : Window BgAlpha = null; } - Flags = ResolveFlags(Plugin.Config.CanMove, Plugin.Config.CanResize); + Flags = ResolveFlags( + Plugin.Config.CanMove, + Plugin.Config.CanResize, + Plugin.Config.ShowTitleBar + ); } public Tab? ActiveTab => _activeTab; From a73f4d0d0ca64b3753031ba22d06743a345e6aa2 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Mon, 15 Jun 2026 20:49:14 +0200 Subject: [PATCH 12/12] feat(window): restore hide-chat-window + Enter-to-restore (1.5.6) The eye/hide button now hides the HellionChat window (runtime-only, via a new DrawConditions gate) instead of toggling native-chat suppression, matching 1.5.6. The chat-activation keybind (Enter / "/"), whose dispatch was a dead stub in the KeybindManager since the v1.6.0 rewrite, is re-wired to MainWindow.ActivateChat: it un-hides, opens if closed, brings the window to front and focuses the input -- so the chat reacts to Enter again from any state. /hellion is a reliable one-press recovery (Toggle now clears the hide), and the window always shows on login (start state no longer read from the persisted flag). Adds HideRestoreSelfTestStep. --- HellionChat/Configuration.cs | 3 + HellionChat/GameFunctions/KeybindManager.cs | 9 +-- HellionChat/Plugin.cs | 1 + HellionChat/PluginHostFactory.cs | 3 +- .../SelfTests/HideRestoreSelfTestStep.cs | 69 +++++++++++++++++++ HellionChat/Ui/Components/InputBar.cs | 22 +++--- HellionChat/Ui/Windows/MainWindow.cs | 36 ++++++++-- 7 files changed, 124 insertions(+), 19 deletions(-) create mode 100644 HellionChat/SelfTests/HideRestoreSelfTestStep.cs diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index 80eae80..7172db8 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -261,6 +261,9 @@ public class Configuration : IPluginConfiguration // v20 fields: window visibility state, channel popout pool size and // sidebar auto-switch threshold. All initializers double as the // migration defaults for configs loaded at v19 or earlier. + // Still written on open/close, but no longer read for the start state: the + // window always shows on login (1.5.6 parity, MainWindow ctor). Kept for the + // migration round-trip and a possible future "remember session state" opt-in. public bool MainWindowOpen = true; public bool SettingsWindowOpen; public int MaxParallelPopouts = 8; diff --git a/HellionChat/GameFunctions/KeybindManager.cs b/HellionChat/GameFunctions/KeybindManager.cs index 3861623..ec7c7ea 100644 --- a/HellionChat/GameFunctions/KeybindManager.cs +++ b/HellionChat/GameFunctions/KeybindManager.cs @@ -501,12 +501,13 @@ internal unsafe class KeybindManager : IDisposable return; Plugin.KeyState[currentBest.Item1] = false; - if (!KeybindsToIntercept.TryGetValue(currentBest.Item2, out var info)) + if (!KeybindsToIntercept.ContainsKey(currentBest.Item2)) return; - // Chat-window Activated integration is offline until the new chat - // layer surfaces an Activated entry point. - _ = info; + // 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. Channel/prefill routing from the bind stays out of scope. + Plugin.Instance.MainWindow?.ActivateChat(); } // Tab-cycle dispatch is offline until the new chat layer surfaces a diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index c38232e..7f4e746 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -383,6 +383,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.ColorEditorBufferStep(this), new SelfTests.ThemePickerCategoryStep(this), new SelfTests.QuickPickerSelfTestStep(this), + new SelfTests.HideRestoreSelfTestStep(this), new SelfTests.SettingsWindowOpenStep(this), new SelfTests.OnOpenMainUiRoutesMainWindowStep(this), new SelfTests.TypingIpcStateStep(this), diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 3c6d0d4..1fb745f 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -156,7 +156,8 @@ internal static class PluginHostFactory sp.GetRequiredService>(), () => sp.GetRequiredService().SettingsWindow.Toggle(), sp.GetRequiredService(), - sp.GetRequiredService() + sp.GetRequiredService(), + () => sp.GetRequiredService().MainWindow.UserHide() )); services.AddSingleton(sp => new Ui.Components.Settings.TabSidebar( sp.GetRequiredService() diff --git a/HellionChat/SelfTests/HideRestoreSelfTestStep.cs b/HellionChat/SelfTests/HideRestoreSelfTestStep.cs new file mode 100644 index 0000000..cb5ebf9 --- /dev/null +++ b/HellionChat/SelfTests/HideRestoreSelfTestStep.cs @@ -0,0 +1,69 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Ui.Windows; + +namespace HellionChat.SelfTests; + +// P8 wiring: UserHide() suppresses DrawConditions; both ActivateChat() (Enter) and +// Toggle() (/hellion) restore it. Pure window-state — the focus side is left to smoke. +internal sealed class HideRestoreSelfTestStep : ISelfTestStep +{ + private readonly Plugin _plugin; + + public HideRestoreSelfTestStep(Plugin plugin) + { + _plugin = plugin; + } + + public string Name => "Hellion Chat - Hide + activate restore"; + + public SelfTestStepResult RunStep() + { + var window = _plugin.MainWindow; + if (window is null) + { + ImGui.Text("Plugin.MainWindow is null"); + return SelfTestStepResult.Fail; + } + + var savedOpen = window.IsOpen; + var result = Evaluate(window); + + // Never leave the window stuck hidden, even if an assertion failed. + window.ActivateChat(); + window.IsOpen = savedOpen; + return result; + } + + private static SelfTestStepResult Evaluate(MainWindow window) + { + window.UserHide(); + if (window.DrawConditions()) + { + ImGui.Text("UserHide did not suppress DrawConditions"); + return SelfTestStepResult.Fail; + } + + window.ActivateChat(); + if (!window.DrawConditions() || !window.IsOpen) + { + ImGui.Text( + $"ActivateChat failed: DrawConditions={window.DrawConditions()}, IsOpen={window.IsOpen}" + ); + return SelfTestStepResult.Fail; + } + + // /hellion (Toggle) must also clear a user-hide, not just flip IsOpen. + window.UserHide(); + window.Toggle(); + if (!window.DrawConditions()) + { + ImGui.Text("Toggle did not restore the window from a user-hide"); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/Ui/Components/InputBar.cs b/HellionChat/Ui/Components/InputBar.cs index f3c13e4..55bb2d0 100644 --- a/HellionChat/Ui/Components/InputBar.cs +++ b/HellionChat/Ui/Components/InputBar.cs @@ -45,6 +45,9 @@ internal sealed class InputBar // pop-out would be confusing). The main window's InputBar gets the instance. private readonly ThemeQuickPicker? _themeQuickPicker; + // Null in pop-outs (those have their own close button). Hides the main window. + private readonly Action? _onHideWindow; + private string _pendingMessage = string.Empty; private bool _isFocused; private bool _wasInputTextHovered; @@ -81,7 +84,8 @@ internal sealed class InputBar ILogger logger, Action onOpenSettings, CommandHelpWindow commandHelpWindow, - ThemeQuickPicker? themeQuickPicker = null + ThemeQuickPicker? themeQuickPicker = null, + Action? onHideWindow = null ) { _symbolPicker = symbolPicker; @@ -92,6 +96,7 @@ internal sealed class InputBar _onOpenSettings = onOpenSettings; _commandHelpWindow = commandHelpWindow; _themeQuickPicker = themeQuickPicker; + _onHideWindow = onHideWindow; } public string PendingMessage => _pendingMessage; @@ -540,20 +545,17 @@ internal sealed class InputBar ImGui.SetTooltip("Settings"); } - // Hide button gated on ShowHideButton (1.5.6 parity). It is the last - // button in the row, so skipping it (with its leading SameLine) leaves - // no dangling SameLine. Shared by main + pop-out InputBars. - if (Plugin.Config.ShowHideButton) + // 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. + if (Plugin.Config.ShowHideButton && _onHideWindow is not null) { ImGui.SameLine(); - var hidden = Plugin.Config.HideChat; - var visIcon = hidden ? FontAwesomeIcon.EyeSlash : FontAwesomeIcon.Eye; - if (ImGui.Button(visIcon.ToIconString())) - Plugin.Config.HideChat = !hidden; + if (ImGui.Button(FontAwesomeIcon.EyeSlash.ToIconString())) + _onHideWindow(); if (ImGui.IsItemHovered()) { using (ImRaii.DefaultFont()) - ImGui.SetTooltip(hidden ? "Unhide chat" : "Hide chat"); + ImGui.SetTooltip("Hide chat (Enter to bring back)"); } } } diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index 310c8e0..646a9e9 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -33,6 +33,10 @@ internal sealed class MainWindow : Window private Tab? _activeTab; + // Runtime-only hide: window stays IsOpen but DrawConditions skips it, so the + // chat-activation key can restore it (1.5.6 HideState.User parity). + private bool _userHidden; + public Vector2 LastWindowPos { get; private set; } = Vector2.Zero; public Vector2 LastWindowSize { get; private set; } = Vector2.Zero; internal unsafe ImGuiViewport* LastViewport; @@ -66,7 +70,9 @@ internal sealed class MainWindow : Window MinimumSize = new Vector2(MinWidth, MinHeight), MaximumSize = new Vector2(float.MaxValue, float.MaxValue), }; - IsOpen = Plugin.Config.MainWindowOpen; + // 1.5.6 parity: the chat always shows on login. The window stays closeable + // and hideable within a session, but that state is not carried across starts. + IsOpen = true; RespectCloseHotkey = false; } @@ -162,11 +168,33 @@ internal sealed class MainWindow : Window internal Components.MessageList GetMessageListForSelfTest() => _messages; - // new-shadow on Window.Toggle so the open path also writes Config — - // OnClose already covers the close path through the base behaviour. + public override bool DrawConditions() => !_userHidden; + + internal void UserHide() => _userHidden = true; + + // Chat-activation keybind (Enter) entry point. Field writes only, so it is safe + // from the framework thread; the draw path applies focus next frame. + internal void ActivateChat() + { + _userHidden = false; + if (!IsOpen) + { + IsOpen = true; + Plugin.Config.MainWindowOpen = true; + } + 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). public new void Toggle() { - IsOpen = !IsOpen; + var visible = IsOpen && !_userHidden; + IsOpen = !visible; + if (IsOpen) + _userHidden = false; Plugin.Config.MainWindowOpen = IsOpen; }