feat(input-bar): wire auto-translate tab picker + payload-replace on send

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
<at:group,key> 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 <at:group,key>, 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.
This commit is contained in:
2026-05-28 17:23:11 +02:00
parent 0319636fc5
commit b221a6e418
+302 -6
View File
@@ -2,9 +2,11 @@ using System.Numerics;
using System.Text; using System.Text;
using Dalamud.Bindings.ImGui; using Dalamud.Bindings.ImGui;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Utility.Raii;
using HellionChat.Code; using HellionChat.Code;
using HellionChat.GameFunctions; using HellionChat.GameFunctions;
using HellionChat.Resources;
using HellionChat.Themes; using HellionChat.Themes;
using HellionChat.Ui; using HellionChat.Ui;
using HellionChat.Ui.StyleEngine; using HellionChat.Ui.StyleEngine;
@@ -40,6 +42,21 @@ internal sealed class InputBar
private bool _wasInputTextHovered; private bool _wasInputTextHovered;
private bool? _isFocusedOverride; // Test-only; null = honour per-frame Draw() value. 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<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 bool Activate;
public InputBar( public InputBar(
@@ -146,6 +163,10 @@ internal sealed class InputBar
var inserted = _symbolPicker.DrawAndConsume(); var inserted = _symbolPicker.DrawAndConsume();
if (inserted is not null && _pendingMessage.Length + inserted.Length <= BufferCapacity) if (inserted is not null && _pendingMessage.Length + inserted.Length <= BufferCapacity)
_pendingMessage += inserted; _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) private static string ResolvePillLabel(Tab? tab, bool isTell)
@@ -231,7 +252,10 @@ internal sealed class InputBar
"##hellion-input", "##hellion-input",
ref _pendingMessage, ref _pendingMessage,
BufferCapacity, BufferCapacity,
ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.CallbackEdit, ImGuiInputTextFlags.EnterReturnsTrue
| ImGuiInputTextFlags.CallbackEdit
| ImGuiInputTextFlags.CallbackCompletion
| ImGuiInputTextFlags.CallbackAlways,
SlashCommandCallback SlashCommandCallback
) )
) )
@@ -243,18 +267,55 @@ internal sealed class InputBar
_wasInputTextHovered = ImGui.IsItemHovered(); _wasInputTextHovered = ImGui.IsItemHovered();
} }
// v1.5.6 character-level slash-detect: fires on every edit so CommandHelpWindow // Dispatches across three ImGui callback events: CallbackAlways (cursor
// stays in sync with what the user is typing without a per-frame poll. // restore after popup commit), CallbackCompletion (Tab opens the auto-
// translate picker), CallbackEdit (slash-command help window sync).
private int SlashCommandCallback(scoped ref ImGuiInputTextCallbackData data) 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; _commandHelpWindow.IsOpen = false;
var text = Encoding.UTF8.GetString(data.BufTextSpan); var text = Encoding.UTF8.GetString(data.BufTextSpan);
if (!text.StartsWith('/')) if (!text.StartsWith('/'))
return 0; return 0;
var spaceIdx = text.IndexOf(' '); var slashSpaceIdx = text.IndexOf(' ');
var command = spaceIdx > 0 ? text[..spaceIdx] : text; var command = slashSpaceIdx > 0 ? text[..slashSpaceIdx] : text;
// Keys in CommandManager.Commands include the leading slash. // Keys in CommandManager.Commands include the leading slash.
if (AllCommands.TryGetValue(command, out var textCommand)) if (AllCommands.TryGetValue(command, out var textCommand))
@@ -290,7 +351,21 @@ internal sealed class InputBar
try 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; _pendingMessage = string.Empty;
} }
catch (Exception ex) 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 // 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. // override and let Draw()'s ImGui.IsItemFocused() result take over again.
internal void TestSetFocusedForSelfTest(bool? value) => _isFocusedOverride = value; 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 = $"<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;
}
} }