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:
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Dalamud.NET.Sdk/15.0.0">
|
||||
<PropertyGroup>
|
||||
<!-- Independent versioning; see yaml changelog for upstream Chat 2 base -->
|
||||
<Version>1.13.0</Version>
|
||||
<Version>1.14.0</Version>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- Use lock file to pin exact versions -->
|
||||
|
||||
@@ -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<Ui.Windows.WidgetGalleryWindow>();
|
||||
#endif
|
||||
InputBarLab = _host.Services.GetRequiredService<Ui.Windows.InputBarLabWindow>();
|
||||
DebuggerWindow = _host.Services.GetRequiredService<DebuggerWindow>();
|
||||
FirstRunWizard = _host.Services.GetRequiredService<FirstRunWizard>();
|
||||
ChannelPopoutPool = _host.Services.GetRequiredService<Ui.Windows.ChannelPopoutPool>();
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -379,6 +379,12 @@ internal static class PluginHostFactory
|
||||
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>()
|
||||
));
|
||||
#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(
|
||||
sp.GetRequiredService<Plugin>(),
|
||||
sp.GetRequiredService<PayloadHandler>()
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1361,4 +1361,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Sessió no iniciada</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Més accions</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1360,4 +1360,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Nepřihlášen</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Další akce</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1360,4 +1360,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Ikke logget ind</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Flere handlinger</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1355,4 +1355,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Nicht eingeloggt</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Weitere Aktionen</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1360,4 +1360,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Εκτός σύνδεσης</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Περισσότερες ενέργειες</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1361,4 +1361,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Sesión no iniciada</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Más acciones</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1360,4 +1360,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Ei kirjautuneena</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Lisää toimintoja</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1361,4 +1361,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Non connecté</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Plus d'actions</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1360,4 +1360,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Nincs bejelentkezve</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>További műveletek</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1361,4 +1361,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Non connesso</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Altre azioni</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1361,4 +1361,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>未ログイン</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>その他の操作</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1361,4 +1361,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>로그인되지 않음</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>추가 작업</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1360,4 +1360,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Ikke innlogget</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Flere handlinger</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1361,4 +1361,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Niet ingelogd</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Meer acties</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1360,4 +1360,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Niezalogowany</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Więcej działań</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1361,4 +1361,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Não conectado</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Mais ações</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1360,4 +1360,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Sem sessão iniciada</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Mais ações</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1372,4 +1372,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Not logged in</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>More actions</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1361,4 +1361,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Neconectat</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Mai multe acțiuni</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1361,4 +1361,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Вход не выполнен</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Другие действия</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1361,4 +1361,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Inte inloggad</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Fler åtgärder</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1360,4 +1360,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Oturum açılmadı</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Diğer işlemler</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1360,4 +1360,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>Вхід не виконано</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>Інші дії</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1361,4 +1361,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>未登录</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>更多操作</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1361,4 +1361,7 @@
|
||||
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
|
||||
<value>未登入</value>
|
||||
</data>
|
||||
<data name="InputBar_More_Tooltip" xml:space="preserve">
|
||||
<value>更多操作</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -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()))
|
||||
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 (DrawGhostButton("##qb-more", FontAwesomeIcon.EllipsisH))
|
||||
ImGui.OpenPopup("##hellion-more-menu");
|
||||
if (ImGui.IsItemHovered())
|
||||
tooltip = HellionStrings.InputBar_Settings_Tooltip;
|
||||
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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,18 +160,7 @@ 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)
|
||||
);
|
||||
|
||||
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
|
||||
@@ -173,6 +170,13 @@ internal static class ChannelHeader
|
||||
surfaceAbgr,
|
||||
4.5f
|
||||
);
|
||||
|
||||
dl.DrawEdgeTint(origin, bottomRight, ColourUtil.ApplyAlpha(accent, 0.10f), height);
|
||||
|
||||
var textY = origin.Y + MathF.Round(PadYRaw * scale);
|
||||
|
||||
if (plan.ShowName)
|
||||
{
|
||||
var x = origin.X + inset;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+13
-3
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Executable
+60
@@ -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"
|
||||
Reference in New Issue
Block a user