Files
HellionChat/HellionChat/Ui/Components/InputBar.cs
T

704 lines
27 KiB
C#

using System.Numerics;
using System.Text;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using HellionChat._Helpers;
using HellionChat.Code;
using HellionChat.GameFunctions;
using HellionChat.Resources;
using HellionChat.Themes;
using HellionChat.Ui;
using HellionChat.Ui.StyleEngine;
using HellionChat.Util;
using Microsoft.Extensions.Logging;
namespace HellionChat.Ui.Components;
// Bottom input row: channel pill, text field, quick buttons. Channel pill
// recolours by tab type — cyan accent for a normal channel, ember accent
// for a tell. Enter on the input field sends through ChatBox; messages
// that don't already start with a slash get the active channel's prefix
// prepended so a typed line in /fc reaches free-company chat instead of
// the current game-side channel.
internal sealed class InputBar
{
public const float Height = 32f;
private const float PillHeight = 22f;
private const float PillPaddingX = 8f;
private const int BufferCapacity = 500;
private const float QuickButtonsReserve = 130f;
private readonly SymbolPicker _symbolPicker;
private readonly FontManager _fonts;
private readonly ThemeRegistry _themes;
private readonly TokenResolver _resolver;
private readonly ILogger<InputBar> _logger;
private readonly Action _onOpenSettings;
private readonly CommandHelpWindow _commandHelpWindow;
private string _pendingMessage = string.Empty;
private bool _isFocused;
private bool _wasInputTextHovered;
private bool? _isFocusedOverride; // Test-only; null = honour per-frame Draw() value.
// UI-11 plugin-disclosure arm-and-hold: holds the buffer that armed the
// disclosure warning. null = not armed. Compared by value so an edit
// re-arms and a resend on the identical buffer goes through. 1.5.6 parity
// (ChatInputBar 1d3b429:27).
private string? _disclosureArmedBuffer;
// Auto-translate popup state — lives here because the popup lifecycle is
// tightly coupled to the input callback and the pending message buffer.
private const string AutoCompleteId = "##hellion-at-complete";
private AutoCompleteInfo? _autoCompleteInfo;
private bool _autoCompleteOpen;
private List<AutoTranslateEntry>? _autoCompleteList;
private bool _fixCursor;
private int _autoCompleteSelection;
private bool _autoCompleteShouldScroll;
// Cursor restore position after popup commit; -1 = no pending restore.
// The main InputText sees the write inside its CallbackAlways branch on the
// next frame because ImGui only honours data.CursorPos writes from a callback.
private int _activatePos = -1;
public bool Activate;
public InputBar(
SymbolPicker symbolPicker,
FontManager fonts,
ThemeRegistry themes,
TokenResolver resolver,
ILogger<InputBar> logger,
Action onOpenSettings,
CommandHelpWindow commandHelpWindow
)
{
_symbolPicker = symbolPicker;
_fonts = fonts;
_themes = themes;
_resolver = resolver;
_logger = logger;
_onOpenSettings = onOpenSettings;
_commandHelpWindow = commandHelpWindow;
}
public string PendingMessage => _pendingMessage;
public int PendingLength => _pendingMessage.Length;
// IsFocused respects the test override first so a SelfTest can pin focus
// state without racing against per-frame ImGui.IsItemFocused() in Draw().
// Note: when MainWindow is closed, DrawInputField never runs, so
// _isFocused keeps the last value written by the previous draw pass.
// The consumer that actually pushes this state across the IPC boundary
// (TypingIpc.BuildState, see F3 Step 2) gates on Plugin.MainWindow.IsOpen
// itself, so the stale backing-field never leaks to subscribers. Mirroring
// the gate here would require an extra Plugin-backref in InputBar that the
// rest of the component doesn't need.
public bool IsFocused => _isFocusedOverride ?? _isFocused;
// Sampled in DrawInputField() right after ImGui.InputText so the value
// reflects the text widget, not a later QuickButton item.
public bool WasInputTextHovered => _wasInputTextHovered;
public void ClearBuffer() => _pendingMessage = string.Empty;
// BufferCapacity is an ImGui UX limit, not a protocol constraint. We
// LogWarning + truncate/drop (matching v1.5.6's silent-overwrite semantics)
// so overflow is observable via /xllog without forcing try/catch at call-sites.
public void SetPendingMessage(string value)
{
if (value is null)
throw new ArgumentNullException(nameof(value));
if (value.Length > BufferCapacity)
{
_logger.LogWarning(
"SetPendingMessage: value of length {Length} exceeds BufferCapacity ({Capacity}); truncating.",
value.Length,
BufferCapacity
);
_pendingMessage = value[..BufferCapacity];
}
else
{
_pendingMessage = value;
}
}
// Null treated as empty here (matches IsNullOrEmpty guard); contrast with SetPendingMessage which throws to surface PayloadHandler call-site bugs early.
public void AppendPending(string suffix)
{
if (string.IsNullOrEmpty(suffix))
return;
if (_pendingMessage.Length + suffix.Length > BufferCapacity)
{
_logger.LogWarning(
"AppendPending: appending {SuffixLength} chars would exceed BufferCapacity ({Capacity}); dropping suffix.",
suffix.Length,
BufferCapacity
);
return;
}
_pendingMessage += suffix;
}
public void Draw(Tab? activeTab)
{
if (!_fonts.FontsReady)
{
ImGui.Dummy(new Vector2(0, Height));
return;
}
var theme = _themes.Active;
var isTell = activeTab is { IsTempTab: true, TellTarget: { } target } && target.IsSet();
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);
DrawChannelPill(activeTab, isTell, pillAbgr, pillTextAbgr);
ImGui.SameLine();
DrawInputField(activeTab);
ImGui.SameLine();
DrawQuickButtons();
// UI-11: yellow inline warning while a plugin-only-glyph message is
// armed-and-held (buffer unchanged since it armed). Renders on its own
// line below the input row. 1.5.6 parity (ChatInputBar 1d3b429:93-103).
if (
Plugin.Config.NotifyPluginDisclosure
&& _disclosureArmedBuffer is not null
&& _pendingMessage == _disclosureArmedBuffer
)
{
ImGui.TextColored(
ImGuiColors.DalamudYellow,
HellionStrings.ChatInput_PluginDisclosure_Warning
);
}
// SymbolPicker popup is rendered last so it can splice its fragment
// straight into the pending buffer.
var inserted = _symbolPicker.DrawAndConsume();
if (inserted is not null && _pendingMessage.Length + inserted.Length <= BufferCapacity)
_pendingMessage += inserted;
// Auto-translate popup runs after all other popups so the OpenPopup
// anchor lands on the InputText item we just drew.
DrawAutoCompletePopup();
}
private static string ResolvePillLabel(Tab? tab, bool isTell)
{
if (isTell && tab?.TellTarget is { } t && t.IsSet())
return $"→ {t.Name}";
// CurrentChannel carries the runtime input state; Tab.Channel is the
// saved default and is null for most non-FC tabs, which produced
// the "—" placeholder users saw.
var current = tab?.CurrentChannel?.Channel ?? InputChannel.Invalid;
if (current != InputChannel.Invalid)
return current.ToChatType().Name();
if (tab?.Channel is { } saved)
return saved.ToChatType().Name();
return "—";
}
private void DrawChannelPill(Tab? tab, bool isTell, uint pillAbgr, uint textAbgr)
{
var label = ResolvePillLabel(tab, isTell);
var labelSize = ImGui.CalcTextSize(label);
var width = labelSize.X + PillPaddingX * 2;
var origin = ImGui.GetCursorScreenPos();
var dl = ImGui.GetWindowDrawList();
var max = origin + new Vector2(width, PillHeight);
dl.AddRectFilled(origin, max, pillAbgr, 6f);
dl.AddText(origin + new Vector2(PillPaddingX, 3f), textAbgr, label);
// 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.
ImGui.InvisibleButton("##hellion-pill", new Vector2(width, PillHeight));
if (ImGui.IsItemClicked() && tab is not null)
ImGui.OpenPopup("##hellion-channel-picker");
DrawChannelPickerPopup(tab);
}
private static void DrawChannelPickerPopup(Tab? tab)
{
if (!ImGui.BeginPopup("##hellion-channel-picker"))
return;
try
{
if (tab is null || tab.SelectedChannels.Count == 0)
{
ImGui.TextDisabled("No channels");
return;
}
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))
tab.CurrentChannel.SetChannel(input);
}
}
finally
{
ImGui.EndPopup();
}
}
private void DrawInputField(Tab? activeTab)
{
if (Activate)
{
ImGui.SetKeyboardFocusHere();
Activate = false;
}
ImGui.SetNextItemWidth(-QuickButtonsReserve);
if (
ImGui.InputText(
"##hellion-input",
ref _pendingMessage,
BufferCapacity,
ImGuiInputTextFlags.EnterReturnsTrue
| ImGuiInputTextFlags.CallbackEdit
| ImGuiInputTextFlags.CallbackCompletion
| ImGuiInputTextFlags.CallbackAlways,
SlashCommandCallback
)
)
{
_commandHelpWindow.IsOpen = false;
TrySend(activeTab);
}
_isFocused = ImGui.IsItemFocused();
_wasInputTextHovered = ImGui.IsItemHovered();
}
// Dispatches across three ImGui callback events: CallbackAlways (cursor
// restore after popup commit), CallbackCompletion (Tab opens the auto-
// translate picker), CallbackEdit (slash-command help window sync).
private int SlashCommandCallback(scoped ref ImGuiInputTextCallbackData data)
{
// Cursor restore after popup commit. _activatePos is set in
// DrawAutoCompletePopup to "behind the inserted <at:...> token";
// we replay it on the next CallbackAlways frame because ImGui only
// honours data.CursorPos writes from inside a callback.
if (data.EventFlag == ImGuiInputTextFlags.CallbackAlways)
{
if (_activatePos != -1)
{
data.CursorPos = _activatePos;
data.SelectionStart = data.SelectionEnd = _activatePos;
_activatePos = -1;
}
return 0;
}
if (data.EventFlag == ImGuiInputTextFlags.CallbackCompletion)
{
// CursorPos is a BYTE offset into the UTF-8 buffer. We decode the
// prefix up to the cursor as a managed string so every offset in
// AutoCompleteInfo is a CHAR offset — _pendingMessage is a managed
// string and gets spliced via char-indices in DrawAutoCompletePopup.
// Mixing byte- and char-offsets crashes on multi-byte UTF-8 (CJK,
// emoji) before the cursor.
var prefix = Encoding.UTF8.GetString(data.BufTextSpan[..data.CursorPos]);
var spaceIdx = prefix.LastIndexOf(' ');
var wordStart = spaceIdx < 0 ? 0 : spaceIdx + 1;
var word = prefix[wordStart..];
_autoCompleteInfo = new AutoCompleteInfo(word, wordStart, prefix.Length);
_autoCompleteOpen = true;
_autoCompleteSelection = 0;
return 0;
}
// CallbackEdit (or any remaining event): v1.5.6 character-level slash
// detection keeps CommandHelpWindow in sync with what the user is
// typing without a per-frame poll.
_commandHelpWindow.IsOpen = false;
var text = Encoding.UTF8.GetString(data.BufTextSpan);
if (!text.StartsWith('/'))
return 0;
var slashSpaceIdx = text.IndexOf(' ');
var command = slashSpaceIdx > 0 ? text[..slashSpaceIdx] : text;
// Keys in CommandManager.Commands include the leading slash.
if (AllCommands.TryGetValue(command, out var textCommand))
_commandHelpWindow.UpdateContent(textCommand.Description);
else if (
Plugin.CommandManager.Commands.TryGetValue(command, out var info) && info.ShowInHelp
)
_commandHelpWindow.UpdateContent(info.HelpMessage);
return 0;
}
private void TrySend(Tab? activeTab)
{
var text = _pendingMessage.Trim();
if (string.IsNullOrEmpty(text))
return;
// UI-11: plugin-disclosure arm-and-hold. Arm + scan on the RAW
// _pendingMessage (NOT the trimmed `text`) so the Draw warning gate
// (_pendingMessage == _disclosureArmedBuffer) matches byte-for-byte even
// when the buffer has leading/trailing whitespace. 1.5.6 armed/held/
// warned on the raw buffer and only trimmed at SendChatBox; storing the
// trimmed value here would silently kill the warning for a padded buffer
// (the Draw gate compares the untrimmed _pendingMessage). Runs BEFORE the
// channel prefix + AutoTranslate.ReplaceWithPayload (the resolved <at:>
// macro carries its own non-ASCII bytes and would false-positive;
// whitespace is never a PUA codepoint, so scanning the raw buffer is
// equivalent for detection). First Enter on a buffer with a plugin-only
// PUA glyph arms + HOLDS (returns without sending, buffer kept); a second
// Enter on the same unchanged buffer sends; editing re-checks. 1.5.6
// parity (ChatInputBar.SubmitCompact 1d3b429:108-118).
if (
Plugin.Config.NotifyPluginDisclosure
&& _disclosureArmedBuffer != _pendingMessage
&& PluginDisclosureScanner.ContainsPrivateUseGlyph(_pendingMessage)
)
{
_disclosureArmedBuffer = _pendingMessage;
return;
}
_disclosureArmedBuffer = null;
// Slash commands route through verbatim — the game's chat parser
// handles /tell, /fc, /hellion etc. on its own. Other text gets
// the active channel's prefix so the line lands on the channel
// the user is reading instead of the game-side default.
string toSend;
if (text.StartsWith('/'))
{
toSend = text;
}
else
{
var current = activeTab?.CurrentChannel?.Channel ?? InputChannel.Invalid;
toSend = current == InputChannel.Invalid ? text : $"{current.Prefix()} {text}";
}
try
{
// AutoTranslate produces binary SeString macro bytes; SendMessage(string)
// would run SanitiseText over them and destroy the payload encoding.
// SendMessageUnsafe bypasses ValidateMessage entirely, so we mirror its
// 500-byte guard manually.
var bytes = Encoding.UTF8.GetBytes(toSend);
AutoTranslate.ReplaceWithPayload(ref bytes);
if (bytes.Length > 500)
{
_logger.LogWarning(
"TrySend dropped: message exceeds 500 bytes ({Length}) after AT-resolve.",
bytes.Length
);
return;
}
ChatBox.SendMessageUnsafe(bytes);
_pendingMessage = string.Empty;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send chat message ({Length} chars)", toSend.Length);
}
}
private void DrawQuickButtons()
{
using (_fonts.FontAwesome.Push())
{
if (ImGui.Button(FontAwesomeIcon.SmileBeam.ToIconString()))
_symbolPicker.OpenPopup();
if (ImGui.IsItemHovered())
{
using (ImRaii.DefaultFont())
ImGui.SetTooltip("Insert symbol");
}
ImGui.SameLine();
if (ImGui.Button(FontAwesomeIcon.Cog.ToIconString()))
{
_onOpenSettings();
}
if (ImGui.IsItemHovered())
{
using (ImRaii.DefaultFont())
ImGui.SetTooltip("Settings");
}
ImGui.SameLine();
var hidden = Plugin.Config.HideChat;
var visIcon = hidden ? FontAwesomeIcon.EyeSlash : FontAwesomeIcon.Eye;
if (ImGui.Button(visIcon.ToIconString()))
Plugin.Config.HideChat = !hidden;
if (ImGui.IsItemHovered())
{
using (ImRaii.DefaultFont())
ImGui.SetTooltip(hidden ? "Unhide chat" : "Hide chat");
}
}
}
// Test-only hook; do not call from production code.
internal void TestSetPendingMessageForSelfTest(string value) => _pendingMessage = value;
// Test-only hook; do not call from production code. Pass null to release the
// override and let Draw()'s ImGui.IsItemFocused() result take over again.
internal void TestSetFocusedForSelfTest(bool? value) => _isFocusedOverride = value;
// Test-only hook; do not call from production code. Drives the REAL TrySend
// arm path: with NotifyPluginDisclosure on and a PUA glyph in the buffer the
// first call arms and HOLDS (no send). Returns whether the buffer is armed.
// The caller asserts PendingMessage is unchanged (held) so a regressed wiring
// that fell through to ChatBox.SendMessageUnsafe is caught.
internal bool TestTryArmDisclosureForSelfTest(Tab? activeTab)
{
TrySend(activeTab);
return _disclosureArmedBuffer is not null;
}
// Test-only hook; do not call from production code. Clears the armed buffer
// so a SelfTest leaves no residual arm state.
internal void TestResetDisclosureForSelfTest() => _disclosureArmedBuffer = null;
private void DrawAutoCompletePopup()
{
if (_autoCompleteInfo == null)
return;
// Match cache: rebuilt on every search-field edit below. Lazy init here
// covers the first frame after Tab opens the popup.
_autoCompleteList ??= AutoTranslate.Matching(
_autoCompleteInfo.ToComplete,
Plugin.Config.SortAutoTranslate
);
if (_autoCompleteOpen)
{
ImGui.OpenPopup(AutoCompleteId);
_autoCompleteOpen = false;
}
ImGui.SetNextWindowSize(new Vector2(400, 300) * ImGuiHelpers.GlobalScale);
using var popup = ImRaii.Popup(AutoCompleteId);
if (!popup.Success)
{
// Popup just closed (Escape, click-outside, or commit). Schedule the
// main InputText to re-focus and restore the cursor to the end of
// the original word so the user can keep typing without manual repositioning.
if (_activatePos == -1)
_activatePos = _autoCompleteInfo.EndPos;
_autoCompleteInfo = null;
_autoCompleteList = null;
Activate = true;
return;
}
ImGui.SetNextItemWidth(-1);
if (
ImGui.InputTextWithHint(
"##hellion-at-search",
Language.AutoTranslate_Search_Hint,
ref _autoCompleteInfo.ToComplete,
256,
ImGuiInputTextFlags.CallbackAlways | ImGuiInputTextFlags.CallbackHistory,
AutoCompleteCallback
)
)
{
// User typed in the search field: refresh matches and reset selection.
_autoCompleteList = AutoTranslate.Matching(
_autoCompleteInfo.ToComplete,
Plugin.Config.SortAutoTranslate
);
_autoCompleteSelection = 0;
_autoCompleteShouldScroll = true;
}
// Ctrl+0..9 jump-pick: 1..9 maps to index 0..8, 0 maps to index 9 (top-row layout).
var selected = -1;
if (ImGui.IsItemActive() && ImGui.GetIO().KeyCtrl)
{
for (var i = 0; i < 10 && i < _autoCompleteList.Count; i++)
{
var num = (i + 1) % 10;
var key = ImGuiKey.Key0 + num;
var key2 = ImGuiKey.Keypad0 + num;
if (ImGui.IsKeyDown(key) || ImGui.IsKeyDown(key2))
selected = i;
}
}
if (ImGui.IsItemDeactivated())
{
if (ImGui.IsKeyDown(ImGuiKey.Escape))
{
ImGui.CloseCurrentPopup();
return;
}
var enter = ImGui.IsKeyDown(ImGuiKey.Enter) || ImGui.IsKeyDown(ImGuiKey.KeypadEnter);
if (_autoCompleteList.Count > 0 && enter)
selected = _autoCompleteSelection;
}
// First-frame focus: hand keyboard focus back to the search field and
// ask AutoCompleteCallback to drop the caret at the end of the prefix.
if (ImGui.IsWindowAppearing())
{
_fixCursor = true;
ImGui.SetKeyboardFocusHere(-1);
}
using var child = ImRaii.Child(
"##hellion-at-list",
Vector2.Zero,
false,
ImGuiWindowFlags.HorizontalScrollbar
);
if (!child.Success)
return;
// ListClipper wrapper (Util/SearchSelector.cs) is IDisposable, so the
// using-statement frees the unmanaged ImGuiListClipper for us — without
// it the block would leak per render frame.
using var clipper = new ListClipper(_autoCompleteList.Count);
foreach (var i in clipper.Rows)
{
var entry = _autoCompleteList[i];
var highlight = _autoCompleteSelection == i;
var clicked =
ImGui.Selectable($"{entry.Text}##{entry.Group}/{entry.Row}", highlight)
|| selected == i;
if (i < 10)
{
var button = (i + 1) % 10;
var text = string.Format(Language.AutoTranslate_Completion_Key, button);
var size = ImGui.CalcTextSize(text);
ImGui.SameLine(ImGui.GetContentRegionAvail().X - size.X);
using (
ImRaii.PushColor(
ImGuiCol.Text,
ImGui.GetStyle().Colors[(int)ImGuiCol.TextDisabled]
)
)
ImGui.TextUnformatted(text);
}
if (!clicked)
continue;
// StartPos/EndPos are CHAR offsets — see SlashCommandCallback's
// CallbackCompletion branch for the byte→char conversion rationale.
var start = _autoCompleteInfo.StartPos;
var end = _autoCompleteInfo.EndPos;
var replacement = $"<at:{entry.Group},{entry.Row}>";
_pendingMessage = _pendingMessage[..start] + replacement + _pendingMessage[end..];
ImGui.CloseCurrentPopup();
Activate = true;
_activatePos = start + replacement.Length;
}
if (!_autoCompleteShouldScroll)
return;
_autoCompleteShouldScroll = false;
var selectedPos =
clipper.DisplayEnd > 0
? _autoCompleteSelection * ImGui.GetTextLineHeightWithSpacing()
: 0f;
ImGui.SetScrollY(selectedPos);
}
private int AutoCompleteCallback(scoped ref ImGuiInputTextCallbackData data)
{
// Runs every frame because the search field sets CallbackAlways. First
// frame after IsWindowAppearing flips _fixCursor on so the caret lands
// at the end of the pre-filled prefix instead of position 0.
if (data.EventFlag == ImGuiInputTextFlags.CallbackAlways)
{
if (_fixCursor && _autoCompleteInfo != null)
{
data.CursorPos = _autoCompleteInfo.ToComplete.Length;
data.SelectionStart = data.SelectionEnd = data.CursorPos;
_fixCursor = false;
}
}
if (_autoCompleteList == null || _autoCompleteList.Count == 0)
return 0;
switch (data.EventKey)
{
case ImGuiKey.UpArrow:
_autoCompleteSelection =
_autoCompleteSelection == 0
? _autoCompleteList.Count - 1
: _autoCompleteSelection - 1;
_autoCompleteShouldScroll = true;
return 1;
case ImGuiKey.DownArrow:
_autoCompleteSelection =
_autoCompleteSelection == _autoCompleteList.Count - 1
? 0
: _autoCompleteSelection + 1;
_autoCompleteShouldScroll = true;
return 1;
default:
// Tab inside the popup cycles forward — CallbackHistory does
// not fire for Tab, so we sniff it via IsKeyPressed inside
// the CallbackAlways pass.
if (ImGui.IsKeyPressed(ImGuiKey.Tab))
{
_autoCompleteSelection = (_autoCompleteSelection + 1) % _autoCompleteList.Count;
_autoCompleteShouldScroll = true;
return 1;
}
break;
}
return 0;
}
}
// DTO for an in-flight auto-translate completion. Lives as a companion type
// in this file because it is only consumed by InputBar (see v1.7.1 Fix #4 plan §2.4).
internal sealed class AutoCompleteInfo
{
// ToComplete MUST be a mutable field (not an auto-property), because the
// popup's ImGui.InputTextWithHint(... ref _autoCompleteInfo.ToComplete, ...)
// call takes it as a ref-parameter. Auto-properties cannot be passed as
// ref-targets — would produce CS0206 at compile time.
internal string ToComplete;
internal int StartPos { get; }
internal int EndPos { get; }
internal AutoCompleteInfo(string toComplete, int startPos, int endPos)
{
ToComplete = toComplete;
StartPos = startPos;
EndPos = endPos;
}
}