From c6f7266194b96509204d280e3404b822725a101d Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 26 May 2026 16:36:54 +0200 Subject: [PATCH] feat(settings): add ThemePicker with five categories and switch lock --- HellionChat/PluginHostFactory.cs | 4 + .../Ui/Components/Settings/ThemePicker.cs | 125 ++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 HellionChat/Ui/Components/Settings/ThemePicker.cs diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 16fc6a3..6b2e993 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -147,6 +147,10 @@ internal static class PluginHostFactory sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.Settings.ContentArea()); + services.AddSingleton(sp => new Ui.Components.Settings.ThemePicker( + sp.GetRequiredService(), + sp.GetRequiredService() + )); services.AddSingleton(sp => new Ui.Components.StatusBar( sp.GetRequiredService(), sp.GetRequiredService() diff --git a/HellionChat/Ui/Components/Settings/ThemePicker.cs b/HellionChat/Ui/Components/Settings/ThemePicker.cs new file mode 100644 index 0000000..613cdbc --- /dev/null +++ b/HellionChat/Ui/Components/Settings/ThemePicker.cs @@ -0,0 +1,125 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Utility.Raii; +using HellionChat.Themes; +using HellionChat.Util; + +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 + ), + ( + "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), + }; + + // T2 ThemePickerCategoryStep diffs this against ThemeRegistry.BuiltinSlugs + // 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 readonly ThemeRegistry _themes; + private readonly Plugin _plugin; + + public ThemePicker(ThemeRegistry themes, Plugin plugin) + { + _themes = themes; + _plugin = plugin; + } + + public void Draw() + { + var locked = _themes.EditingThemeBuffer is not null; + + using (ImRaii.Disabled(locked)) + { + foreach (var (category, slugs, defaultExpanded) in CategoryMap) + { + var flags = defaultExpanded + ? ImGuiTreeNodeFlags.DefaultOpen + : ImGuiTreeNodeFlags.None; + if (ImGui.CollapsingHeader(category, flags)) + { + foreach (var slug in slugs) + { + DrawCard(slug); + } + } + } + } + + if (locked && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + { + ImGui.SetTooltip("Save or discard your edits first"); + } + } + + private void DrawCard(string slug) + { + if (!_themes.TryGet(slug, out var theme)) + { + return; + } + + var active = _themes.Active.Slug == slug; + var label = $"{theme.Name} — {theme.Author}##theme-card-{slug}"; + + // 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))) + { + _themes.Switch(slug); + Plugin.Config.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); + } +}