From d89540de52827e92ae8a29abe1c76d07e205c32b Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sat, 23 May 2026 15:59:36 +0200 Subject: [PATCH] feat(style-engine): add TokenResolver and TokenMap Semantic token layer between code and ThemeColors slots. 41 tokens in three categories: 24 ImGui-slot tokens with TokenMap mapping to ImGuiCol, 11 custom-drawing tokens that throw on ToImGuiCol, 6 derived surface/text tokens that lerp from base slots so user picks propagate without inflating the persisted slot count. Resolver values are RGBA; callers convert at the ImGui boundary. --- HellionChat/PluginHostFactory.cs | 2 + HellionChat/Ui/StyleEngine/TokenResolver.cs | 213 ++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 HellionChat/Ui/StyleEngine/TokenResolver.cs diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 62ed716..a3502b1 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -92,6 +92,8 @@ internal static class PluginHostFactory sp.GetRequiredService>() )); + services.AddSingleton(_ => new Ui.StyleEngine.TokenResolver()); + services.AddSingleton(sp => new GameFunctions.GameFunctions( sp.GetRequiredService(), sp.GetRequiredService>(), diff --git a/HellionChat/Ui/StyleEngine/TokenResolver.cs b/HellionChat/Ui/StyleEngine/TokenResolver.cs new file mode 100644 index 0000000..0491e56 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/TokenResolver.cs @@ -0,0 +1,213 @@ +using Dalamud.Bindings.ImGui; +using HellionChat.Themes; + +namespace HellionChat.Ui.StyleEngine; + +// Semantic indirection between code and ThemeColors. ImGui-slot tokens map +// to ImGuiCol via TokenMap; custom-drawing tokens must be resolved directly +// and fed to DrawList; derived tokens lerp from base slots so user color +// picks propagate without persisting every variant. +public enum Token +{ + // ImGui-Slot tokens (24). + WindowBg, + ChildBg, + PopupBg, + FrameBg, + FrameBgHovered, + FrameBgActive, + Border, + BorderShadow, + Button, + ButtonHovered, + ButtonActive, + Header, + HeaderHovered, + HeaderActive, + Tab, + TabHovered, + TabActive, + Text, + TextDisabled, + CheckMark, + ScrollbarBg, + ScrollbarGrab, + ScrollbarGrabHovered, + ResizeGrip, + + // Custom-drawing tokens (11). + AccentPrimary, + AccentEmber, + SheenWhite, + GlowOuter, + HonorificCrown, + SlipFill, + SlipBorder, + StatusSuccess, + StatusDanger, + StatusWarning, + StatusInfo, + + // Derived surface/text tokens (6). + SurfaceBase, + SurfaceRaised, + SurfaceHover, + SurfaceActive, + TextMuted, + TextFaint, +} + +// Stateless lookup. Resolver values are RGBA (0xRRGGBBAA) — callers convert +// to ABGR at the ImGui boundary. +internal sealed class TokenResolver +{ + private const uint White = 0xFFFFFFFFu; + private const uint Black = 0x000000FFu; + private const uint Transparent = 0x00000000u; + + private static readonly Dictionary> Resolvers = new() + { + [Token.WindowBg] = c => c.WindowBg, + [Token.ChildBg] = c => c.ChildBg, + [Token.PopupBg] = c => Lerp(c.WindowBg, Black, 0.1f), + [Token.FrameBg] = c => c.FrameBg, + [Token.FrameBgHovered] = c => Lerp(c.FrameBg, c.Primary, 0.15f), + [Token.FrameBgActive] = c => Lerp(c.FrameBg, c.Primary, 0.3f), + [Token.Border] = c => c.Border, + [Token.BorderShadow] = c => Lerp(c.Border, Transparent, 0.5f), + [Token.Button] = c => Lerp(c.Primary, Transparent, 0.4f), + [Token.ButtonHovered] = c => c.PrimaryLight, + [Token.ButtonActive] = c => c.Primary, + [Token.Header] = c => Lerp(c.Primary, Transparent, 0.5f), + [Token.HeaderHovered] = c => c.PrimaryLight, + [Token.HeaderActive] = c => c.Primary, + [Token.Tab] = c => Lerp(c.Primary, Transparent, 0.7f), + [Token.TabHovered] = c => c.PrimaryLight, + [Token.TabActive] = c => c.Primary, + [Token.Text] = c => c.TextPrimary, + [Token.TextDisabled] = c => c.TextDim, + [Token.CheckMark] = c => c.Primary, + [Token.ScrollbarBg] = c => Lerp(c.WindowBg, Black, 0.2f), + [Token.ScrollbarGrab] = c => Lerp(c.Border, c.Primary, 0.3f), + [Token.ScrollbarGrabHovered] = c => Lerp(c.Border, c.Primary, 0.6f), + [Token.ResizeGrip] = c => Lerp(c.Border, c.Primary, 0.4f), + [Token.AccentPrimary] = c => c.Primary, + [Token.AccentEmber] = c => c.Accent, + [Token.SheenWhite] = _ => 0xFFFFFF20u, + [Token.GlowOuter] = c => Lerp(c.Primary, Transparent, 0.7f), + [Token.HonorificCrown] = c => c.Identity, + [Token.SlipFill] = c => Lerp(c.Surface, c.Primary, 0.05f), + [Token.SlipBorder] = c => c.Border, + [Token.StatusSuccess] = c => c.StatusSuccess, + [Token.StatusDanger] = c => c.StatusDanger, + [Token.StatusWarning] = c => c.StatusWarning, + [Token.StatusInfo] = c => c.StatusInfo, + + // Surface/text slots that already exist in ThemeColors read directly + // so user picks propagate; the rest lerp from base to keep slot count + // small. + [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), + [Token.TextMuted] = c => c.TextMuted, + [Token.TextFaint] = c => c.TextDim, + }; + + public uint Resolve(Token token, ThemeColors theme) + { + if (!Resolvers.TryGetValue(token, out var fn)) + throw new InvalidOperationException( + $"Token {token} has no resolver entry. Add it to TokenResolver.Resolvers." + ); + return fn(theme); + } + + // Math.Round (ToEven) matches ThemeAbgrCacheLerp so derived tokens align + // with the crossfade path at midpoints. t is clamped before the math. + private static uint Lerp(uint from, uint to, float t) + { + t = Math.Clamp(t, 0f, 1f); + + var rf = (byte)((from >> 24) & 0xFFu); + var gf = (byte)((from >> 16) & 0xFFu); + var bf = (byte)((from >> 8) & 0xFFu); + var af = (byte)(from & 0xFFu); + + var rt = (byte)((to >> 24) & 0xFFu); + var gt = (byte)((to >> 16) & 0xFFu); + var bt = (byte)((to >> 8) & 0xFFu); + var at = (byte)(to & 0xFFu); + + var r = (byte)Math.Round(rf + (rt - rf) * t); + var g = (byte)Math.Round(gf + (gt - gf) * t); + var b = (byte)Math.Round(bf + (bt - bf) * t); + var a = (byte)Math.Round(af + (at - af) * t); + + return ((uint)r << 24) | ((uint)g << 16) | ((uint)b << 8) | a; + } +} + +// Every Token must be present so ToImGuiCol can distinguish "custom-drawing +// token" from "Token enum value added without a map entry". +internal static class TokenMap +{ + private static readonly Dictionary ImGuiSlot = new() + { + [Token.WindowBg] = ImGuiCol.WindowBg, + [Token.ChildBg] = ImGuiCol.ChildBg, + [Token.PopupBg] = ImGuiCol.PopupBg, + [Token.FrameBg] = ImGuiCol.FrameBg, + [Token.FrameBgHovered] = ImGuiCol.FrameBgHovered, + [Token.FrameBgActive] = ImGuiCol.FrameBgActive, + [Token.Border] = ImGuiCol.Border, + [Token.BorderShadow] = ImGuiCol.BorderShadow, + [Token.Button] = ImGuiCol.Button, + [Token.ButtonHovered] = ImGuiCol.ButtonHovered, + [Token.ButtonActive] = ImGuiCol.ButtonActive, + [Token.Header] = ImGuiCol.Header, + [Token.HeaderHovered] = ImGuiCol.HeaderHovered, + [Token.HeaderActive] = ImGuiCol.HeaderActive, + [Token.Tab] = ImGuiCol.Tab, + [Token.TabHovered] = ImGuiCol.TabHovered, + [Token.TabActive] = ImGuiCol.TabActive, + [Token.Text] = ImGuiCol.Text, + [Token.TextDisabled] = ImGuiCol.TextDisabled, + [Token.CheckMark] = ImGuiCol.CheckMark, + [Token.ScrollbarBg] = ImGuiCol.ScrollbarBg, + [Token.ScrollbarGrab] = ImGuiCol.ScrollbarGrab, + [Token.ScrollbarGrabHovered] = ImGuiCol.ScrollbarGrabHovered, + [Token.ResizeGrip] = ImGuiCol.ResizeGrip, + [Token.AccentPrimary] = null, + [Token.AccentEmber] = null, + [Token.SheenWhite] = null, + [Token.GlowOuter] = null, + [Token.HonorificCrown] = null, + [Token.SlipFill] = null, + [Token.SlipBorder] = null, + [Token.StatusSuccess] = null, + [Token.StatusDanger] = null, + [Token.StatusWarning] = null, + [Token.StatusInfo] = null, + [Token.SurfaceBase] = null, + [Token.SurfaceRaised] = null, + [Token.SurfaceHover] = null, + [Token.SurfaceActive] = null, + [Token.TextMuted] = null, + [Token.TextFaint] = null, + }; + + public static ImGuiCol ToImGuiCol(Token token) + { + if (!ImGuiSlot.TryGetValue(token, out var col)) + throw new InvalidOperationException( + $"Token {token} is missing from TokenMap. Add it as ImGuiCol or null." + ); + if (!col.HasValue) + throw new InvalidOperationException( + $"Token {token} is a custom-drawing token without an ImGuiCol mapping. " + + "Use TokenResolver.Resolve(token, theme) and feed the uint to DrawList." + ); + return col.Value; + } +}