Files
HellionChat/HellionChat/Ui/Components/InputBar.cs
T
JonKazama-Hellion 29fb4b92eb fix(input-preview): wire Inside-mode + Tooltip-mode render paths
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.
2026-05-28 13:21:59 +02:00

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;
}