Files
HellionChat/HellionChat/Ui/Windows/InputBarLabWindow.cs
T
JonKazama-Hellion 3e46600fc3 fix(style): the popup text fed RGBA into the contrast helper -- and a guard so this stops recurring
Fifth and sixth occurrences of the same defect in one day, this time in PopupRow
and three spots in the lab. The menu entries and the channel picker were
unreadable at rest on the green theme and only became legible on hover, because
the hover lerp pulls toward a correctly converted accent -- which is exactly the
symptom the tester reported.

The shape of the mistake is always identical: a raw ThemeColors member (RGBA)
handed to EnsureContrast (ABGR). The compiler cannot see it, both layouts are
uint, and a stray extra RgbaToAbgr around the call makes the result look
plausible while measuring a contrast between two colours that are never on
screen.

So this commit is mostly the guard. preflight Block G runs
scripts/verify-colour-channels.sh, which flags any raw theme member in either
argument of EnsureContrast across the UI tree. Falsified before trusting: broken
deliberately, it goes red; and on its very first real run it caught three
offenders in the lab that a hand-rolled grep had missed minutes earlier.
2026-08-19 19:09:22 +02:00

421 lines
14 KiB
C#

using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing;
using HellionChat.Themes;
using HellionChat.Ui.StyleEngine;
using HellionChat.Ui.StyleEngine.Widgets;
using HellionChat.Util;
namespace HellionChat.Ui.Windows;
// Variants of UI elements, drawn side by side so decisions are made by looking
// rather than by imagining. Reachable with /hellion lab.
//
// Permanent, by Flo's call: a dev playground for seeing ideas in-game against
// the live theme. The radios default to whatever shipped, so the window also
// documents which variant won and what it beat.
//
// It exists because the alternative was drawing mockups, and mockups are what
// sent this cycle down the wrong path once already: they are from before the
// settings window learned that structure is typography and only controls get a
// fill. Everything here is real ImGui against the live theme, so switching
// themes switches the answer too -- which is the part a picture cannot show.
//
// The tools were all sitting in DrawListExtensions unused: DrawSlipPolygon and
// DrawVerticalGradient had one caller between them (SegmentedControl),
// DrawGlowBorder had none at all.
internal sealed class InputBarLabWindow : Window
{
private readonly Plugin _plugin;
private string _sample = string.Empty;
private int _buttonVariant = 2;
private int _fieldVariant = 2;
private int _pillVariant = 2;
private int _headerVariant = 2;
internal InputBarLabWindow(Plugin plugin)
: base("Input Bar Lab###hellion-input-lab")
{
_plugin = plugin;
Size = new Vector2(560, 720);
SizeCondition = ImGuiCond.FirstUseEver;
}
public override void Draw()
{
var fonts = _plugin.FontManager;
if (fonts is null || !fonts.FontsReady)
{
ImGui.TextDisabled("fonts not ready");
return;
}
var c = _plugin.ThemeRegistry.Active.Colors;
ImGui.TextDisabled(
$"theme {_plugin.ThemeRegistry.Active.Slug} · scale {Metrics.Scale:0.00}"
);
ImGui.Separator();
DrawButtonSection(c, fonts);
DrawFieldSection(c);
DrawPillSection(c, fonts);
DrawHeaderSection(c, fonts);
}
// ---- icon buttons -------------------------------------------------------
private void DrawButtonSection(ThemeColors c, FontManager fonts)
{
Heading("ICON BUTTONS", c);
ImGui.RadioButton("plain hover##b", ref _buttonVariant, 0);
ImGui.SameLine();
ImGui.RadioButton("chamfer + gradient##b", ref _buttonVariant, 1);
ImGui.SameLine();
ImGui.RadioButton("glow on hover##b", ref _buttonVariant, 2);
ImGui.Spacing();
var icons = new[]
{
FontAwesomeIcon.SmileBeam,
FontAwesomeIcon.Palette,
FontAwesomeIcon.Cog,
FontAwesomeIcon.Camera,
FontAwesomeIcon.EyeSlash,
};
var scale = Metrics.Scale;
var side = MathF.Round(26f * scale);
var gap = MathF.Round(4f * scale);
var origin = ImGui.GetCursorScreenPos();
var dl = ImGui.GetWindowDrawList();
for (var i = 0; i < icons.Length; i++)
{
var min = new Vector2(origin.X + i * (side + gap), origin.Y);
var max = min + new Vector2(side, side);
ImGui.SetCursorScreenPos(min);
ImGui.InvisibleButton($"##lab-btn-{i}", new Vector2(side, side));
var hovered = ImGui.IsItemHovered();
var amount = HoverState.Query((uint)(0x1B000 + i), hovered);
DrawIconButtonVariant(dl, min, max, icons[i], amount, c, fonts, scale);
}
ImGui.SetCursorScreenPos(origin);
ImGui.Dummy(new Vector2(0f, side + 10f * scale));
ImGui.Spacing();
}
private void DrawIconButtonVariant(
ImDrawListPtr dl,
Vector2 min,
Vector2 max,
FontAwesomeIcon icon,
float amount,
ThemeColors c,
FontManager fonts,
float scale
)
{
var accent = ColourUtil.RgbaToAbgr(c.Accent);
switch (_buttonVariant)
{
case 0:
// What the sidebar does: nothing at rest, a soft fill on hover.
if (amount > 0f)
dl.AddRectFilled(
min,
max,
ColourUtil.ApplyAlpha(ColourUtil.RgbaToAbgr(c.SurfaceHover), amount),
3f * scale
);
break;
case 1:
// Chamfered plate with a vertical gradient, the shape the
// segmented control uses for its selected segment.
dl.DrawSlipPolygon(min, max, c.Surface, 4f * scale);
dl.DrawVerticalGradient(
min,
max,
ColourUtil.ApplyAlpha(
ColourUtil.RgbaToAbgr(c.SurfaceHover),
0.35f + amount * 0.4f
),
0u
);
break;
case 2:
// Flat at rest, and the glow border -- unused until now -- comes
// up with the hover instead of a fill.
if (amount > 0f)
{
dl.AddRectFilled(
min,
max,
ColourUtil.ApplyAlpha(ColourUtil.RgbaToAbgr(c.Surface), amount * 0.8f),
3f * scale
);
// Alpha into the LOW byte: DrawGlowBorder reads RGBA, while
// ApplyAlpha writes the high byte (ABGR). The first version
// of this lab mixed them up and got a full-alpha glow with
// the red channel dimmed instead.
var glow = (c.Accent & 0xFFFFFF00u) | (uint)(byte)Math.Round(0xB4 * amount);
dl.DrawGlowBorder(min, max, glow, 1f, 4);
}
break;
}
using (fonts.FontAwesome.Push())
{
var glyph = icon.ToIconString();
var size = ImGui.CalcTextSize(glyph);
var tint = ColourUtil.EnsureContrast(
ColourUtil.RgbaToAbgr(c.TextPrimary),
ColourUtil.RgbaToAbgr(c.ChildBg),
4.5f
);
if (amount > 0f)
tint = ColourUtil.Lerp(tint, accent, amount);
dl.AddText(min + (max - min - size) * 0.5f, tint, glyph);
}
}
// ---- input field --------------------------------------------------------
private void DrawFieldSection(ThemeColors c)
{
Heading("INPUT FIELD", c);
ImGui.RadioButton("imgui default##f", ref _fieldVariant, 0);
ImGui.SameLine();
ImGui.RadioButton("own surface##f", ref _fieldVariant, 1);
ImGui.SameLine();
ImGui.RadioButton("own surface + focus rail##f", ref _fieldVariant, 2);
ImGui.Spacing();
var scale = Metrics.Scale;
var height = MathF.Round(24f * scale);
var width = ImGui.GetContentRegionAvail().X - 8f;
var origin = ImGui.GetCursorScreenPos();
var dl = ImGui.GetWindowDrawList();
var max = origin + new Vector2(width, height);
if (_fieldVariant > 0)
{
// The technique from Boutique.Inputs: paint the surface, then hand
// ImGui a transparent frame so the widget draws only text and caret.
dl.AddRectFilled(origin, max, ColourUtil.RgbaToAbgr(c.FrameBg), 6f * scale);
if (_fieldVariant == 2 && ImGui.IsItemActive())
dl.AddRectFilled(
origin,
new Vector2(origin.X + 2f * scale, max.Y),
ColourUtil.RgbaToAbgr(c.Accent)
);
}
using (ImRaii.PushColor(ImGuiCol.FrameBg, Vector4.Zero, _fieldVariant > 0))
using (ImRaii.PushColor(ImGuiCol.FrameBgHovered, Vector4.Zero, _fieldVariant > 0))
using (ImRaii.PushColor(ImGuiCol.FrameBgActive, Vector4.Zero, _fieldVariant > 0))
using (ImRaii.PushStyle(ImGuiStyleVar.FramePadding, new Vector2(10f * scale, 4f * scale)))
{
ImGui.SetNextItemWidth(width);
ImGui.InputTextWithHint("##lab-field", "Type a message...", ref _sample, 256);
}
// Focus rail after the widget: IsItemActive only answers afterwards.
if (_fieldVariant == 2 && ImGui.IsItemActive())
dl.AddRectFilled(
origin,
new Vector2(origin.X + 2f * scale, max.Y),
ColourUtil.RgbaToAbgr(c.Accent)
);
ImGui.Spacing();
}
// ---- channel pill -------------------------------------------------------
private void DrawPillSection(ThemeColors c, FontManager fonts)
{
Heading("CHANNEL PILL", c);
ImGui.RadioButton("current##p", ref _pillVariant, 0);
ImGui.SameLine();
ImGui.RadioButton("gradient + top light##p", ref _pillVariant, 1);
ImGui.SameLine();
ImGui.RadioButton("chamfered##p", ref _pillVariant, 2);
ImGui.Spacing();
var scale = Metrics.Scale;
var origin = ImGui.GetCursorScreenPos();
var dl = ImGui.GetWindowDrawList();
const string label = "Jingliu Moon";
var textWidth = ImGui.CalcTextSize(label).X;
var size = new Vector2(textWidth + 34f * scale, MathF.Round(22f * scale));
var max = origin + size;
var fill = ColourUtil.RgbaToAbgr(c.Accent);
switch (_pillVariant)
{
case 0:
dl.AddRectFilled(origin, max, fill, 6f * scale);
break;
case 1:
dl.AddRectFilled(origin, max, fill, 6f * scale);
dl.DrawVerticalGradient(origin, max, 0x30FFFFFFu, 0u);
dl.AddRectFilled(
new Vector2(origin.X + 6f * scale, origin.Y),
new Vector2(max.X - 6f * scale, origin.Y + 1f * scale),
0x60FFFFFFu
);
break;
case 2:
dl.DrawSlipPolygon(origin, max, c.Accent, 6f * scale);
dl.DrawVerticalGradient(origin, max, 0x28FFFFFFu, 0u);
break;
}
var ink = ColourUtil.EnsureContrast(
ColourUtil.RgbaToAbgr(c.WindowBg),
ColourUtil.RgbaToAbgr(c.Accent),
4.5f
);
using (fonts.FontAwesome.Push())
{
var arrow = FontAwesomeIcon.ArrowRight.ToIconString();
dl.AddText(
new Vector2(
origin.X + 8f * scale,
origin.Y + (size.Y - ImGui.GetFontSize()) * 0.5f
),
ink,
arrow
);
}
dl.AddText(
new Vector2(
origin.X + 24f * scale,
origin.Y + (size.Y - ImGui.GetTextLineHeight()) * 0.5f
),
ink,
label
);
ImGui.Dummy(new Vector2(0f, size.Y + 10f * scale));
ImGui.Spacing();
}
// ---- channel header -----------------------------------------------------
private void DrawHeaderSection(ThemeColors c, FontManager fonts)
{
Heading("CHANNEL HEADER", c);
ImGui.RadioButton("rule only (current)##h", ref _headerVariant, 0);
ImGui.SameLine();
ImGui.RadioButton("underline##h", ref _headerVariant, 1);
ImGui.SameLine();
ImGui.RadioButton("tinted band##h", ref _headerVariant, 2);
ImGui.Spacing();
var scale = Metrics.Scale;
var width = ImGui.GetContentRegionAvail().X - 8f;
var height = ImGui.GetTextLineHeight() + 14f * scale;
var origin = ImGui.GetCursorScreenPos();
var max = origin + new Vector2(width, height);
var dl = ImGui.GetWindowDrawList();
var accent = ColourUtil.EnsureContrast(
ColourUtil.RgbaToAbgr(c.Accent),
ColourUtil.RgbaToAbgr(c.ChildBg),
4.5f
);
// The band variant is the one the mockup drew and v1.11.0 abandoned.
// Here at a fraction of the strength, as a tint rather than a plate --
// included so the comparison is fair rather than rhetorical.
if (_headerVariant == 2)
dl.DrawEdgeTint(origin, max, ColourUtil.ApplyAlpha(accent, 0.10f), height);
var textY = origin.Y + 7f * scale;
const string name = "FREE COMPANY";
float drawn;
using (fonts.MetaFont!.Push())
drawn = dl.DrawTrackedText(
new Vector2(origin.X + 14f * scale, textY),
name,
accent,
1.8f * scale
);
if (_headerVariant == 0)
{
var ruleX = origin.X + 14f * scale + drawn + 10f * scale;
if (max.X - 14f * scale > ruleX)
dl.DrawFadeRule(
new Vector2(ruleX, textY + ImGui.GetTextLineHeight() * 0.5f),
max.X - 14f * scale - ruleX,
ColourUtil.RgbaToAbgr(c.Border),
MathF.Max(1f, scale)
);
}
else if (_headerVariant == 1)
{
// Underline under the name only, the way the top tab strip marks its
// active tab -- a shape the plugin already uses elsewhere.
dl.AddRectFilled(
new Vector2(origin.X + 14f * scale, max.Y - 2f * scale),
new Vector2(origin.X + 14f * scale + drawn, max.Y),
accent
);
}
ImGui.Dummy(new Vector2(0f, height + 6f * scale));
}
// ---- shared -------------------------------------------------------------
private static void Heading(string text, ThemeColors c)
{
ImGui.Spacing();
var dl = ImGui.GetWindowDrawList();
var pos = ImGui.GetCursorScreenPos();
var width = dl.DrawTrackedText(
pos,
text,
ColourUtil.EnsureContrast(
ColourUtil.RgbaToAbgr(c.TextMuted),
ColourUtil.RgbaToAbgr(c.WindowBg),
4.5f
),
1.6f * Metrics.Scale
);
var ruleX = pos.X + width + 10f;
var avail = ImGui.GetContentRegionAvail().X;
dl.DrawFadeRule(
new Vector2(ruleX, pos.Y + ImGui.GetTextLineHeight() * 0.5f),
pos.X + avail - ruleX,
ColourUtil.RgbaToAbgr(c.Border),
1f
);
ImGui.Dummy(new Vector2(0f, ImGui.GetTextLineHeight() + 4f));
}
}