Merge branch 'feature/v1.14.0' into main

The input row cycle: two ghost buttons and a menu where five filled defaults
sat, a field with its own surface and focus rail, popups built from the
sidebar's row shape, a softened seam to the conversation, and hover fades at a
human pace. Plus the style lab and the preflight guard for the RGBA/ABGR trap.

Local state only. repo.json stays on 1.5.6.0.

Note on shape: these commits were made directly on main by mistake and were
re-homed onto feature/v1.14.0 before this merge, so the branch-per-cycle rule
holds in the history even where it slipped in the moment.
This commit is contained in:
2026-08-19 19:35:51 +02:00
46 changed files with 1225 additions and 146 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Dalamud.NET.Sdk/15.0.0"> <Project Sdk="Dalamud.NET.Sdk/15.0.0">
<PropertyGroup> <PropertyGroup>
<!-- Independent versioning; see yaml changelog for upstream Chat 2 base --> <!-- Independent versioning; see yaml changelog for upstream Chat 2 base -->
<Version>1.13.0</Version> <Version>1.14.0</Version>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<!-- Use lock file to pin exact versions --> <!-- Use lock file to pin exact versions -->
+10
View File
@@ -112,6 +112,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
#if DEBUG #if DEBUG
internal Ui.Windows.WidgetGalleryWindow WidgetGallery { get; private set; } = null!; internal Ui.Windows.WidgetGalleryWindow WidgetGallery { get; private set; } = null!;
#endif #endif
internal Ui.Windows.InputBarLabWindow InputBarLab { get; private set; } = null!;
public FirstRunWizard FirstRunWizard { get; private set; } = null!; public FirstRunWizard FirstRunWizard { get; private set; } = null!;
internal DebuggerWindow DebuggerWindow { get; private set; } = null!; internal DebuggerWindow DebuggerWindow { get; private set; } = null!;
@@ -456,6 +457,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
#if DEBUG #if DEBUG
WidgetGallery = _host.Services.GetRequiredService<Ui.Windows.WidgetGalleryWindow>(); WidgetGallery = _host.Services.GetRequiredService<Ui.Windows.WidgetGalleryWindow>();
#endif #endif
InputBarLab = _host.Services.GetRequiredService<Ui.Windows.InputBarLabWindow>();
DebuggerWindow = _host.Services.GetRequiredService<DebuggerWindow>(); DebuggerWindow = _host.Services.GetRequiredService<DebuggerWindow>();
FirstRunWizard = _host.Services.GetRequiredService<FirstRunWizard>(); FirstRunWizard = _host.Services.GetRequiredService<FirstRunWizard>();
ChannelPopoutPool = _host.Services.GetRequiredService<Ui.Windows.ChannelPopoutPool>(); ChannelPopoutPool = _host.Services.GetRequiredService<Ui.Windows.ChannelPopoutPool>();
@@ -1042,6 +1044,14 @@ public sealed class Plugin : IAsyncDalamudPlugin
WidgetGallery.Toggle(); WidgetGallery.Toggle();
return; return;
} }
#endif
if (arg.Equals("lab", StringComparison.OrdinalIgnoreCase))
{
InputBarLab.Toggle();
return;
}
#if DEBUG
#endif #endif
if (arg.Equals("reset", StringComparison.OrdinalIgnoreCase)) if (arg.Equals("reset", StringComparison.OrdinalIgnoreCase))
{ {
+6
View File
@@ -379,6 +379,12 @@ internal static class PluginHostFactory
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>() sp.GetRequiredService<Ui.StyleEngine.TokenResolver>()
)); ));
#endif #endif
// The style lab: variants side by side, in-game, against the live theme.
// Permanent by Flo's call, and deliberately not behind DEBUG -- style
// decisions happen in the build he actually runs.
services.AddSingleton(sp => new Ui.Windows.InputBarLabWindow(
sp.GetRequiredService<Plugin>()
));
services.AddSingleton(sp => new DebuggerWindow( services.AddSingleton(sp => new DebuggerWindow(
sp.GetRequiredService<Plugin>(), sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<PayloadHandler>() sp.GetRequiredService<PayloadHandler>()
+1
View File
@@ -67,6 +67,7 @@ internal sealed class PluginLifecycle : IAsyncDisposable
#if DEBUG #if DEBUG
plugin.WindowSystem.AddWindow(plugin.WidgetGallery); plugin.WindowSystem.AddWindow(plugin.WidgetGallery);
#endif #endif
plugin.WindowSystem.AddWindow(plugin.InputBarLab);
plugin.WindowSystem.AddWindow(plugin.DebuggerWindow); plugin.WindowSystem.AddWindow(plugin.DebuggerWindow);
plugin.WindowSystem.AddWindow(plugin.FirstRunWizard); plugin.WindowSystem.AddWindow(plugin.FirstRunWizard);
+1
View File
@@ -389,6 +389,7 @@ internal class HellionStrings
internal static string Settings_Preview_TitleMock => Get(nameof(Settings_Preview_TitleMock)); internal static string Settings_Preview_TitleMock => Get(nameof(Settings_Preview_TitleMock));
internal static string Settings_Preview_StatusOpen => Get(nameof(Settings_Preview_StatusOpen)); internal static string Settings_Preview_StatusOpen => Get(nameof(Settings_Preview_StatusOpen));
internal static string ChannelHeader_NotLoggedIn => Get(nameof(ChannelHeader_NotLoggedIn)); internal static string ChannelHeader_NotLoggedIn => Get(nameof(ChannelHeader_NotLoggedIn));
internal static string InputBar_More_Tooltip => Get(nameof(InputBar_More_Tooltip));
internal static string Settings_Section_Links => Get(nameof(Settings_Section_Links)); internal static string Settings_Section_Links => Get(nameof(Settings_Section_Links));
internal static string Settings_Section_Behaviour => Get(nameof(Settings_Section_Behaviour)); internal static string Settings_Section_Behaviour => Get(nameof(Settings_Section_Behaviour));
internal static string Settings_Section_Keybinds => Get(nameof(Settings_Section_Keybinds)); internal static string Settings_Section_Keybinds => Get(nameof(Settings_Section_Keybinds));
@@ -1361,4 +1361,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Sessió no iniciada</value> <value>Sessió no iniciada</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Més accions</value>
</data>
</root> </root>
@@ -1360,4 +1360,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Nepřihlášen</value> <value>Nepřihlášen</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Další akce</value>
</data>
</root> </root>
@@ -1360,4 +1360,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Ikke logget ind</value> <value>Ikke logget ind</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Flere handlinger</value>
</data>
</root> </root>
@@ -1355,4 +1355,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Nicht eingeloggt</value> <value>Nicht eingeloggt</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Weitere Aktionen</value>
</data>
</root> </root>
@@ -1360,4 +1360,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Εκτός σύνδεσης</value> <value>Εκτός σύνδεσης</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Περισσότερες ενέργειες</value>
</data>
</root> </root>
@@ -1361,4 +1361,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Sesión no iniciada</value> <value>Sesión no iniciada</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Más acciones</value>
</data>
</root> </root>
@@ -1360,4 +1360,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Ei kirjautuneena</value> <value>Ei kirjautuneena</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Lisää toimintoja</value>
</data>
</root> </root>
@@ -1361,4 +1361,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Non connecté</value> <value>Non connecté</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Plus d'actions</value>
</data>
</root> </root>
@@ -1360,4 +1360,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Nincs bejelentkezve</value> <value>Nincs bejelentkezve</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>További műveletek</value>
</data>
</root> </root>
@@ -1361,4 +1361,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Non connesso</value> <value>Non connesso</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Altre azioni</value>
</data>
</root> </root>
@@ -1361,4 +1361,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>未ログイン</value> <value>未ログイン</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>その他の操作</value>
</data>
</root> </root>
@@ -1361,4 +1361,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>로그인되지 않음</value> <value>로그인되지 않음</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>추가 작업</value>
</data>
</root> </root>
@@ -1360,4 +1360,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Ikke innlogget</value> <value>Ikke innlogget</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Flere handlinger</value>
</data>
</root> </root>
@@ -1361,4 +1361,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Niet ingelogd</value> <value>Niet ingelogd</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Meer acties</value>
</data>
</root> </root>
@@ -1360,4 +1360,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Niezalogowany</value> <value>Niezalogowany</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Więcej działań</value>
</data>
</root> </root>
@@ -1361,4 +1361,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Não conectado</value> <value>Não conectado</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Mais ações</value>
</data>
</root> </root>
@@ -1360,4 +1360,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Sem sessão iniciada</value> <value>Sem sessão iniciada</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Mais ações</value>
</data>
</root> </root>
@@ -1372,4 +1372,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Not logged in</value> <value>Not logged in</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>More actions</value>
</data>
</root> </root>
@@ -1361,4 +1361,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Neconectat</value> <value>Neconectat</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Mai multe acțiuni</value>
</data>
</root> </root>
@@ -1361,4 +1361,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Вход не выполнен</value> <value>Вход не выполнен</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Другие действия</value>
</data>
</root> </root>
@@ -1361,4 +1361,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Inte inloggad</value> <value>Inte inloggad</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Fler åtgärder</value>
</data>
</root> </root>
@@ -1360,4 +1360,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Oturum açılmadı</value> <value>Oturum açılmadı</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Diğer işlemler</value>
</data>
</root> </root>
@@ -1360,4 +1360,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Вхід не виконано</value> <value>Вхід не виконано</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>Інші дії</value>
</data>
</root> </root>
@@ -1361,4 +1361,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>未登录</value> <value>未登录</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>更多操作</value>
</data>
</root> </root>
@@ -1361,4 +1361,7 @@
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve"> <data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>未登入</value> <value>未登入</value>
</data> </data>
<data name="InputBar_More_Tooltip" xml:space="preserve">
<value>更多操作</value>
</data>
</root> </root>
+309 -92
View File
@@ -182,7 +182,13 @@ internal sealed class InputBar
var pillToken = isTell ? Token.AccentEmber : Token.AccentPrimary; var pillToken = isTell ? Token.AccentEmber : Token.AccentPrimary;
var pillRgba = _resolver.Resolve(pillToken, theme.Colors); var pillRgba = _resolver.Resolve(pillToken, theme.Colors);
var pillAbgr = ColourUtil.RgbaToAbgr(pillRgba); var pillAbgr = ColourUtil.RgbaToAbgr(pillRgba);
var pillTextAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary); // Measured against the pill fill it lands on, not taken from the theme
// raw -- an accent fill can swallow the theme's text colour whole.
var pillTextAbgr = ColourUtil.EnsureContrast(
ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary),
pillAbgr,
4.5f
);
DrawChannelPill(activeTab, isTell, pillAbgr, pillTextAbgr); DrawChannelPill(activeTab, isTell, pillAbgr, pillTextAbgr);
ImGui.SameLine(); ImGui.SameLine();
@@ -271,7 +277,18 @@ internal sealed class InputBar
var size = StyleEngine.Widgets.Pill.CalcSize(label, withDot: false); var size = StyleEngine.Widgets.Pill.CalcSize(label, withDot: false);
var origin = ImGui.GetCursorScreenPos(); var origin = ImGui.GetCursorScreenPos();
StyleEngine.Widgets.Pill.Draw(origin, label, pillAbgr, textAbgr); // Chamfered, not rounded: the shape the segmented control already uses
// for its selected segment, with the CS+ white gradient for depth. The
// slip corner is the one piece of the Boutique geometry the plugin had
// built and barely used.
var pillDl = ImGui.GetWindowDrawList();
var pillScale = StyleEngine.Metrics.Scale;
var pillMax = origin + size;
pillDl.DrawSlipPolygon(origin, pillMax, ColourUtil.RgbaToAbgr(pillAbgr), 6f * pillScale);
pillDl.DrawVerticalGradient(origin, pillMax, 0x28FFFFFFu, 0u);
var labelSize = ImGui.CalcTextSize(label);
pillDl.AddText(origin + (size - labelSize) * 0.5f, textAbgr, label);
// Hit area over the rendered pill so a click opens the channel // Hit area over the rendered pill so a click opens the channel
// picker. InvisibleButton both reserves the layout slot and gives // picker. InvisibleButton both reserves the layout slot and gives
@@ -283,7 +300,7 @@ internal sealed class InputBar
DrawChannelPickerPopup(tab); DrawChannelPickerPopup(tab);
} }
private static void DrawChannelPickerPopup(Tab? tab) private void DrawChannelPickerPopup(Tab? tab)
{ {
if (!ImGui.BeginPopup("##hellion-channel-picker")) if (!ImGui.BeginPopup("##hellion-channel-picker"))
return; return;
@@ -296,14 +313,38 @@ internal sealed class InputBar
return; return;
} }
var pickerWidth = 0f;
foreach (var chatType in tab.SelectedChannels.Keys)
{
if (chatType.ToInputChannel() is not { } ch)
continue;
pickerWidth = MathF.Max(
pickerWidth,
StyleEngine.Widgets.PopupRow.CalcWidth(ch.ToChatType().Name(), null, _fonts)
);
}
ImGui.Dummy(new Vector2(pickerWidth, 0f));
var i = 0;
foreach (var chatType in tab.SelectedChannels.Keys) foreach (var chatType in tab.SelectedChannels.Keys)
{ {
if (chatType.ToInputChannel() is not { } input) if (chatType.ToInputChannel() is not { } input)
continue; continue;
var isCurrent = tab.CurrentChannel.Channel == input; var isCurrent = tab.CurrentChannel.Channel == input;
if (ImGui.Selectable(input.ToChatType().Name(), isCurrent)) if (
StyleEngine.Widgets.PopupRow.Draw(
$"##ch-{i++}",
input.ToChatType().Name(),
isCurrent,
_fonts
)
)
{
tab.CurrentChannel.SetChannel(input); tab.CurrentChannel.SetChannel(input);
ImGui.CloseCurrentPopup();
}
} }
} }
finally finally
@@ -325,6 +366,33 @@ internal sealed class InputBar
Activate = false; Activate = false;
} }
// Boutique.Inputs technique: paint the surface, then hand ImGui a
// transparent frame so the widget draws only text and caret. The focus
// rail is the same two-pixel accent bar the rows and popups carry --
// one vocabulary for "this is where you are".
var scale = StyleEngine.Metrics.Scale;
var fieldOrigin = ImGui.GetCursorScreenPos();
var fieldSize = new Vector2(
ImGui.GetContentRegionAvail().X - QuickButtonsReserve,
ImGui.GetFrameHeight()
);
ImGui
.GetWindowDrawList()
.AddRectFilled(
fieldOrigin,
fieldOrigin + fieldSize,
ColourUtil.RgbaToAbgr(_themes.Active.Colors.FrameBg),
6f * scale
);
using var frameBg = ImRaii.PushColor(ImGuiCol.FrameBg, Vector4.Zero);
using var frameBgHovered = ImRaii.PushColor(ImGuiCol.FrameBgHovered, Vector4.Zero);
using var frameBgActive = ImRaii.PushColor(ImGuiCol.FrameBgActive, Vector4.Zero);
using var framePad = ImRaii.PushStyle(
ImGuiStyleVar.FramePadding,
new Vector2(10f * scale, ImGui.GetStyle().FramePadding.Y)
);
ImGui.SetNextItemWidth(-QuickButtonsReserve); ImGui.SetNextItemWidth(-QuickButtonsReserve);
if ( if (
ImGui.InputText( ImGui.InputText(
@@ -343,6 +411,15 @@ internal sealed class InputBar
_commandHelpWindow.IsOpen = false; _commandHelpWindow.IsOpen = false;
TrySend(activeTab); TrySend(activeTab);
} }
if (ImGui.IsItemActive())
ImGui
.GetWindowDrawList()
.AddRectFilled(
fieldOrigin,
new Vector2(fieldOrigin.X + 2f * scale, fieldOrigin.Y + fieldSize.Y),
ColourUtil.RgbaToAbgr(_themes.Active.Colors.Accent)
);
DrawInputContextMenu(); DrawInputContextMenu();
_isFocused = ImGui.IsItemFocused(); _isFocused = ImGui.IsItemFocused();
@@ -627,106 +704,246 @@ internal sealed class InputBar
return (current == InputChannel.Invalid ? text : $"{current.Prefix()} {text}", false); return (current == InputChannel.Invalid ? text : $"{current.Prefix()} {text}", false);
} }
// Two ghost buttons and a menu, where five filled plates used to sit. The
// plates were ImGui defaults in a row whose pill and status bar are drawn;
// the ghosts follow the sidebar's icon buttons instead -- nothing at rest, a
// held hover fill, the glyph lifting toward the accent.
//
// Symbols stay outside the menu because they are used mid-sentence; a click
// through a drawer for that would be a regression. The screenshot toggle may
// live in the menu only because its state moved to the status bar first --
// the whole point of that mode is knowing what your screen shows BEFORE the
// screenshot key, and a state behind a closed menu answers nothing.
private void DrawQuickButtons() private void DrawQuickButtons()
{ {
// Collected here and drawn after the icon font is popped. Inside the push
// it rendered against the FontAwesome atlas, which has no ASCII glyphs, so
// every tooltip came out as an empty box.
string? tooltip = null; string? tooltip = null;
using (_fonts.FontAwesome.Push()) if (DrawGhostButton("##qb-symbols", FontAwesomeIcon.SmileBeam))
{ _symbolPicker.OpenPopup();
if (ImGui.Button(FontAwesomeIcon.SmileBeam.ToIconString())) if (ImGui.IsItemHovered())
_symbolPicker.OpenPopup(); tooltip = HellionStrings.InputBar_InsertSymbol_Tooltip;
if (ImGui.IsItemHovered())
tooltip = HellionStrings.InputBar_InsertSymbol_Tooltip;
if (_themeQuickPicker is not null) ImGui.SameLine(0f, 4f * StyleEngine.Metrics.Scale);
{
ImGui.SameLine();
if (ImGui.Button(FontAwesomeIcon.Palette.ToIconString()))
_themeQuickPicker.OpenPopup();
if (ImGui.IsItemHovered())
tooltip = HellionStrings.Settings_QuickPicker_Tooltip;
}
ImGui.SameLine(); if (DrawGhostButton("##qb-more", FontAwesomeIcon.EllipsisH))
if (ImGui.Button(FontAwesomeIcon.Cog.ToIconString())) ImGui.OpenPopup("##hellion-more-menu");
{ if (ImGui.IsItemHovered())
_onOpenSettings(); tooltip = HellionStrings.InputBar_More_Tooltip;
}
if (ImGui.IsItemHovered())
tooltip = HellionStrings.InputBar_Settings_Tooltip;
// Screenshot mode. It lived only in the right-click menu on a player DrawMoreMenu();
// name, which for a privacy feature is the same as not existing --
// reported as missing by a tester who had been using the plugin for
// months. Lit when active, because a mode you cannot see the state of
// is worse than no mode.
ImGui.SameLine();
var shooting = Plugin.Config.ScreenshotMode;
using (
ImRaii.PushColor(
ImGuiCol.Text,
ColourUtil.RgbaToVector4(
_resolver.Resolve(Token.AccentEmber, _themes.Active.Colors)
),
shooting
)
)
{
if (ImGui.Button(FontAwesomeIcon.Camera.ToIconString()))
Plugin.Config.ScreenshotMode = !Plugin.Config.ScreenshotMode;
}
if (ImGui.IsItemHovered())
tooltip = Language.Context_ScreenshotMode;
// Hides the window (1.5.6 UserHide). One-way — Enter brings it back.
// Main window only; last in the row there.
if (Plugin.Config.ShowHideButton && _onHideWindow is not null)
{
ImGui.SameLine();
if (ImGui.Button(FontAwesomeIcon.EyeSlash.ToIconString()))
_onHideWindow();
if (ImGui.IsItemHovered())
tooltip = HellionStrings.InputBar_HideChat_Tooltip;
}
// Pop-in, in the pop-out windows only. It sits here rather than in a
// header row because a pop-out with its title bar on had no header at
// all -- and the title bar carries no close button, since closing has
// to go through the pool to release the slot.
if (OnPopIn is not null)
{
ImGui.SameLine();
// Red, and contrast-checked against the button plate it sits on
// rather than taken raw: several themes ship a danger colour that
// is nearly invisible on their own button fill.
var danger = ColourUtil.RgbaToAbgr(
_resolver.Resolve(Token.StatusDanger, _themes.Active.Colors)
);
var plate = ColourUtil.Vector4ToAbgr(ImGui.GetStyle().Colors[(int)ImGuiCol.Button]);
using (
ImRaii.PushColor(
ImGuiCol.Text,
ColourUtil.RgbaToVector4(
ColourUtil.RgbaToAbgr(ColourUtil.EnsureContrast(danger, plate, 3f))
)
)
)
{
if (ImGui.Button(FontAwesomeIcon.Times.ToIconString()))
OnPopIn();
}
if (ImGui.IsItemHovered())
tooltip = HellionStrings.InputBar_PopIn_Tooltip;
}
}
if (tooltip is not null) if (tooltip is not null)
ImGui.SetTooltip(tooltip); ImGui.SetTooltip(tooltip);
} }
// Sidebar language: no plate at rest, held hover fill, glyph toward accent.
private bool DrawGhostButton(string id, FontAwesomeIcon icon, bool lit = false)
{
var scale = StyleEngine.Metrics.Scale;
var side = MathF.Round(24f * scale);
var origin = ImGui.GetCursorScreenPos();
var max = origin + new Vector2(side, side);
var clicked = ImGui.InvisibleButton(id, new Vector2(side, side));
var hovered = ImGui.IsItemHovered();
var amount = StyleEngine.HoverState.Query(ImGui.GetID(id), hovered);
var c = _themes.Active.Colors;
var dl = ImGui.GetWindowDrawList();
// Every colour here is measured against what it actually lands on. The
// glyph sits on the window floor, the glow hugs the fill -- a raw theme
// accent can vanish on either, which is exactly the warning that came
// back from the smoke test.
var accent = ColourUtil.EnsureContrast(
ColourUtil.RgbaToAbgr(c.Accent),
ColourUtil.RgbaToAbgr(c.ChildBg),
3f
);
if (amount > 0f)
{
dl.AddRectFilled(
origin,
max,
ColourUtil.ApplyAlpha(ColourUtil.RgbaToAbgr(c.Surface), amount * 0.8f),
3f * scale
);
// DrawGlowBorder reads RGBA with alpha in the low byte; ApplyAlpha
// writes the high byte (ABGR). Mixing them is the channel-order trap
// this cycle already fell into once, so the alpha byte is set by hand.
var glowRgba =
(ColourUtil.RgbaToAbgr(accent) & 0xFFFFFF00u)
| (uint)(byte)Math.Round(0xB4 * amount);
dl.DrawGlowBorder(min: origin, max: max, glowRgba, 1f, 4);
}
var tint = ColourUtil.EnsureContrast(
ColourUtil.RgbaToAbgr(c.TextMuted),
ColourUtil.RgbaToAbgr(c.ChildBg),
4.5f
);
if (lit)
tint = accent;
else if (amount > 0f)
tint = ColourUtil.Lerp(tint, accent, amount * 0.6f);
using (_fonts.FontAwesome.Push())
{
var glyph = icon.ToIconString();
var size = ImGui.CalcTextSize(glyph);
dl.AddText(origin + (max - origin - size) * 0.5f, tint, glyph);
}
return clicked;
}
private void DrawMoreMenu()
{
if (!ImGui.BeginPopup("##hellion-more-menu"))
return;
// Actions run after EndPopup: the theme picker opens its own popup at
// this component's ID scope, not inside the menu's.
var openTheme = false;
try
{
// Width from the widest visible entry, not a fixed minimum -- the
// popup does not grow for draw-list content, and German labels
// outran the first guess within a day.
var menuWidth = 0f;
if (_themeQuickPicker is not null)
menuWidth = MathF.Max(
menuWidth,
StyleEngine.Widgets.PopupRow.CalcWidth(
HellionStrings.Settings_QuickPicker_Tooltip,
FontAwesomeIcon.Palette,
_fonts
)
);
menuWidth = MathF.Max(
menuWidth,
StyleEngine.Widgets.PopupRow.CalcWidth(
HellionStrings.InputBar_Settings_Tooltip,
FontAwesomeIcon.Cog,
_fonts
)
);
menuWidth = MathF.Max(
menuWidth,
StyleEngine.Widgets.PopupRow.CalcWidth(
Language.Context_ScreenshotMode,
FontAwesomeIcon.Camera,
_fonts
)
);
if (Plugin.Config.ShowHideButton && _onHideWindow is not null)
menuWidth = MathF.Max(
menuWidth,
StyleEngine.Widgets.PopupRow.CalcWidth(
HellionStrings.InputBar_HideChat_Tooltip,
FontAwesomeIcon.EyeSlash,
_fonts
)
);
if (OnPopIn is not null)
menuWidth = MathF.Max(
menuWidth,
StyleEngine.Widgets.PopupRow.CalcWidth(
HellionStrings.InputBar_PopIn_Tooltip,
FontAwesomeIcon.Times,
_fonts
)
);
ImGui.Dummy(new Vector2(menuWidth, 0f));
if (
_themeQuickPicker is not null
&& StyleEngine.Widgets.PopupRow.Draw(
"##mm-theme",
HellionStrings.Settings_QuickPicker_Tooltip,
active: false,
_fonts,
FontAwesomeIcon.Palette
)
)
{
openTheme = true;
ImGui.CloseCurrentPopup();
}
if (
StyleEngine.Widgets.PopupRow.Draw(
"##mm-settings",
HellionStrings.InputBar_Settings_Tooltip,
active: false,
_fonts,
FontAwesomeIcon.Cog
)
)
{
_onOpenSettings();
ImGui.CloseCurrentPopup();
}
// Shows its state right here as the active row, and again in the
// status bar once on -- the menu closes, the awareness must not.
if (
StyleEngine.Widgets.PopupRow.Draw(
"##mm-screenshot",
Language.Context_ScreenshotMode,
active: Plugin.Config.ScreenshotMode,
_fonts,
FontAwesomeIcon.Camera
)
)
{
Plugin.Config.ScreenshotMode = !Plugin.Config.ScreenshotMode;
}
if (
Plugin.Config.ShowHideButton
&& _onHideWindow is not null
&& StyleEngine.Widgets.PopupRow.Draw(
"##mm-hide",
HellionStrings.InputBar_HideChat_Tooltip,
active: false,
_fonts,
FontAwesomeIcon.EyeSlash
)
)
{
_onHideWindow();
ImGui.CloseCurrentPopup();
}
if (
OnPopIn is not null
&& StyleEngine.Widgets.PopupRow.Draw(
"##mm-popin",
HellionStrings.InputBar_PopIn_Tooltip,
active: false,
_fonts,
FontAwesomeIcon.Times
)
)
{
OnPopIn();
ImGui.CloseCurrentPopup();
}
}
finally
{
ImGui.EndPopup();
}
if (openTheme)
_themeQuickPicker!.OpenPopup();
}
// Test-only hook; do not call from production code. // Test-only hook; do not call from production code.
internal void TestSetPendingMessageForSelfTest(string value) => _pendingMessage = value; internal void TestSetPendingMessageForSelfTest(string value) => _pendingMessage = value;
@@ -269,20 +269,10 @@ internal sealed class LivePreviewPanel : IDisposable
draw.AddRectFilled(listOrigin, max, ColourUtil.RgbaToAbgr(theme.Colors.WindowBg)); draw.AddRectFilled(listOrigin, max, ColourUtil.RgbaToAbgr(theme.Colors.WindowBg));
// The channel header the real window now carries above its log. Tracked // The channel header the real window carries above its log: tracked caps
// caps, same as the widget draws them. // and a rule that fades out, no plate. Same reasoning as the section
// headings in this very window.
var headerHeight = ImGui.GetTextLineHeight() + 8f; var headerHeight = ImGui.GetTextLineHeight() + 8f;
draw.AddRectFilled(
listOrigin,
new Vector2(max.X, listOrigin.Y + headerHeight),
ColourUtil.RgbaToAbgr(theme.Colors.Surface)
);
draw.AddLine(
new Vector2(listOrigin.X, listOrigin.Y + headerHeight),
new Vector2(max.X, listOrigin.Y + headerHeight),
ColourUtil.RgbaToAbgr(theme.Colors.Border),
1f
);
draw.DrawTrackedText( draw.DrawTrackedText(
new Vector2(listOrigin.X + 6f, listOrigin.Y + 4f), new Vector2(listOrigin.X + 6f, listOrigin.Y + 4f),
MockChannel, MockChannel,
@@ -295,6 +285,16 @@ internal sealed class LivePreviewPanel : IDisposable
1.8f 1.8f
); );
draw.DrawFadeRule(
new Vector2(
listOrigin.X + 6f + 68f,
listOrigin.Y + 4f + ImGui.GetTextLineHeight() * 0.5f
),
listWidth - 6f - 68f - 6f,
ColourUtil.RgbaToAbgr(theme.Colors.Border),
1f
);
var padMin = new Vector2(listOrigin.X + 2f, max.Y - 6f); var padMin = new Vector2(listOrigin.X + 2f, max.Y - 6f);
draw.AddRectFilled( draw.AddRectFilled(
padMin, padMin,
+7
View File
@@ -317,6 +317,13 @@ internal sealed class Sidebar
), ),
AccentAbgr = _palette.Abgr(Token.AccentPrimary, colors), AccentAbgr = _palette.Abgr(Token.AccentPrimary, colors),
BorderAbgr = ColourUtil.ApplyAlpha(_palette.Abgr(Token.Border, colors), opacity), BorderAbgr = ColourUtil.ApplyAlpha(_palette.Abgr(Token.Border, colors), opacity),
},
new RowStyle
{
// Across the gap between the sidebar group and the message
// area, so the selected tab touches the conversation it
// selects instead of ending at the seam.
ActiveBridgeWidth = ImGui.GetStyle().ItemSpacing.X,
} }
); );
+24
View File
@@ -203,6 +203,30 @@ internal sealed class StatusBar
FontAwesomeIcon.Lock, FontAwesomeIcon.Lock,
_fonts.FontAwesome _fonts.FontAwesome
); );
// Screenshot mode announces itself here while it is on. The toggle sits
// in the input row's menu, and a closed menu answers nothing -- the whole
// point of the mode is knowing what the screen shows before the
// screenshot key is pressed. Accent text, because this one is a state
// the user chose, not ambient information.
if (Plugin.Config.ScreenshotMode)
{
var camWidth = StyleEngine.Widgets.Pill.MeasureIcon(
FontAwesomeIcon.Camera,
_fonts.FontAwesome
);
var camLabel = Language.Context_ScreenshotMode;
if (Fits(camLabel, withDot: false, camWidth))
x +=
DrawSlot(
new Vector2(x, top),
camLabel,
pillFill,
palette.Abgr(Token.AccentPrimary, colors),
null,
(FontAwesomeIcon.Camera, _fonts.FontAwesome)
) + gap;
}
if (Fits(privacyLabel, withDot: false, lockWidth)) if (Fits(privacyLabel, withDot: false, lockWidth))
x += x +=
DrawSlot( DrawSlot(
+4 -1
View File
@@ -61,7 +61,10 @@ internal static class HoverState
// OR, never assign: a second caller in the same frame that is not hovered // OR, never assign: a second caller in the same frame that is not hovered
// must not cancel the first one that is. // must not cancel the first one that is.
entry.Hovered |= hovered; entry.Hovered |= hovered;
return entry.Value;
// Callers get the eased value; the entry keeps the linear one, which is
// what the evict rule and the advance step are written against.
return HoverMath.Ease(entry.Value);
} }
// Never re-entrant: BeginFrame runs once from the draw thread, AdvanceForTest // Never re-entrant: BeginFrame runs once from the draw thread, AdvanceForTest
+4 -4
View File
@@ -27,10 +27,10 @@ internal static class Metrics
// --- Input bar --- // --- Input bar ---
internal const float InputBarHeightRaw = 32f; internal const float InputBarHeightRaw = 32f;
// Raised from 130 in v1.13.0 for the screenshot-mode button. Five buttons in // Two ghost buttons since v1.14.0 -- symbols and the more-menu. Everything
// the main window (symbols, theme, screenshot, settings, hide) and five in a // else moved into the menu, and the hundred-odd pixels went back to the
// pop-out, where pop-in takes the place of hide. // input field.
internal const float InputQuickButtonsReserveRaw = 156f; internal const float InputQuickButtonsReserveRaw = 62f;
// --- Honorific header --- // --- Honorific header ---
internal const float HonorificHeightRaw = 30f; internal const float HonorificHeightRaw = 30f;
@@ -9,11 +9,17 @@ namespace HellionChat.Ui.StyleEngine.Widgets;
// The band above the conversation: which channel you are in on the left, where // The band above the conversation: which channel you are in on the left, where
// you are and what time it is on the right. // you are and what time it is on the right.
// //
// Set apart by small caps with wide tracking rather than by size. That is a // Set apart by small caps with wide tracking, and by nothing else. The mockup
// deliberate departure from the mockup, which asks for one pixel smaller than // draws this as a filled bar; the settings window used to draw its section
// body text: one pixel would cost a whole additional font handle at full glyph // headings that way too, and it was dropped in v1.11.0 for a reason worth
// range, because tab names are free user input and can be CJK. Tracking carries // repeating -- a fill reads fine against blue themes and vanishes against violet
// the same weight in every palette and costs nothing. // ones, because its only distinction from its surroundings is hue. Tracking is
// shape, and shape survives every palette.
//
// So: no plate, but a tint. A tenth-opacity accent wash falling from the top
// edge -- colour as atmosphere rather than as a box, which is what the plate got
// wrong. Flo picked it from the lab over the rule-only variant; at this strength
// it survives the violet themes that killed the filled bar.
// //
// Which face draws what is not a style choice here, it is a constraint. The meta // Which face draws what is not a style choice here, it is a constraint. The meta
// face has a glyph range of ASCII plus a middle dot, so only the world name and // face has a glyph range of ASCII plus a middle dot, so only the world name and
@@ -68,8 +74,10 @@ internal static class ChannelHeader
var origin = ImGui.GetCursorScreenPos(); var origin = ImGui.GetCursorScreenPos();
var theme = Plugin.Instance.ThemeRegistry.Active; var theme = Plugin.Instance.ThemeRegistry.Active;
var surface = theme.Colors.Surface; // Contrast is still measured against the surface the header sits on, even
var surfaceAbgr = ColourUtil.RgbaToAbgr(surface); // though it no longer paints one -- the chat log's own floor is what the
// text lands on.
var surfaceAbgr = ColourUtil.RgbaToAbgr(theme.Colors.ChildBg);
var body = BodyFace(fonts); var body = BodyFace(fonts);
var meta = MetaFace(fonts); var meta = MetaFace(fonts);
@@ -152,27 +160,23 @@ internal static class ChannelHeader
var dl = ImGui.GetWindowDrawList(); var dl = ImGui.GetWindowDrawList();
var bottomRight = origin + new Vector2(width, height); var bottomRight = origin + new Vector2(width, height);
dl.AddRectFilled(origin, bottomRight, surfaceAbgr);
dl.AddLine( // Converted first, then measured. EnsureContrast works in ABGR --
new Vector2(origin.X, bottomRight.Y - 1f), // handing it the theme's RGBA swaps red and blue on both arguments,
new Vector2(bottomRight.X, bottomRight.Y - 1f), // so it measures a contrast that has nothing to do with what ends up
ColourUtil.RgbaToAbgr(theme.Colors.Border), // on screen and returns a colour in the wrong order on top.
MathF.Max(1f, scale) var accent = ColourUtil.EnsureContrast(
ColourUtil.RgbaToAbgr(theme.Colors.Accent),
surfaceAbgr,
4.5f
); );
dl.DrawEdgeTint(origin, bottomRight, ColourUtil.ApplyAlpha(accent, 0.10f), height);
var textY = origin.Y + MathF.Round(PadYRaw * scale); var textY = origin.Y + MathF.Round(PadYRaw * scale);
if (plan.ShowName) if (plan.ShowName)
{ {
// Converted first, then measured. EnsureContrast works in ABGR --
// handing it the theme's RGBA swaps red and blue on both arguments,
// so it measures a contrast that has nothing to do with what ends up
// on screen and returns a colour in the wrong order on top.
var accent = ColourUtil.EnsureContrast(
ColourUtil.RgbaToAbgr(theme.Colors.Accent),
surfaceAbgr,
4.5f
);
var x = origin.X + inset; var x = origin.X + inset;
// Centred against the band, not aligned to the text baseline: // Centred against the band, not aligned to the text baseline:
@@ -0,0 +1,113 @@
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using HellionChat.Util;
namespace HellionChat.Ui.StyleEngine.Widgets;
// The row a popup is made of. Replaces ImGui.Selectable inside the plugin's
// popups, which carried the right colours through GlobalStyleScope but none of
// the shape the rest of the window speaks: no held hover, no accent bar on the
// active entry.
//
// The shape is the sidebar row's, and it is also what Character Select+ draws
// for its popup entries (Boutique.Dropdowns, DrawPopupRow): a soft fill on
// hover, a two-pixel accent bar when active. One conversation, wherever a list
// appears.
internal static class PopupRow
{
private const float PadXRaw = 10f;
private const float PadYRaw = 5f;
private const float IconGapRaw = 8f;
private const float AccentBarRaw = 2f;
// The popup must be sized from its rows, not the rows from the popup: an
// ImGui popup does not grow for draw-list content, so a fixed minimum width
// clips whichever locale writes the longest labels. German did, first day.
internal static float CalcWidth(string label, FontAwesomeIcon? icon, FontManager fonts)
{
var scale = Metrics.Scale;
var w = PadXRaw * 2f * scale + ImGui.CalcTextSize(label).X;
if (icon is { } glyph)
{
using (fonts.FontAwesome.Push())
w += ImGui.CalcTextSize(glyph.ToIconString()).X + IconGapRaw * scale;
}
return w;
}
internal static bool Draw(
string id,
string label,
bool active,
FontManager fonts,
FontAwesomeIcon? icon = null
)
{
var scale = Metrics.Scale;
var theme = Plugin.Instance.ThemeRegistry.Active;
var c = theme.Colors;
var height = ImGui.GetTextLineHeight() + MathF.Round(PadYRaw * 2f * scale);
var width = ImGui.GetContentRegionAvail().X;
var origin = ImGui.GetCursorScreenPos();
var max = origin + new Vector2(width, height);
var clicked = ImGui.InvisibleButton(id, new Vector2(width, height));
var hovered = ImGui.IsItemHovered();
var amount = HoverState.Query(ImGui.GetID(id), hovered);
var dl = ImGui.GetWindowDrawList();
var accentAbgr = ColourUtil.RgbaToAbgr(c.Accent);
// Active first, hover on top -- an active entry still answers the mouse.
if (active)
dl.AddRectFilled(origin, max, ColourUtil.ApplyAlpha(accentAbgr, 0.10f), 3f * scale);
if (amount > 0f)
dl.AddRectFilled(
origin,
max,
ColourUtil.ApplyAlpha(ColourUtil.RgbaToAbgr(c.SurfaceHover), amount),
3f * scale
);
if (active)
dl.AddRectFilled(
origin,
new Vector2(origin.X + AccentBarRaw * scale, max.Y),
accentAbgr
);
var textAbgr = ColourUtil.EnsureContrast(
ColourUtil.RgbaToAbgr(c.TextPrimary),
ColourUtil.RgbaToAbgr(c.ChildBg),
4.5f
);
if (active || amount > 0f)
textAbgr = ColourUtil.Lerp(textAbgr, accentAbgr, active ? 0.6f : amount * 0.4f);
var x = origin.X + PadXRaw * scale;
if (icon is { } glyph)
{
using (fonts.FontAwesome.Push())
{
var s = glyph.ToIconString();
var size = ImGui.CalcTextSize(s);
dl.AddText(new Vector2(x, origin.Y + (height - size.Y) * 0.5f), textAbgr, s);
x += size.X + IconGapRaw * scale;
}
}
dl.AddText(
new Vector2(x, origin.Y + (height - ImGui.GetTextLineHeight()) * 0.5f),
textAbgr,
label
);
return clicked;
}
}
+23 -6
View File
@@ -25,6 +25,12 @@ internal readonly record struct RowStyle
public float AccentBarWidth { get; init; } = 2f; public float AccentBarWidth { get; init; } = 2f;
public bool DrawSeparator { get; init; } = true; public bool DrawSeparator { get; init; } = true;
// Extends the active fill past the right edge by this many pixels, so the
// selected tab reads as attached to the content it selects -- the classic
// tab metaphor -- instead of ending at a hard seam. Zero for rows that do
// not sit next to the thing they select.
public float ActiveBridgeWidth { get; init; } = 0f;
} }
internal static class Row internal static class Row
@@ -51,13 +57,19 @@ internal static class Row
// default is 0.85 -- an opaque fill per row would make the sidebar a // default is 0.85 -- an opaque fill per row would make the sidebar a
// solid block inside a translucent window. // solid block inside a translucent window.
if (state.IsActive) if (state.IsActive)
dl.AddRectFilled(origin, max, state.SurfaceActiveAbgr); {
var fillMax = max;
if (style.ActiveBridgeWidth > 0f)
fillMax.X += style.ActiveBridgeWidth;
dl.AddRectFilled(origin, fillMax, state.SurfaceActiveAbgr);
}
if (state.HoverAmount > 0f) if (state.HoverAmount > 0f)
dl.AddRectFilled( dl.AddRectFilled(
origin, origin,
max, max,
ColourUtil.ApplyAlpha(state.SurfaceHoverAbgr, state.HoverAmount) ColourUtil.ApplyAlpha(state.SurfaceHoverAbgr, state.HoverAmount),
3f * Metrics.Scale
); );
if (state.IsActive && style.AccentBarWidth > 0f) if (state.IsActive && style.AccentBarWidth > 0f)
@@ -72,12 +84,17 @@ internal static class Row
// path, so an unscaled 1px offset with a scaled stroke puts half the // path, so an unscaled 1px offset with a scaled stroke puts half the
// line below max.Y -- and rows stack flush, so that half lands in the // line below max.Y -- and rows stack flush, so that half lands in the
// first pixel row of the next one. // first pixel row of the next one.
// A full-width line under every row reads as a ladder of hard
// cuts -- the tester's words were that the list felt too hard.
// The rule now starts past the icon column and fades to nothing
// before the right edge, the same shape the section headers use.
var thickness = Metrics.Scale; var thickness = Metrics.Scale;
var y = max.Y - thickness * 0.5f; var y = max.Y - thickness * 0.5f;
dl.AddLine( var inset = 10f * Metrics.Scale;
new Vector2(origin.X, y), dl.DrawFadeRule(
new Vector2(max.X, y), new Vector2(origin.X + inset, y),
state.BorderAbgr, size.X - inset,
ColourUtil.ApplyAlpha(state.BorderAbgr, 0.55f),
thickness thickness
); );
} }
+420
View File
@@ -0,0 +1,420 @@
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));
}
}
+19
View File
@@ -367,6 +367,25 @@ internal sealed class MainWindow : Window, IFocusableChatWindow
_sidebar.Draw(bodyWidth, tabs, ref _activeTab); _sidebar.Draw(bodyWidth, tabs, ref _activeTab);
} }
// The gap between the tab list and the conversation used to be a bare
// strip of window background with a hard edge on both sides -- tester
// feedback called the transition too hard. A faint surface wash that
// fades toward the messages turns the cut into a seam. The active row
// bridges across it (RowStyle.ActiveBridgeWidth), so the selected tab
// stays attached to its content on top of the wash.
{
var seamMin = new Vector2(ImGui.GetItemRectMax().X, ImGui.GetItemRectMin().Y);
var seamMax = new Vector2(
seamMin.X + ImGui.GetStyle().ItemSpacing.X,
ImGui.GetItemRectMax().Y
);
var wash = ColourUtil.ApplyAlpha(
ColourUtil.RgbaToAbgr(Plugin.Instance.ThemeRegistry.Active.Colors.Surface),
0.35f
);
ImGui.GetWindowDrawList().AddRectFilledMultiColor(seamMin, seamMax, wash, 0u, 0u, wash);
}
ImGui.SameLine(); ImGui.SameLine();
using (ImRaii.Group()) using (ImRaii.Group())
+16 -4
View File
@@ -1,12 +1,19 @@
namespace HellionChat.Util; namespace HellionChat.Util;
// State rules for the held hover value, split from HoverState so the build suite // State rules for the held hover value, split from HoverState so the build suite
// can pin them without an ImGui frame. Rates follow Lightless (Selune.cs:36-37): // can pin them without an ImGui frame. Slower out than in is what makes a fade
// slower out than in is what makes a fade read as deliberate rather than laggy. // read as deliberate rather than laggy (the asymmetry follows Lightless,
// Selune.cs:36-37).
//
// The rates started at 14/s in and 8/s out -- a full fade-in in seventy
// milliseconds, which tester feedback called light speed. The first correction
// halved them and was still "much too fast", so: rise in about a third of a
// second, settle in half of one. Textbook says 150ms; the textbook does not
// play this game.
internal static class HoverMath internal static class HoverMath
{ {
internal const float FadeInPerSecond = 14f; internal const float FadeInPerSecond = 3f;
internal const float FadeOutPerSecond = 8f; internal const float FadeOutPerSecond = 2f;
// Below this an entry is indistinguishable from zero and can be dropped. // Below this an entry is indistinguishable from zero and can be dropped.
internal const float EvictBelow = 0.001f; internal const float EvictBelow = 0.001f;
@@ -20,4 +27,9 @@ internal static class HoverMath
); );
internal static bool ShouldEvict(float value, bool hovered) => !hovered && value <= EvictBelow; internal static bool ShouldEvict(float value, bool hovered) => !hovered && value <= EvictBelow;
// Smoothstep over the linear state. The state itself stays linear -- evict
// and threshold logic depend on it -- but what callers paint with starts
// gently and lands gently instead of moving at one speed and stopping dead.
internal static float Ease(float t) => t * t * (3f - 2f * t);
} }
+77
View File
@@ -11,6 +11,83 @@ releases as an overview and links to the release pages for details.
--- ---
## [1.14.0] — unreleased (local only)
The input row. It was the one surface a user actually works in and it spoke three
shape languages at once — a rounded pill, a square field, five filled default
buttons — sitting directly above a status bar that is rounded throughout. This
release makes the row one thing, and it teaches the popups the window's own
shapes while it is in there. **Not published** — the public release stays at
v1.5.6.
### Added
- A style lab under `/hellion lab`: variants of UI elements side by side, drawn
in real ImGui against the live theme. Permanent, as a dev playground — its
radio buttons default to whatever shipped, so the window doubles as a record
of which variant won. Every decision in this release was made by looking at it
in-game rather than at a drawing.
- `PopupRow`, the row a popup is made of: soft hover fill, a two-pixel accent
bar on the active entry — the sidebar row's shape, which is also exactly what
Character Select+ draws for its own popup entries. The channel picker and the
new input-row menu are built from it.
- A colour-channel guard in preflight (Block G). `EnsureContrast` works in ABGR,
theme colours are RGBA, both are `uint`, and the mistake of feeding one into
the other happened six times in a single day — each found only by a human
staring at unreadable text. The guard flags any raw theme member reaching the
helper; on its first real run it caught three cases a hand-written search had
missed minutes earlier.
### Changed
- The five filled buttons in the input row are two ghost buttons and a menu.
Symbols stay outside because they are used mid-sentence; theme, settings,
screenshot mode and hide moved in. The ghosts are flat at rest and glow on
hover — the first caller `DrawGlowBorder` ever had.
- The screenshot toggle was allowed into the menu only because its state moved
to the status bar first, as an accent camera pill that shows while the mode is
on. Knowing what the screen shows before the screenshot key is pressed is the
entire point of the mode; a state behind a closed menu answers nothing.
- The input field paints its own rounded surface and hands ImGui a transparent
frame, so the widget draws only text and caret. Focus is a two-pixel accent
rail on the left edge — the same mark rows and popups use. The field is about
a hundred pixels wider, reclaimed from the button reserve.
- The channel pill is chamfered, the corner language of the segmented control,
with a faint white depth gradient.
- The channel header traded its surface plate for a tenth-opacity accent wash.
The plate was the exact shape the settings window abandoned in v1.11.0 — it
reads fine on blue themes and vanishes on violet ones.
- The sidebar softened, on tester feedback that the transition to the chat field
was too hard: the full-width border line under every row is a fading rule now,
the gap between list and messages carries a surface wash instead of two hard
edges, and the selected tab bridges across it so it touches the conversation
it selects.
- Hover fades run at a quarter of their old speed with a smoothstep curve —
soft start, soft landing. 14/s in was judged light speed; it is 3/s now, and
a test guards against drifting back. Every hover in the plugin inherits both
changes through the one shared code path.
### Fixed
- Popups size themselves from their widest visible entry. Both new popups
carried a guessed minimum width, and German outran it within a day —
`Schnellauswahl` and `Chat ausblenden` were clipped mid-word.
- Popup entry text was unreadable at rest on several themes and only became
legible on hover: the RGBA-into-ABGR mistake again, in `PopupRow` and three
spots in the lab. Fixed, and the new preflight guard exists so the seventh
occurrence dies in CI rather than in a screenshot.
- The pill label was raw theme text on an accent fill with no contrast check.
### Known issues
- The channel header still recomputes its widths every frame rather than on the
status bar's one-second tick.
- `Metrics.Scale` still calls a Dalamud property that is marked obsolete.
- The first-run wizard is now the last surface drawn in ImGui defaults. It is
the next cycle.
---
## [1.13.0] — unreleased (local only) ## [1.13.0] — unreleased (local only)
Typography. The message list was the last surface still drawn in ImGui defaults, Typography. The message list was the last surface still drawn in ImGui defaults,
+13 -3
View File
@@ -14,7 +14,7 @@ be a poor fit for the plugin's privacy-first scope during brainstorming.
The published release is **v1.5.6**. Development since then runs as a UI rebuild towards v2.0.0 and The published release is **v1.5.6**. Development since then runs as a UI rebuild towards v2.0.0 and
is not published: the whole window layer is being rewritten from ImGui defaults to custom drawing. is not published: the whole window layer is being rewritten from ImGui defaults to custom drawing.
Versions v1.6.0 through v1.13.0 are local development states, and `repo.json` deliberately keeps Versions v1.6.0 through v1.14.0 are local development states, and `repo.json` deliberately keeps
its download links on v1.5.6 so nobody updates into a partial state. its download links on v1.5.6 so nobody updates into a partial state.
Where it stands: Where it stands:
@@ -35,8 +35,18 @@ Where it stands:
- **v1.13.0** — typography. Named type roles instead of one size, a channel header above the - **v1.13.0** — typography. Named type roles instead of one size, a channel header above the
conversation, timestamps in a column of their own, and rows with a surface. Screenshot mode conversation, timestamps in a column of their own, and rows with a surface. Screenshot mode
reached one of four surfaces that draw a tab name and now reaches all of them. reached one of four surfaces that draw a tab name and now reaches all of them.
- **v1.14.0 onwards** — the sidebar moving from tab rows to channel rows, then the first-run - **v1.14.0** — the input row: two ghost buttons and a menu where five filled defaults sat, a
wizard and the input bar, which are the last two surfaces still drawn in ImGui defaults. field with its own surface and focus rail, popups built from the sidebar's row shape, and a
softened seam between the tab list and the conversation. Plus a style lab under
`/hellion lab` and a preflight guard for the RGBA/ABGR contrast trap.
- **v1.15.0** — the first-run wizard, the last surface still drawn in ImGui defaults, and the
first thing a tester sees.
The sidebar is no longer on this list. Earlier drafts had it moving from tab rows to channel
rows, but both reasons for that are gone: it was flat and unstyled when the idea was written,
and it is neither now — it draws its own row surfaces and already groups pinned and auto-tell
tabs under headings. What remained was a change to how the plugin is operated rather than how
it looks, and tabs are what users have learned. They stay.
A tester beta follows once the visual pass is complete. A tester beta follows once the visual pass is complete.
+3
View File
@@ -18,6 +18,9 @@ echo "==> preflight: Block B — manifest shape"
echo "==> preflight: Block C — changelog sync" echo "==> preflight: Block C — changelog sync"
./scripts/verify-changelog-sync.sh ./scripts/verify-changelog-sync.sh
echo "==> preflight: Block G — colour channel audit"
./scripts/verify-colour-channels.sh
echo "==> preflight: Block D — plugin compile health" echo "==> preflight: Block D — plugin compile health"
dotnet build HellionChat/HellionChat.csproj --configuration Release --nologo --verbosity quiet dotnet build HellionChat/HellionChat.csproj --configuration Release --nologo --verbosity quiet
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# verify-colour-channels.sh — Block G of preflight.
#
# EnsureContrast works in ABGR. ThemeColors fields are RGBA. Feeding one into
# the other swaps red and blue on both arguments, measures a contrast between
# two colours that are never on screen, and returns a value in the wrong order
# on top — and because RgbaToAbgr is an involution, a stray extra conversion
# around the call makes it LOOK plausible while being wrong.
#
# This happened four times on 2026-08-19 alone (channel header, ghost buttons,
# pill text, popup rows), each found only by a human staring at an unreadable
# window. The compiler cannot catch it — both layouts are uint. This script
# catches the one shape every occurrence shared: a raw ThemeColors member as a
# direct argument to EnsureContrast.
#
# Legitimate calls convert first (RgbaToAbgr(...)) or pass values that are
# already ABGR (fields/locals named *Abgr, palette.Abgr(...)).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
fail() { echo "verify-colour-channels: FAIL — $1" >&2; exit 1; }
ok() { echo "verify-colour-channels: OK — $1"; }
HITS="$(python3 - "$ROOT/HellionChat" <<'PY'
import re
import sys
from pathlib import Path
root = Path(sys.argv[1])
# A ThemeColors member (via any local alias) fed straight into EnsureContrast,
# in either argument position, spanning line breaks. Members already in ABGR
# end in "Abgr" and are exempt.
pattern = re.compile(
r"EnsureContrast\(\s*"
r"(?:[A-Za-z_][\w.]*\.)?(?:Colors|c|colors)\.(?!\w*Abgr\b)\w+\s*,"
r"|EnsureContrast\([^;]{0,200}?,\s*"
r"(?:[A-Za-z_][\w.]*\.)?(?:Colors|c|colors)\.(?!\w*Abgr\b)\w+\s*[,)]",
re.S,
)
hits = []
for f in root.rglob("*.cs"):
if "obj" in f.parts or "bin" in f.parts:
continue
text = f.read_text(encoding="utf-8")
for m in pattern.finditer(text):
line = text.count("\n", 0, m.start()) + 1
hits.append(f"{f.relative_to(root.parent)}:{line}")
print("\n".join(hits))
PY
)"
if [ -n "$HITS" ]; then
fail "raw ThemeColors member fed into EnsureContrast (expects ABGR — wrap in RgbaToAbgr):
$HITS"
fi
ok "no raw RGBA theme members reach EnsureContrast"