InputPreview was only rendered for PreviewPosition.Top/Bottom (the DrawConditions IsWindowMode gate). Inside-mode (the default) and Tooltip-mode had no caller at all because v1.5.6's inline-render path lived on the deleted ChatLogWindow and was not migrated to the v1.7.0 Components-Layer. Wire Inside-mode by calling CalculatePreviewHeight + DrawPreview inline from MainWindow.DrawMainArea between the message-list child and the input bar, with the message-list height reserved for the preview block. Wire Tooltip-mode by sampling IsItemHovered() on the input text widget inside InputBar.DrawInputField (analog to the existing _isFocused = ImGui.IsItemFocused() idiom on the same line) and exposing it as WasInputTextHovered; MainWindow opens the tooltip after _input.Draw when both the hover-flag and PreviewPosition.Tooltip are active. Plan-drift acknowledged: the plan stated Plugin.InputPreview is statically reachable, but the property was declared as an instance member on Plugin.cs:101. Hoisted to internal static to match the plan's intention (analog to Plugin.Config); updated the single external instance-access site in PluginLifecycle.RegisterWindows to the type-qualified form. Verified in-game: Inside-mode preview block appears between message list and input bar on first keystroke; tooltip-mode shows preview on text-field hover only; Top/Bottom-mode unchanged; empty buffer hides the preview in all modes. dotnet build clean, dotnet csharpier check clean.
345 lines
12 KiB
C#
345 lines
12 KiB
C#
using System.Numerics;
|
|
using System.Text;
|
|
using Dalamud.Bindings.ImGui;
|
|
using Dalamud.Interface;
|
|
using Dalamud.Interface.Utility.Raii;
|
|
using HellionChat.Code;
|
|
using HellionChat.GameFunctions;
|
|
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.
|
|
|
|
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();
|
|
|
|
// 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;
|
|
}
|
|
|
|
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,
|
|
SlashCommandCallback
|
|
)
|
|
)
|
|
{
|
|
_commandHelpWindow.IsOpen = false;
|
|
TrySend(activeTab);
|
|
}
|
|
_isFocused = ImGui.IsItemFocused();
|
|
_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.
|
|
private int SlashCommandCallback(scoped ref ImGuiInputTextCallbackData data)
|
|
{
|
|
_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;
|
|
|
|
// 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;
|
|
|
|
// 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
|
|
{
|
|
ChatBox.SendMessage(toSend);
|
|
_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;
|
|
}
|