From b221a6e418cd204570c7e23d43d14a892bd46674 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Thu, 28 May 2026 17:23:11 +0200 Subject: [PATCH] feat(input-bar): wire auto-translate tab picker + payload-replace on send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.7.0 component-layer refactor removed ChatLogWindow.cs (which housed the auto-translate popup) and dropped Ui/AutoCompleteInfo.cs without migrating the logic into the new InputBar component — v2.x spec §3 said "LÖSCHEN + Logik migrieren", but only the deletion happened. Result: Tab key did nothing in v1.7.1, and even manually typed tokens were never resolved into real auto-translate payloads on send. Root cause confirmed empirically via VN-1 diagnostic build (_logger.LogDebug in SlashCommandCallback proved CallbackCompletion fires on Tab once the flag is set). Following the diagnose-zuerst pattern established by Issue #3 to avoid the source-code-only hypothesis trap from Issue #2. Migration follows v1.5.6 ChatLogWindow.DrawAutoComplete + ChatTwo upstream AutoCompleteHandler patterns, but ported to v1.7.0 stil: - ImGuiInputTextFlags extended with CallbackCompletion (Tab trigger) and CallbackAlways (cursor restore via _activatePos analog v1.5.6 ActivatePos) - SlashCommandCallback now dispatches three branches: CallbackAlways (cursor restore), CallbackCompletion (Tab → word-boundary search via Encoding.UTF8.GetString on the byte span, char-offset DTO construction to avoid the byte-vs-char drift in v1.5.6's raw pointer arithmetic), CallbackEdit (existing slash-command help detection, now properly scoped) - 7 new private state fields (_autoCompleteInfo, _autoCompleteOpen, _autoCompleteList, _fixCursor, _autoCompleteSelection, _autoCompleteShouldScroll, _activatePos) - DrawAutoCompletePopup renders the picker at the end of Draw(): IsWindowAppearing seeds _fixCursor + focus, ListClipper-wrapper from Util/SearchSelector.cs (IDisposable, automatic Destroy) for the result list, Ctrl+0-9 quick-pick, Enter/Escape handling, char-splice commit (_pendingMessage = before + replacement + after) - AutoCompleteCallback handles popup-input-field fix-cursor seeding, Up/Down navigation with wrap-around, Tab cycle in the default case - TrySend now runs AutoTranslate.ReplaceWithPayload(ref bytes) and sends via ChatBox.SendMessageUnsafe(byte[]) with a manual 500-byte guard, because SendMessage(string) would route through SanitiseText which destroys the binary SeString macro bytes that ReplaceWithPayload emits - AutoCompleteInfo DTO added as sealed internal companion type at the end of InputBar.cs (15 LOC, exclusively consumed by InputBar); ToComplete is a mutable field rather than auto-property so it can be passed as ref to ImGui.InputTextWithHint without CS0206 Verified in-game (Flo): Tab on empty input opens picker with full list, "fire" + Tab filters correctly, Up/Down/Tab navigate, Enter commits , send resolves to real auto-translate payload in chat, Ctrl+0-9 quick-pick works, Escape closes without commit. dotnet build clean, dotnet csharpier check clean. Single minor plan-drift: scroll-to-selected uses ImGui.SetScrollY(selection * lineHeight) instead of SetScrollFromPosY(clipper.StartPosY) because the local ListClipper-wrapper does not expose StartPosY — same UX effect. --- HellionChat/Ui/Components/InputBar.cs | 308 +++++++++++++++++++++++++- 1 file changed, 302 insertions(+), 6 deletions(-) diff --git a/HellionChat/Ui/Components/InputBar.cs b/HellionChat/Ui/Components/InputBar.cs index 502c607..61334ca 100644 --- a/HellionChat/Ui/Components/InputBar.cs +++ b/HellionChat/Ui/Components/InputBar.cs @@ -2,9 +2,11 @@ using System.Numerics; using System.Text; using Dalamud.Bindings.ImGui; using Dalamud.Interface; +using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using HellionChat.Code; using HellionChat.GameFunctions; +using HellionChat.Resources; using HellionChat.Themes; using HellionChat.Ui; using HellionChat.Ui.StyleEngine; @@ -40,6 +42,21 @@ internal sealed class InputBar private bool _wasInputTextHovered; private bool? _isFocusedOverride; // Test-only; null = honour per-frame Draw() value. + // 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? _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( @@ -146,6 +163,10 @@ internal sealed class InputBar 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) @@ -231,7 +252,10 @@ internal sealed class InputBar "##hellion-input", ref _pendingMessage, BufferCapacity, - ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.CallbackEdit, + ImGuiInputTextFlags.EnterReturnsTrue + | ImGuiInputTextFlags.CallbackEdit + | ImGuiInputTextFlags.CallbackCompletion + | ImGuiInputTextFlags.CallbackAlways, SlashCommandCallback ) ) @@ -243,18 +267,55 @@ internal sealed class InputBar _wasInputTextHovered = ImGui.IsItemHovered(); } - // v1.5.6 character-level slash-detect: fires on every edit so CommandHelpWindow - // stays in sync with what the user is typing without a per-frame poll. + // 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 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 spaceIdx = text.IndexOf(' '); - var command = spaceIdx > 0 ? text[..spaceIdx] : text; + 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)) @@ -290,7 +351,21 @@ internal sealed class InputBar try { - ChatBox.SendMessage(toSend); + // 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) @@ -341,4 +416,225 @@ internal sealed class InputBar // 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; + + 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 = $""; + _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; + } }