From b8d1090c1ddb4867b0b852aecee3c02fd78f9300 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 07:30:06 +0000 Subject: [PATCH] Map every ImGuiCol slot in ImGuiPaletteMapper Thirteen ImGuiCol slots had no mapping, so applying a theme left them at the previous style's values. Light themes kept StyleColorsDark's white text cursor and navy dimmed tabs. The slots are now mapped from the semantic palette, and the two dimming overlays get translucent alpha. A new test asserts that every ImGuiCol below Count is assigned for every theme, which comparing themes against each other could not catch. Fixes ktsu-dev/ThemeProvider#110 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015ErXBgrjdbMZYoULNa1H7n --- ThemeProvider.ImGui/ImGuiPaletteMapper.cs | 32 +++++++++- ThemeProvider.Test/ImGuiPaletteMapperTests.cs | 60 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/ThemeProvider.ImGui/ImGuiPaletteMapper.cs b/ThemeProvider.ImGui/ImGuiPaletteMapper.cs index 96b0e76..6d6159b 100644 --- a/ThemeProvider.ImGui/ImGuiPaletteMapper.cs +++ b/ThemeProvider.ImGui/ImGuiPaletteMapper.cs @@ -42,6 +42,9 @@ public IReadOnlyDictionary MapTheme(ISemanticTheme theme) { ImGuiCol.FrameBgActive, new(SemanticMeaning.Neutral, Priority.Medium)}, { ImGuiCol.TextDisabled, new(SemanticMeaning.Neutral, Priority.High)}, { ImGuiCol.Text, new(SemanticMeaning.Neutral, Priority.VeryHigh)}, + // The caret is drawn over input frames like text is, so it takes the text color. + { ImGuiCol.InputTextCursor, new(SemanticMeaning.Neutral, Priority.VeryHigh)}, + { ImGuiCol.TextLink, new(SemanticMeaning.Primary, Priority.High)}, { ImGuiCol.ScrollbarBg, new(SemanticMeaning.Neutral, Priority.Low)}, { ImGuiCol.ScrollbarGrab, new(SemanticMeaning.Neutral, Priority.Medium)}, @@ -66,6 +69,14 @@ public IReadOnlyDictionary MapTheme(ISemanticTheme theme) { ImGuiCol.ResizeGripActive, new(SemanticMeaning.Neutral, Priority.VeryHigh)}, { ImGuiCol.NavWindowingHighlight, new(SemanticMeaning.Primary, Priority.VeryHigh)}, + { ImGuiCol.NavCursor, new(SemanticMeaning.Primary, Priority.High)}, + { ImGuiCol.DragDropTarget, new(SemanticMeaning.Primary, Priority.High)}, + { ImGuiCol.DockingPreview, new(SemanticMeaning.Primary, Priority.High)}, + { ImGuiCol.DockingEmptyBg, new(SemanticMeaning.Neutral, Priority.VeryLow)}, + + // The dimming overlays cover the whole viewport; their alpha is set below. + { ImGuiCol.NavWindowingDimBg, new(SemanticMeaning.Neutral, Priority.Medium)}, + { ImGuiCol.ModalWindowDimBg, new(SemanticMeaning.Neutral, Priority.Medium)}, { ImGuiCol.SliderGrab, new(SemanticMeaning.Primary, Priority.MediumLow)}, { ImGuiCol.SliderGrabActive, new(SemanticMeaning.Primary, Priority.High)}, @@ -73,10 +84,16 @@ public IReadOnlyDictionary MapTheme(ISemanticTheme theme) { ImGuiCol.Separator, new(SemanticMeaning.Neutral, Priority.MediumHigh)}, { ImGuiCol.SeparatorHovered, new(SemanticMeaning.Neutral, Priority.High)}, { ImGuiCol.SeparatorActive, new(SemanticMeaning.Neutral, Priority.VeryHigh)}, + { ImGuiCol.TreeLines, new(SemanticMeaning.Neutral, Priority.MediumHigh)}, { ImGuiCol.Tab, new(SemanticMeaning.Neutral, Priority.Low)}, { ImGuiCol.TabSelected, new(SemanticMeaning.Primary, Priority.VeryLow)}, { ImGuiCol.TabHovered, new(SemanticMeaning.Primary, Priority.Low)}, + { ImGuiCol.TabSelectedOverline, new(SemanticMeaning.Primary, Priority.High)}, + // Tabs in an unfocused window drop to neutral so the focused window's tabs stand out. + { ImGuiCol.TabDimmed, new(SemanticMeaning.Neutral, Priority.VeryLow)}, + { ImGuiCol.TabDimmedSelected, new(SemanticMeaning.Neutral, Priority.Low)}, + { ImGuiCol.TabDimmedSelectedOverline, new(SemanticMeaning.Primary, Priority.High)}, // Alternate elements spread across the full 50-90% range for better contrast { ImGuiCol.PlotLines, new(SemanticMeaning.Alternate, Priority.Medium)}, @@ -101,6 +118,13 @@ public IReadOnlyDictionary MapTheme(ISemanticTheme theme) { ImGuiCol.BorderShadow, new(SemanticMeaning.Neutral, Priority.Low) }, }; + // Overlays drawn over other content, with the opacity ImGui's stock styles give them. + Dictionary alphaOverrides = new() + { + { ImGuiCol.NavWindowingDimBg, 0.20f }, + { ImGuiCol.ModalWindowDimBg, 0.35f }, + }; + // Convert the semantic colors to ImGui Vector4 format Dictionary result = []; foreach (KeyValuePair kv in colorMapping) @@ -109,7 +133,13 @@ public IReadOnlyDictionary MapTheme(ISemanticTheme theme) SemanticColorRequest request = kv.Value; if (completePalette.TryGetValue(request, out Color color)) { - result[imguiCol] = color.ToSrgbVector4(); + Vector4 value = color.ToSrgbVector4(); + if (alphaOverrides.TryGetValue(imguiCol, out float alpha)) + { + value.W = alpha; + } + + result[imguiCol] = value; } } diff --git a/ThemeProvider.Test/ImGuiPaletteMapperTests.cs b/ThemeProvider.Test/ImGuiPaletteMapperTests.cs index f543a9d..99e32cf 100644 --- a/ThemeProvider.Test/ImGuiPaletteMapperTests.cs +++ b/ThemeProvider.Test/ImGuiPaletteMapperTests.cs @@ -75,6 +75,66 @@ public void MapTheme_ProducesSamePaletteSizeForEveryTheme() } } + /// + /// Every slot must be assigned. An unassigned slot keeps whatever style was + /// active before the theme was applied, usually StyleColorsDark(), so a light theme would be + /// left with a white text cursor and navy dimmed tabs. Comparing themes against each other cannot + /// catch a slot that every theme is missing. + /// + [TestMethod] + public void MapTheme_AssignsEveryImGuiColor() + { + ImGuiPaletteMapper mapper = new(); + + foreach (ThemeRegistry.ThemeInfo info in ThemeRegistry.AllThemes) + { + IReadOnlyDictionary palette = mapper.MapTheme(info.CreateInstance()); + + for (int i = 0; i < (int)ImGuiCol.Count; i++) + { + ImGuiCol key = (ImGuiCol)i; + Assert.IsTrue(palette.ContainsKey(key), $"{info.Name}: missing ImGui color {key}"); + } + } + } + + /// + /// The text cursor is drawn over input frames, so it takes the text color rather than a fixed one. + /// + [TestMethod] + public void MapTheme_InputTextCursorMatchesText() + { + ImGuiPaletteMapper mapper = new(); + + foreach (ThemeRegistry.ThemeInfo info in ThemeRegistry.AllThemes) + { + IReadOnlyDictionary palette = mapper.MapTheme(info.CreateInstance()); + Assert.IsTrue(palette.TryGetValue(ImGuiCol.InputTextCursor, out Vector4 cursor), $"{info.Name}: InputTextCursor unmapped"); + Assert.AreEqual(palette[ImGuiCol.Text], cursor, info.Name); + } + } + + /// + /// The dimming overlays cover the whole viewport, so they must be translucent or they would hide + /// everything behind a modal or the window switcher. + /// + [TestMethod] + public void MapTheme_DimBackgroundsAreTranslucent() + { + ImGuiPaletteMapper mapper = new(); + + foreach (ThemeRegistry.ThemeInfo info in ThemeRegistry.AllThemes) + { + IReadOnlyDictionary palette = mapper.MapTheme(info.CreateInstance()); + + foreach (ImGuiCol key in new[] { ImGuiCol.ModalWindowDimBg, ImGuiCol.NavWindowingDimBg }) + { + Assert.IsTrue(palette.TryGetValue(key, out Vector4 dim), $"{info.Name}: {key} unmapped"); + Assert.IsTrue(dim.W is > 0f and < 1f, $"{info.Name}: {key} alpha is {dim.W}"); + } + } + } + /// /// ImGui expects color components in [0, 1]; anything outside that renders as a clipped or /// wrapped color.