feat(chat): the input row learns the window's own language

Five filled plates become two ghost buttons and a menu. The plates were ImGui
defaults sitting between a drawn pill and a drawn status bar -- three shape
languages in the one row a user works in, which is what the tester's screenshot
made obvious. The ghosts follow the sidebar's icon buttons: nothing at rest, a
held hover fill, the glyph lifting toward the accent.

Symbols stay outside the menu because they are used mid-sentence. Theme,
settings, screenshot and hide move in; the screenshot toggle was allowed in only
because its state moved to the status bar first, as an accent pill that shows
while the mode is on. A privacy 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 -- the Boutique.Inputs technique
from Character Select+, no rebuild of the widget itself. Focus is a two-pixel
accent rail on the left edge: the same mark the rows, the popups and the message
list already use for "this is where you are".

The channel pill gets the CS+ treatment, a faint white gradient and a one-pixel
light along the top edge. White over the fill rather than a second hue, so it
reads as depth in every palette.

And the popups that open from the row are made of PopupRow now -- the sidebar
row's shape, which is also exactly what CS+ draws for its own popup entries. The
channel picker was the reported case: a drawn pill opening a list of naked
selectables, the style breaking mid-click.

A hundred-odd pixels of button reserve go back to the input field.
This commit is contained in:
2026-08-19 18:30:15 +02:00
parent df6296024b
commit 6bdfecb1df
30 changed files with 418 additions and 94 deletions
+220 -90
View File
@@ -273,6 +273,18 @@ internal sealed class InputBar
StyleEngine.Widgets.Pill.Draw(origin, label, pillAbgr, textAbgr);
// The CS+ pill treatment: a faint white gradient from the top and a
// one-pixel light along the upper edge. Reads as depth in every palette
// because it is white over the fill, not a second hue.
var pillDl = ImGui.GetWindowDrawList();
var pillScale = StyleEngine.Metrics.Scale;
pillDl.DrawVerticalGradient(origin, origin + size, 0x28FFFFFFu, 0u);
pillDl.AddRectFilled(
new Vector2(origin.X + 6f * pillScale, origin.Y),
new Vector2(origin.X + size.X - 6f * pillScale, origin.Y + 1f * pillScale),
0x50FFFFFFu
);
// Hit area over the rendered pill so a click opens the channel
// picker. InvisibleButton both reserves the layout slot and gives
// the popup a stable anchor item.
@@ -283,7 +295,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 +308,27 @@ internal sealed class InputBar
return;
}
ImGui.Dummy(new Vector2(150f * StyleEngine.Metrics.Scale, 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 +350,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 +395,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 +688,175 @@ 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();
if (amount > 0f)
dl.AddRectFilled(
origin,
max,
ColourUtil.ApplyAlpha(ColourUtil.RgbaToAbgr(c.SurfaceHover), amount),
3f * scale
);
var accent = ColourUtil.RgbaToAbgr(c.Accent);
var tint = ColourUtil.RgbaToAbgr(ColourUtil.EnsureContrast(c.TextMuted, 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
{
ImGui.Dummy(new Vector2(160f * StyleEngine.Metrics.Scale, 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;