704 lines
27 KiB
C#
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;
|
|
}
|
|
}
|