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.
This commit is contained in:
2026-05-23 15:59:36 +02:00
parent 1d3b429f1b
commit d89540de52
2 changed files with 215 additions and 0 deletions
+2
View File
@@ -92,6 +92,8 @@ internal static class PluginHostFactory
sp.GetRequiredService<ILogger<ThemeRegistry>>()
));
services.AddSingleton(_ => new Ui.StyleEngine.TokenResolver());
services.AddSingleton(sp => new GameFunctions.GameFunctions(
sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<ILogger<GameFunctions.GameFunctions>>(),
+213
View File
@@ -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<Token, Func<ThemeColors, uint>> 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<Token, ImGuiCol?> 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;
}
}