Screenshot mode is a persisted setting, but neither of the two toggles saved the config. Turning it on only stuck when some unrelated save happened to run afterwards -- and once it was stored, turning it off never reached the file at all, so it came back on with every plugin load. Both toggles save now. An install currently stuck on it needs one click.
1207 lines
48 KiB
C#
1207 lines
48 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 FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
|
using HellionChat._Helpers;
|
|
using HellionChat.Code;
|
|
using HellionChat.GameFunctions;
|
|
using HellionChat.GameFunctions.Types;
|
|
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
|
|
{
|
|
// Scaled: MainWindow and ChannelPopoutWindow both reserve against this, so
|
|
// they follow automatically. The pill's own metrics live in PillStyle now.
|
|
public static float Height => StyleEngine.Metrics.InputBarHeight;
|
|
private const int BufferCapacity = 500;
|
|
|
|
// Scaled: the buttons themselves grow with the font, so a fixed reserve
|
|
// stops fitting them at 150%.
|
|
private static float QuickButtonsReserve => StyleEngine.Metrics.InputQuickButtonsReserve;
|
|
|
|
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;
|
|
|
|
// Null in pop-out windows: the theme/tab quick-picker only belongs in the
|
|
// main window (1.5.4 had no pop-outs, and a tab jump from a channel-bound
|
|
// pop-out would be confusing). The main window's InputBar gets the instance.
|
|
private readonly ThemeQuickPicker? _themeQuickPicker;
|
|
|
|
// Null in pop-outs (those have their own close button). Hides the main window.
|
|
private readonly Action? _onHideWindow;
|
|
|
|
// Set only for pop-outs, and after construction: the window does not exist
|
|
// yet while its own input row is being built, and routing it through the DI
|
|
// graph would close a factory-callsite cycle MS.DI cannot see. Its presence
|
|
// is what puts the pop-in button in the row, so the main window cannot grow
|
|
// one by accident.
|
|
internal Action? OnPopIn { get; set; }
|
|
|
|
private string _pendingMessage = string.Empty;
|
|
private bool _isFocused;
|
|
private bool _wasInputTextHovered;
|
|
private bool? _isFocusedOverride; // Test-only; null = honour per-frame Draw() value.
|
|
|
|
// 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,
|
|
ThemeQuickPicker? themeQuickPicker = null,
|
|
Action? onHideWindow = null
|
|
)
|
|
{
|
|
_symbolPicker = symbolPicker;
|
|
_fonts = fonts;
|
|
_themes = themes;
|
|
_resolver = resolver;
|
|
_logger = logger;
|
|
_onOpenSettings = onOpenSettings;
|
|
_commandHelpWindow = commandHelpWindow;
|
|
_themeQuickPicker = themeQuickPicker;
|
|
_onHideWindow = onHideWindow;
|
|
}
|
|
|
|
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) 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);
|
|
// Measured against the pill fill it lands on, not taken from the theme
|
|
// raw -- an accent fill can swallow the theme's text colour whole.
|
|
var pillTextAbgr = ColourUtil.EnsureContrast(
|
|
ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary),
|
|
pillAbgr,
|
|
4.5f
|
|
);
|
|
|
|
DrawChannelPill(activeTab, isTell, pillAbgr, pillTextAbgr);
|
|
ImGui.SameLine();
|
|
DrawInputField(activeTab);
|
|
ImGui.SameLine();
|
|
DrawQuickButtons();
|
|
|
|
// 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;
|
|
|
|
// Theme/tab quick-picker popup (main window only; null in pop-outs).
|
|
_themeQuickPicker?.Draw();
|
|
|
|
// 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;
|
|
|
|
// Privacy transparency: a game-side tell or reply writes {Channel=Tell,
|
|
// TellTarget} onto the active tab's CurrentChannel even on a NORMAL tab
|
|
// (Tab.TellTarget stays empty, so the isTell branch above is false). In
|
|
// that state BuildOutgoing's leg2/leg3 would route the next typed line as
|
|
// /tell to that partner — but the bare "Tell" label hid WHO. Mirror the
|
|
// exact leg2/leg3 source (current==Tell, TempTellTarget ?? TellTarget) AND
|
|
// the COMP-1 world-resolve gate, so the pill names the partner ONLY when a
|
|
// /tell would actually be built; an unresolvable world sends no /tell and
|
|
// falls through to the plain label below. Read-only — no routing effect.
|
|
// 1.5.6 showed the partner name here; this restores that transparency.
|
|
if (current == InputChannel.Tell)
|
|
{
|
|
// Mirror BuildOutgoing's exact target chain for the tell channel (leg1
|
|
// Tab.TellTarget first, then leg2/leg3 CurrentChannel) so the pill names
|
|
// precisely who the next line would reach — no drift between shown and sent.
|
|
var ccTarget =
|
|
tab is not null && tab.TellTarget.IsSet()
|
|
? tab.TellTarget
|
|
: tab?.CurrentChannel?.TempTellTarget ?? tab?.CurrentChannel?.TellTarget;
|
|
if (ccTarget is not null && ccTarget.IsSet())
|
|
{
|
|
var world = ccTarget.ToWorldString();
|
|
if (!string.IsNullOrEmpty(world))
|
|
return $"→ {ccTarget.Name}@{world}";
|
|
}
|
|
}
|
|
|
|
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 size = StyleEngine.Widgets.Pill.CalcSize(label, withDot: false);
|
|
var origin = ImGui.GetCursorScreenPos();
|
|
|
|
// Chamfered, not rounded: the shape the segmented control already uses
|
|
// for its selected segment, with the CS+ white gradient for depth. The
|
|
// slip corner is the one piece of the Boutique geometry the plugin had
|
|
// built and barely used.
|
|
var pillDl = ImGui.GetWindowDrawList();
|
|
var pillScale = StyleEngine.Metrics.Scale;
|
|
var pillMax = origin + size;
|
|
pillDl.DrawSlipPolygon(origin, pillMax, ColourUtil.RgbaToAbgr(pillAbgr), 6f * pillScale);
|
|
pillDl.DrawVerticalGradient(origin, pillMax, 0x28FFFFFFu, 0u);
|
|
|
|
var labelSize = ImGui.CalcTextSize(label);
|
|
pillDl.AddText(origin + (size - labelSize) * 0.5f, 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", size);
|
|
if (ImGui.IsItemClicked() && tab is not null)
|
|
ImGui.OpenPopup("##hellion-channel-picker");
|
|
|
|
DrawChannelPickerPopup(tab);
|
|
}
|
|
|
|
private 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;
|
|
}
|
|
|
|
var pickerWidth = 0f;
|
|
foreach (var chatType in tab.SelectedChannels.Keys)
|
|
{
|
|
if (chatType.ToInputChannel() is not { } ch)
|
|
continue;
|
|
pickerWidth = MathF.Max(
|
|
pickerWidth,
|
|
StyleEngine.Widgets.PopupRow.CalcWidth(ch.ToChatType().Name(), null, _fonts)
|
|
);
|
|
}
|
|
|
|
ImGui.Dummy(new Vector2(pickerWidth, 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 (
|
|
StyleEngine.Widgets.PopupRow.Draw(
|
|
$"##ch-{i++}",
|
|
input.ToChatType().Name(),
|
|
isCurrent,
|
|
_fonts
|
|
)
|
|
)
|
|
{
|
|
tab.CurrentChannel.SetChannel(input);
|
|
ImGui.CloseCurrentPopup();
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
ImGui.EndPopup();
|
|
}
|
|
}
|
|
|
|
// -1 means "not browsing". Per input bar, not shared: the history itself is
|
|
// global across the main window and every pop-out, but where each of them is
|
|
// in it is not.
|
|
private int _historyCursor = -1;
|
|
|
|
private void DrawInputField(Tab? activeTab)
|
|
{
|
|
if (Activate)
|
|
{
|
|
ImGui.SetKeyboardFocusHere();
|
|
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(
|
|
"##hellion-input",
|
|
ref _pendingMessage,
|
|
BufferCapacity,
|
|
ImGuiInputTextFlags.EnterReturnsTrue
|
|
| ImGuiInputTextFlags.CallbackEdit
|
|
| ImGuiInputTextFlags.CallbackCompletion
|
|
| ImGuiInputTextFlags.CallbackAlways
|
|
| ImGuiInputTextFlags.CallbackHistory,
|
|
SlashCommandCallback
|
|
)
|
|
)
|
|
{
|
|
_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();
|
|
_wasInputTextHovered = ImGui.IsItemHovered();
|
|
}
|
|
|
|
// Right-clicking the input field opened this in v1.5.6 and has opened
|
|
// nothing since the chat window was retired. Reported by a tester who went
|
|
// looking for the map-flag entry.
|
|
//
|
|
// Must sit immediately after the InputText call: ContextPopupItem binds to
|
|
// the last submitted item.
|
|
//
|
|
// Hiding the chat is not repeated here -- it has its own button two widgets
|
|
// to the right, and one way in is enough.
|
|
private void DrawInputContextMenu()
|
|
{
|
|
using var context = ImRaii.ContextPopupItem("##hellion-input-context");
|
|
if (!context.Success)
|
|
return;
|
|
|
|
// The game expands <flag> and <item> at send time, so inserting the
|
|
// literal token is the whole implementation. Each entry is disabled
|
|
// while its precondition is missing, so the token cannot be sent only
|
|
// to expand into nothing at the other end.
|
|
bool flagSet;
|
|
bool itemSet;
|
|
unsafe
|
|
{
|
|
// Null before dereferencing: both agents can be null during a zone
|
|
// transition, which is precisely when somebody is most likely to be
|
|
// typing a flag into a party chat.
|
|
var map = AgentMap.Instance();
|
|
var chatLog = AgentChatLog.Instance();
|
|
flagSet = map != null && map->FlagMarkerCount > 0;
|
|
itemSet = chatLog != null && chatLog->LinkedItem.ItemId != 0;
|
|
}
|
|
|
|
using (ImRaii.Disabled(!flagSet))
|
|
{
|
|
if (ImGui.Selectable(HellionStrings.ChatLog_Insert_MapFlag))
|
|
InsertToken("<flag>");
|
|
}
|
|
|
|
using (ImRaii.Disabled(!itemSet))
|
|
{
|
|
if (ImGui.Selectable(HellionStrings.ChatLog_Insert_ItemLink))
|
|
InsertToken("<item>");
|
|
}
|
|
}
|
|
|
|
// Focus returns to the field and the caret lands behind the token, so the
|
|
// user can keep typing. Picking from a menu and then having to click back
|
|
// into the field is the kind of small friction that makes a feature go
|
|
// unused.
|
|
private void InsertToken(string token)
|
|
{
|
|
SetPendingMessage(_pendingMessage + token);
|
|
Activate = true;
|
|
_activatePos = _pendingMessage.Length;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Up and down walk the sent-message history, the way 1.5.6 did. ImGui
|
|
// only raises this event when CallbackHistory is set on the field, which
|
|
// is why the arrows did nothing at all before: the service and the
|
|
// cursor maths were both here and tested, with no caller and no flag.
|
|
if (data.EventFlag == ImGuiInputTextFlags.CallbackHistory)
|
|
{
|
|
var direction =
|
|
data.EventKey == ImGuiKey.UpArrow
|
|
? CompactInputHistoryNavigator.Direction.Up
|
|
: CompactInputHistoryNavigator.Direction.Down;
|
|
|
|
var (cursor, replacement) = CompactInputHistoryNavigator.Navigate(
|
|
direction,
|
|
_historyCursor,
|
|
_pendingMessage,
|
|
() => InputHistoryService.Count,
|
|
InputHistoryService.Push,
|
|
InputHistoryService.GetByCursor
|
|
);
|
|
|
|
_historyCursor = cursor;
|
|
if (replacement is null)
|
|
return 0;
|
|
|
|
// The buffer belongs to ImGui inside a callback; writing the managed
|
|
// field here would be overwritten on the way out.
|
|
data.DeleteChars(0, data.BufTextLen);
|
|
if (replacement.Length > 0)
|
|
data.InsertChars(0, replacement);
|
|
|
|
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.
|
|
//
|
|
// Typing also ends the history walk. Without this, down-arrow after
|
|
// editing a recalled line would jump to the next entry and throw the
|
|
// edit away.
|
|
if (data.EventFlag == ImGuiInputTextFlags.CallbackEdit)
|
|
_historyCursor = -1;
|
|
|
|
_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;
|
|
|
|
// 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;
|
|
|
|
// Route the trimmed buffer into the exact send string. BuildOutgoing is
|
|
// pure (no send, no field write) so the SelfTest can exercise the tell
|
|
// routing without firing a real chat line; the wasTell flag drives the
|
|
// post-send ResetTempChannel below.
|
|
var (toSend, wasTell) = BuildOutgoing(activeTab, 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);
|
|
|
|
// Pushed before the buffer is cleared, and the trimmed form is what
|
|
// goes in: the history is for recalling what you typed, not the
|
|
// whitespace around it.
|
|
InputHistoryService.Push(text);
|
|
_historyCursor = -1;
|
|
_pendingMessage = string.Empty;
|
|
|
|
// 1.5.6 parity (1d3b429:ChatLogWindow.cs:1558): clear the temp channel
|
|
// after a tell so a one-off /tell doesn't stick to the tab. Tell-only,
|
|
// so Say/Party/FC stay untouched. A no-op in today's input-bar path
|
|
// (TempTellTarget is inert), kept for an eventual temp-channel revival.
|
|
if (wasTell)
|
|
activeTab?.CurrentChannel?.ResetTempChannel();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to send chat message ({Length} chars)", toSend.Length);
|
|
}
|
|
}
|
|
|
|
// Pure routing: turns the trimmed buffer into the bytes-source string and
|
|
// reports whether it became a tell. No send, no field mutation — the
|
|
// ResetTempChannel side-effect lives in TrySend, gated by wasTell, so this
|
|
// stays exercisable from the SelfTest. Slash input is verbatim (the game
|
|
// parser owns /tell, /fc, …); everything else gets the channel prefix,
|
|
// except a tell tab, which needs the full "/tell name@world" because
|
|
// InputChannel.Tell.Prefix() is only "/t" and would drop the target.
|
|
private (string toSend, bool wasTell) BuildOutgoing(Tab? activeTab, string text)
|
|
{
|
|
if (text.StartsWith('/'))
|
|
return (text, false);
|
|
|
|
var current = activeTab?.CurrentChannel?.Channel ?? InputChannel.Invalid;
|
|
|
|
// 1.5.6 tell-target chain (1d3b429:ChatLogWindow.cs:1543-1546).
|
|
TellTarget? target = null;
|
|
if (activeTab is not null && activeTab.TellTarget.IsSet())
|
|
{
|
|
// leg1 — unconditional: a freshly spawned temp tab carries its target
|
|
// only here, with CurrentChannel still Invalid until a sidebar/top-bar
|
|
// click runs EnsureCurrentChannel. A current==Tell gate would miss it.
|
|
target = activeTab.TellTarget;
|
|
}
|
|
else if (current == InputChannel.Tell)
|
|
{
|
|
// leg2/leg3 — gated on Tell (CORR-1): CurrentChannel.TellTarget is NOT
|
|
// channel-bound. After a game-side tell, switching the pill to Say leaves
|
|
// the tell target standing (SetChannel only sets Channel), so without this
|
|
// gate a say line would silently go out as /tell — a privacy misfire.
|
|
target =
|
|
activeTab?.CurrentChannel?.TempTellTarget ?? activeTab?.CurrentChannel?.TellTarget;
|
|
}
|
|
|
|
// One world lookup, reused by the gate and the string build (ToTargetString
|
|
// would resolve the sheet twice). The !IsNullOrEmpty(world) check is the
|
|
// COMP-1 guard: IsSet() only proves World > 0, not that the id resolves in
|
|
// the Lumina sheet. A miss yields an empty world, and "/tell Name@ text" is
|
|
// exactly what the game rejects with "you must add the World name". On a miss
|
|
// we fall through to the channel-prefix path.
|
|
var world = target?.ToWorldString();
|
|
if (target != null && target.IsSet() && !string.IsNullOrEmpty(world))
|
|
return ($"/tell {target.Name}@{world} {text}", true);
|
|
|
|
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()
|
|
{
|
|
string? tooltip = null;
|
|
|
|
if (DrawGhostButton("##qb-symbols", FontAwesomeIcon.SmileBeam))
|
|
_symbolPicker.OpenPopup();
|
|
if (ImGui.IsItemHovered())
|
|
tooltip = HellionStrings.InputBar_InsertSymbol_Tooltip;
|
|
|
|
ImGui.SameLine(0f, 4f * StyleEngine.Metrics.Scale);
|
|
|
|
if (DrawGhostButton("##qb-more", FontAwesomeIcon.EllipsisH))
|
|
ImGui.OpenPopup("##hellion-more-menu");
|
|
if (ImGui.IsItemHovered())
|
|
tooltip = HellionStrings.InputBar_More_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();
|
|
|
|
// Every colour here is measured against what it actually lands on. The
|
|
// glyph sits on the window floor, the glow hugs the fill -- a raw theme
|
|
// accent can vanish on either, which is exactly the warning that came
|
|
// back from the smoke test.
|
|
var accent = ColourUtil.EnsureContrast(
|
|
ColourUtil.RgbaToAbgr(c.Accent),
|
|
ColourUtil.RgbaToAbgr(c.ChildBg),
|
|
3f
|
|
);
|
|
|
|
if (amount > 0f)
|
|
{
|
|
dl.AddRectFilled(
|
|
origin,
|
|
max,
|
|
ColourUtil.ApplyAlpha(ColourUtil.RgbaToAbgr(c.Surface), amount * 0.8f),
|
|
3f * scale
|
|
);
|
|
|
|
// DrawGlowBorder reads RGBA with alpha in the low byte; ApplyAlpha
|
|
// writes the high byte (ABGR). Mixing them is the channel-order trap
|
|
// this cycle already fell into once, so the alpha byte is set by hand.
|
|
var glowRgba =
|
|
(ColourUtil.RgbaToAbgr(accent) & 0xFFFFFF00u)
|
|
| (uint)(byte)Math.Round(0xB4 * amount);
|
|
dl.DrawGlowBorder(min: origin, max: max, glowRgba, 1f, 4);
|
|
}
|
|
|
|
var tint = ColourUtil.EnsureContrast(
|
|
ColourUtil.RgbaToAbgr(c.TextMuted),
|
|
ColourUtil.RgbaToAbgr(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
|
|
{
|
|
// Width from the widest visible entry, not a fixed minimum -- the
|
|
// popup does not grow for draw-list content, and German labels
|
|
// outran the first guess within a day.
|
|
var menuWidth = 0f;
|
|
if (_themeQuickPicker is not null)
|
|
menuWidth = MathF.Max(
|
|
menuWidth,
|
|
StyleEngine.Widgets.PopupRow.CalcWidth(
|
|
HellionStrings.Settings_QuickPicker_Tooltip,
|
|
FontAwesomeIcon.Palette,
|
|
_fonts
|
|
)
|
|
);
|
|
menuWidth = MathF.Max(
|
|
menuWidth,
|
|
StyleEngine.Widgets.PopupRow.CalcWidth(
|
|
HellionStrings.InputBar_Settings_Tooltip,
|
|
FontAwesomeIcon.Cog,
|
|
_fonts
|
|
)
|
|
);
|
|
menuWidth = MathF.Max(
|
|
menuWidth,
|
|
StyleEngine.Widgets.PopupRow.CalcWidth(
|
|
Language.Context_ScreenshotMode,
|
|
FontAwesomeIcon.Camera,
|
|
_fonts
|
|
)
|
|
);
|
|
if (Plugin.Config.ShowHideButton && _onHideWindow is not null)
|
|
menuWidth = MathF.Max(
|
|
menuWidth,
|
|
StyleEngine.Widgets.PopupRow.CalcWidth(
|
|
HellionStrings.InputBar_HideChat_Tooltip,
|
|
FontAwesomeIcon.EyeSlash,
|
|
_fonts
|
|
)
|
|
);
|
|
if (OnPopIn is not null)
|
|
menuWidth = MathF.Max(
|
|
menuWidth,
|
|
StyleEngine.Widgets.PopupRow.CalcWidth(
|
|
HellionStrings.InputBar_PopIn_Tooltip,
|
|
FontAwesomeIcon.Times,
|
|
_fonts
|
|
)
|
|
);
|
|
|
|
ImGui.Dummy(new Vector2(menuWidth, 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
|
|
)
|
|
)
|
|
{
|
|
// Persisted flag -- write it here too, or turning it back off
|
|
// never reaches the config file.
|
|
Plugin.Config.ScreenshotMode = !Plugin.Config.ScreenshotMode;
|
|
Plugin.Instance.SaveConfig();
|
|
}
|
|
|
|
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;
|
|
|
|
// 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;
|
|
|
|
// Test-only hook; do not call from production code. Exposes the pure routing
|
|
// so the tell SelfTest can assert the string + wasTell flag without ever
|
|
// reaching ChatBox.SendMessageUnsafe (no real chat line).
|
|
internal (string toSend, bool wasTell) TestBuildOutgoingForSelfTest(
|
|
Tab? activeTab,
|
|
string text
|
|
) => BuildOutgoing(activeTab, text);
|
|
|
|
// Test-only hook; do not call from production code. Exposes the pure pill-label
|
|
// resolution so the tell-transparency SelfTest can assert the partner name is
|
|
// shown in the stale-/reply-tell state. Static (ResolvePillLabel is static).
|
|
internal static string TestResolvePillLabelForSelfTest(Tab? tab, bool isTell) =>
|
|
ResolvePillLabel(tab, isTell);
|
|
|
|
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.
|
|
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;
|
|
}
|
|
}
|