diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj index aaf2c79..2c7b7d6 100644 --- a/HellionChat/HellionChat.csproj +++ b/HellionChat/HellionChat.csproj @@ -1,7 +1,7 @@ - 1.13.0 + 1.14.0 enable enable diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 1e01812..99aadc6 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -112,6 +112,7 @@ public sealed class Plugin : IAsyncDalamudPlugin #if DEBUG internal Ui.Windows.WidgetGalleryWindow WidgetGallery { get; private set; } = null!; #endif + internal Ui.Windows.InputBarLabWindow InputBarLab { get; private set; } = null!; public FirstRunWizard FirstRunWizard { get; private set; } = null!; internal DebuggerWindow DebuggerWindow { get; private set; } = null!; @@ -456,6 +457,7 @@ public sealed class Plugin : IAsyncDalamudPlugin #if DEBUG WidgetGallery = _host.Services.GetRequiredService(); #endif + InputBarLab = _host.Services.GetRequiredService(); DebuggerWindow = _host.Services.GetRequiredService(); FirstRunWizard = _host.Services.GetRequiredService(); ChannelPopoutPool = _host.Services.GetRequiredService(); @@ -1042,6 +1044,14 @@ public sealed class Plugin : IAsyncDalamudPlugin WidgetGallery.Toggle(); return; } +#endif + + if (arg.Equals("lab", StringComparison.OrdinalIgnoreCase)) + { + InputBarLab.Toggle(); + return; + } +#if DEBUG #endif if (arg.Equals("reset", StringComparison.OrdinalIgnoreCase)) { diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index eee2b8d..c8fa192 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -379,6 +379,12 @@ internal static class PluginHostFactory sp.GetRequiredService() )); #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() + )); services.AddSingleton(sp => new DebuggerWindow( sp.GetRequiredService(), sp.GetRequiredService() diff --git a/HellionChat/PluginLifecycle.cs b/HellionChat/PluginLifecycle.cs index e0a27df..30c7966 100644 --- a/HellionChat/PluginLifecycle.cs +++ b/HellionChat/PluginLifecycle.cs @@ -67,6 +67,7 @@ internal sealed class PluginLifecycle : IAsyncDisposable #if DEBUG plugin.WindowSystem.AddWindow(plugin.WidgetGallery); #endif + plugin.WindowSystem.AddWindow(plugin.InputBarLab); plugin.WindowSystem.AddWindow(plugin.DebuggerWindow); plugin.WindowSystem.AddWindow(plugin.FirstRunWizard); diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index f72737d..9bbfa0c 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -389,6 +389,7 @@ internal class HellionStrings 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 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_Behaviour => Get(nameof(Settings_Section_Behaviour)); internal static string Settings_Section_Keybinds => Get(nameof(Settings_Section_Keybinds)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index bc4c079..c843d03 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1361,4 +1361,7 @@ Sessió no iniciada + + Més accions + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index ca16c67..048653e 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1360,4 +1360,7 @@ Nepřihlášen + + Další akce + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index b0c3a86..2c9743c 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1360,4 +1360,7 @@ Ikke logget ind + + Flere handlinger + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index d4a2e8c..0ebcdee 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1355,4 +1355,7 @@ Nicht eingeloggt + + Weitere Aktionen + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index 7aad3ea..33afe56 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1360,4 +1360,7 @@ Εκτός σύνδεσης + + Περισσότερες ενέργειες + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index e274935..81d99d8 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1361,4 +1361,7 @@ Sesión no iniciada + + Más acciones + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index ec8548c..e9641cf 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1360,4 +1360,7 @@ Ei kirjautuneena + + Lisää toimintoja + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index c1679c2..a1baa48 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1361,4 +1361,7 @@ Non connecté + + Plus d'actions + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index edcab59..f00d6b3 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1360,4 +1360,7 @@ Nincs bejelentkezve + + További műveletek + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index f821293..ef3f54e 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1361,4 +1361,7 @@ Non connesso + + Altre azioni + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index 67029bb..9d700ef 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1361,4 +1361,7 @@ 未ログイン + + その他の操作 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 8bf133b..eb431f0 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1361,4 +1361,7 @@ 로그인되지 않음 + + 추가 작업 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index bd4eba9..0c5073e 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1360,4 +1360,7 @@ Ikke innlogget + + Flere handlinger + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index a98ec25..a44f9b3 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1361,4 +1361,7 @@ Niet ingelogd + + Meer acties + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index 1002fb9..567421d 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1360,4 +1360,7 @@ Niezalogowany + + Więcej działań + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index 3c17c35..fe29447 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1361,4 +1361,7 @@ Não conectado + + Mais ações + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 708b673..8a0b209 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1360,4 +1360,7 @@ Sem sessão iniciada + + Mais ações + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index c0dfed0..4d12f3d 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1372,4 +1372,7 @@ Not logged in + + More actions + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index adae878..2e97185 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1361,4 +1361,7 @@ Neconectat + + Mai multe acțiuni + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index d7da149..d3e84fd 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1361,4 +1361,7 @@ Вход не выполнен + + Другие действия + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index b175ce6..62059bc 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1361,4 +1361,7 @@ Inte inloggad + + Fler åtgärder + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index b99d3e7..5d8b520 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1360,4 +1360,7 @@ Oturum açılmadı + + Diğer işlemler + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 6942e83..4274ec3 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1360,4 +1360,7 @@ Вхід не виконано + + Інші дії + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index ea43134..5605975 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1361,4 +1361,7 @@ 未登录 + + 更多操作 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index d06e35e..337e273 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1361,4 +1361,7 @@ 未登入 + + 更多操作 + \ No newline at end of file diff --git a/HellionChat/Ui/Components/InputBar.cs b/HellionChat/Ui/Components/InputBar.cs index 545c741..2e18f96 100644 --- a/HellionChat/Ui/Components/InputBar.cs +++ b/HellionChat/Ui/Components/InputBar.cs @@ -182,7 +182,13 @@ internal sealed class InputBar var pillToken = isTell ? Token.AccentEmber : Token.AccentPrimary; var pillRgba = _resolver.Resolve(pillToken, theme.Colors); 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); ImGui.SameLine(); @@ -271,7 +277,18 @@ internal sealed class InputBar var size = StyleEngine.Widgets.Pill.CalcSize(label, withDot: false); 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 // picker. InvisibleButton both reserves the layout slot and gives @@ -283,7 +300,7 @@ internal sealed class InputBar DrawChannelPickerPopup(tab); } - private static void DrawChannelPickerPopup(Tab? tab) + private void DrawChannelPickerPopup(Tab? tab) { if (!ImGui.BeginPopup("##hellion-channel-picker")) return; @@ -296,14 +313,38 @@ internal sealed class InputBar 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) { if (chatType.ToInputChannel() is not { } input) continue; 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); + ImGui.CloseCurrentPopup(); + } } } finally @@ -325,6 +366,33 @@ internal sealed class InputBar 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); if ( ImGui.InputText( @@ -343,6 +411,15 @@ internal sealed class InputBar _commandHelpWindow.IsOpen = false; 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(); _isFocused = ImGui.IsItemFocused(); @@ -627,106 +704,246 @@ internal sealed class InputBar 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() { - // 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; - using (_fonts.FontAwesome.Push()) - { - if (ImGui.Button(FontAwesomeIcon.SmileBeam.ToIconString())) - _symbolPicker.OpenPopup(); - if (ImGui.IsItemHovered()) - tooltip = HellionStrings.InputBar_InsertSymbol_Tooltip; + if (DrawGhostButton("##qb-symbols", FontAwesomeIcon.SmileBeam)) + _symbolPicker.OpenPopup(); + if (ImGui.IsItemHovered()) + tooltip = HellionStrings.InputBar_InsertSymbol_Tooltip; - if (_themeQuickPicker is not null) - { - ImGui.SameLine(); - if (ImGui.Button(FontAwesomeIcon.Palette.ToIconString())) - _themeQuickPicker.OpenPopup(); - if (ImGui.IsItemHovered()) - tooltip = HellionStrings.Settings_QuickPicker_Tooltip; - } + ImGui.SameLine(0f, 4f * StyleEngine.Metrics.Scale); - ImGui.SameLine(); - if (ImGui.Button(FontAwesomeIcon.Cog.ToIconString())) - { - _onOpenSettings(); - } - if (ImGui.IsItemHovered()) - tooltip = HellionStrings.InputBar_Settings_Tooltip; + if (DrawGhostButton("##qb-more", FontAwesomeIcon.EllipsisH)) + ImGui.OpenPopup("##hellion-more-menu"); + if (ImGui.IsItemHovered()) + tooltip = HellionStrings.InputBar_More_Tooltip; - // Screenshot mode. It lived only in the right-click menu on a player - // 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; - } - } + DrawMoreMenu(); if (tooltip is not null) 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. internal void TestSetPendingMessageForSelfTest(string value) => _pendingMessage = value; diff --git a/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs b/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs index c11cb1d..32cf0a6 100644 --- a/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs +++ b/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs @@ -269,20 +269,10 @@ internal sealed class LivePreviewPanel : IDisposable draw.AddRectFilled(listOrigin, max, ColourUtil.RgbaToAbgr(theme.Colors.WindowBg)); - // The channel header the real window now carries above its log. Tracked - // caps, same as the widget draws them. + // The channel header the real window carries above its log: tracked caps + // and a rule that fades out, no plate. Same reasoning as the section + // headings in this very window. 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( new Vector2(listOrigin.X + 6f, listOrigin.Y + 4f), MockChannel, @@ -295,6 +285,16 @@ internal sealed class LivePreviewPanel : IDisposable 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); draw.AddRectFilled( padMin, diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index 64bc5cc..4adad40 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -317,6 +317,13 @@ internal sealed class Sidebar ), AccentAbgr = _palette.Abgr(Token.AccentPrimary, colors), 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, } ); diff --git a/HellionChat/Ui/Components/StatusBar.cs b/HellionChat/Ui/Components/StatusBar.cs index b7c17b1..e3fe143 100644 --- a/HellionChat/Ui/Components/StatusBar.cs +++ b/HellionChat/Ui/Components/StatusBar.cs @@ -203,6 +203,30 @@ internal sealed class StatusBar FontAwesomeIcon.Lock, _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)) x += DrawSlot( diff --git a/HellionChat/Ui/StyleEngine/HoverState.cs b/HellionChat/Ui/StyleEngine/HoverState.cs index 15775ad..0f1246c 100644 --- a/HellionChat/Ui/StyleEngine/HoverState.cs +++ b/HellionChat/Ui/StyleEngine/HoverState.cs @@ -61,7 +61,10 @@ internal static class HoverState // OR, never assign: a second caller in the same frame that is not hovered // must not cancel the first one that is. 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 diff --git a/HellionChat/Ui/StyleEngine/Metrics.cs b/HellionChat/Ui/StyleEngine/Metrics.cs index 2535881..a836dff 100644 --- a/HellionChat/Ui/StyleEngine/Metrics.cs +++ b/HellionChat/Ui/StyleEngine/Metrics.cs @@ -27,10 +27,10 @@ internal static class Metrics // --- Input bar --- internal const float InputBarHeightRaw = 32f; - // Raised from 130 in v1.13.0 for the screenshot-mode button. Five buttons in - // the main window (symbols, theme, screenshot, settings, hide) and five in a - // pop-out, where pop-in takes the place of hide. - internal const float InputQuickButtonsReserveRaw = 156f; + // Two ghost buttons since v1.14.0 -- symbols and the more-menu. Everything + // else moved into the menu, and the hundred-odd pixels went back to the + // input field. + internal const float InputQuickButtonsReserveRaw = 62f; // --- Honorific header --- internal const float HonorificHeightRaw = 30f; diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs index 8668c7c..c738784 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs @@ -9,11 +9,17 @@ namespace HellionChat.Ui.StyleEngine.Widgets; // The band above the conversation: which channel you are in on the left, where // 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 -// deliberate departure from the mockup, which asks for one pixel smaller than -// body text: one pixel would cost a whole additional font handle at full glyph -// range, because tab names are free user input and can be CJK. Tracking carries -// the same weight in every palette and costs nothing. +// Set apart by small caps with wide tracking, and by nothing else. The mockup +// draws this as a filled bar; the settings window used to draw its section +// headings that way too, and it was dropped in v1.11.0 for a reason worth +// repeating -- a fill reads fine against blue themes and vanishes against violet +// 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 // 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 theme = Plugin.Instance.ThemeRegistry.Active; - var surface = theme.Colors.Surface; - var surfaceAbgr = ColourUtil.RgbaToAbgr(surface); + // Contrast is still measured against the surface the header sits on, even + // 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 meta = MetaFace(fonts); @@ -152,27 +160,23 @@ internal static class ChannelHeader var dl = ImGui.GetWindowDrawList(); var bottomRight = origin + new Vector2(width, height); - dl.AddRectFilled(origin, bottomRight, surfaceAbgr); - dl.AddLine( - new Vector2(origin.X, bottomRight.Y - 1f), - new Vector2(bottomRight.X, bottomRight.Y - 1f), - ColourUtil.RgbaToAbgr(theme.Colors.Border), - MathF.Max(1f, scale) + + // 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 ); + dl.DrawEdgeTint(origin, bottomRight, ColourUtil.ApplyAlpha(accent, 0.10f), height); + var textY = origin.Y + MathF.Round(PadYRaw * scale); 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; // Centred against the band, not aligned to the text baseline: diff --git a/HellionChat/Ui/StyleEngine/Widgets/PopupRow.cs b/HellionChat/Ui/StyleEngine/Widgets/PopupRow.cs new file mode 100644 index 0000000..a16755d --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/PopupRow.cs @@ -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; + } +} diff --git a/HellionChat/Ui/StyleEngine/Widgets/Row.cs b/HellionChat/Ui/StyleEngine/Widgets/Row.cs index f80cd93..c2dba86 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/Row.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/Row.cs @@ -25,6 +25,12 @@ internal readonly record struct RowStyle public float AccentBarWidth { get; init; } = 2f; 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 @@ -51,13 +57,19 @@ internal static class Row // default is 0.85 -- an opaque fill per row would make the sidebar a // solid block inside a translucent window. 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) dl.AddRectFilled( origin, max, - ColourUtil.ApplyAlpha(state.SurfaceHoverAbgr, state.HoverAmount) + ColourUtil.ApplyAlpha(state.SurfaceHoverAbgr, state.HoverAmount), + 3f * Metrics.Scale ); 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 // line below max.Y -- and rows stack flush, so that half lands in the // 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 y = max.Y - thickness * 0.5f; - dl.AddLine( - new Vector2(origin.X, y), - new Vector2(max.X, y), - state.BorderAbgr, + var inset = 10f * Metrics.Scale; + dl.DrawFadeRule( + new Vector2(origin.X + inset, y), + size.X - inset, + ColourUtil.ApplyAlpha(state.BorderAbgr, 0.55f), thickness ); } diff --git a/HellionChat/Ui/Windows/InputBarLabWindow.cs b/HellionChat/Ui/Windows/InputBarLabWindow.cs new file mode 100644 index 0000000..17f8f52 --- /dev/null +++ b/HellionChat/Ui/Windows/InputBarLabWindow.cs @@ -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)); + } +} diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index b496c3e..bd07ea3 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -367,6 +367,25 @@ internal sealed class MainWindow : Window, IFocusableChatWindow _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(); using (ImRaii.Group()) diff --git a/HellionChat/Util/HoverMath.cs b/HellionChat/Util/HoverMath.cs index 7a2d624..c006eef 100644 --- a/HellionChat/Util/HoverMath.cs +++ b/HellionChat/Util/HoverMath.cs @@ -1,12 +1,19 @@ namespace HellionChat.Util; // 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): -// slower out than in is what makes a fade read as deliberate rather than laggy. +// can pin them without an ImGui frame. Slower out than in is what makes a fade +// 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 const float FadeInPerSecond = 14f; - internal const float FadeOutPerSecond = 8f; + internal const float FadeInPerSecond = 3f; + internal const float FadeOutPerSecond = 2f; // Below this an entry is indistinguishable from zero and can be dropped. 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; + + // 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); } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f352c18..f7e7651 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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) Typography. The message list was the last surface still drawn in ImGui defaults, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2fddac7..4e6b69f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 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. 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 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. -- **v1.14.0 onwards** — the sidebar moving from tab rows to channel rows, then the first-run - wizard and the input bar, which are the last two surfaces still drawn in ImGui defaults. +- **v1.14.0** — the input row: 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, 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. diff --git a/scripts/preflight.sh b/scripts/preflight.sh index 1eb618f..af60f19 100755 --- a/scripts/preflight.sh +++ b/scripts/preflight.sh @@ -18,6 +18,9 @@ echo "==> preflight: Block B — manifest shape" echo "==> preflight: Block C — changelog sync" ./scripts/verify-changelog-sync.sh +echo "==> preflight: Block G — colour channel audit" +./scripts/verify-colour-channels.sh + echo "==> preflight: Block D — plugin compile health" dotnet build HellionChat/HellionChat.csproj --configuration Release --nologo --verbosity quiet diff --git a/scripts/verify-colour-channels.sh b/scripts/verify-colour-channels.sh new file mode 100755 index 0000000..9bf6b92 --- /dev/null +++ b/scripts/verify-colour-channels.sh @@ -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"