fix(ui): wire chat send and fix sidebar icons, channel pill, scrollbar

Five smoke bugs from the first in-game test:

1. Sidebar showed fa-comment for every tab because the resolve path
   only honoured tab.Icon. Channel-type fallback restored — auto-tell
   tabs render the envelope, the rest map their first SelectedChannels
   key onto FontAwesome (Linkshells → link, FC → users, Party →
   user-friends, System/Echo → cog, emotes → comments).

2. InputBar's channel pill read from tab.Channel (the saved default),
   which is null on most non-FC tabs and rendered as "—". The pill now
   reads tab.CurrentChannel.Channel first so the runtime input state
   surfaces on every tab, with the saved default as a second fallback.

3. MessageList was making its own ImRaii.Child inside the main-area
   child MainWindow already owns. That nested scroll created the second
   scrollbar on the outer window. The component now lays out directly
   into the parent's scroll region.

4. The input field reserved 90px for the three FontAwesome buttons,
   which clipped them on standard frame padding. Reserve raised to
   130px so the trailing buttons fully render.

5. Pressing Enter dropped the buffer — there was no send wiring. The
   field now uses ImGuiInputTextFlags.EnterReturnsTrue and routes the
   pending message through GameFunctions.ChatBox.SendMessage. Lines
   that don't start with a slash get the active channel's prefix
   prepended so typing in /fc lands on the FC channel instead of the
   current game-side default.

InputBar gains an ILogger<InputBar> for the send-failure path; the
DI registration in PluginHostFactory is updated to match.
This commit is contained in:
2026-05-23 20:50:54 +02:00
parent 0109bfd222
commit 52b0fa7c67
4 changed files with 122 additions and 19 deletions
+2 -1
View File
@@ -138,7 +138,8 @@ internal static class PluginHostFactory
sp.GetRequiredService<Ui.Components.SymbolPicker>(),
sp.GetRequiredService<FontManager>(),
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>()
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>(),
sp.GetRequiredService<ILogger<Ui.Components.InputBar>>()
));
services.AddSingleton(sp => new Ui.Components.StatusBar(
sp.GetRequiredService<ThemeRegistry>(),
+68 -10
View File
@@ -3,28 +3,33 @@ using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility.Raii;
using HellionChat.Code;
using HellionChat.GameFunctions;
using HellionChat.Themes;
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. Send wiring lands when the main window assembles the
// components; for now this layer only handles buffer state and the symbol
// picker overlay.
// 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 string _pendingMessage = string.Empty;
@@ -32,13 +37,15 @@ internal sealed class InputBar
SymbolPicker symbolPicker,
FontManager fonts,
ThemeRegistry themes,
TokenResolver resolver
TokenResolver resolver,
ILogger<InputBar> logger
)
{
_symbolPicker = symbolPicker;
_fonts = fonts;
_themes = themes;
_resolver = resolver;
_logger = logger;
}
public string PendingMessage => _pendingMessage;
@@ -62,7 +69,7 @@ internal sealed class InputBar
DrawChannelPill(activeTab, isTell, pillAbgr, pillTextAbgr);
ImGui.SameLine();
DrawInputField();
DrawInputField(activeTab);
ImGui.SameLine();
DrawQuickButtons();
@@ -77,8 +84,17 @@ internal sealed class InputBar
{
if (isTell && tab?.TellTarget is { } t && t.IsSet())
return $"→ {t.Name}";
if (tab?.Channel is { } ch)
return ch.ToChatType().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 "—";
}
@@ -98,10 +114,52 @@ internal sealed class InputBar
ImGui.Dummy(new Vector2(width, PillHeight));
}
private void DrawInputField()
private void DrawInputField(Tab? activeTab)
{
ImGui.SetNextItemWidth(-90f);
ImGui.InputText("##hellion-input", ref _pendingMessage, BufferCapacity);
ImGui.SetNextItemWidth(-QuickButtonsReserve);
if (
ImGui.InputText(
"##hellion-input",
ref _pendingMessage,
BufferCapacity,
ImGuiInputTextFlags.EnterReturnsTrue
)
)
{
TrySend(activeTab);
}
}
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()
+5 -8
View File
@@ -37,10 +37,9 @@ internal sealed class MessageList
return;
}
using var child = ImRaii.Child("##hellion-messages", new Vector2(-1, -1));
if (!child.Success)
return;
// No own ImRaii.Child here — MainWindow already wraps the message
// area in one. Nesting would give the window two stacked scrolls
// and a runaway content-height computation.
var theme = _themes.Active;
var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary);
var mutedAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextMuted);
@@ -49,10 +48,8 @@ internal sealed class MessageList
var compact = Plugin.Config.UseCompactDensity;
// Track whether the user was pinned to the bottom before this frame
// so newly arriving rows do not yank them up — the standard
// chat-window expectation. Read the scroll state before drawing
// anything inside the child so the comparison is against the
// previous frame's max.
// so newly arriving rows do not yank them up. The check runs against
// the parent child's scroll state, which is the one MainWindow owns.
var pinnedToBottom = ImGui.GetScrollY() >= ImGui.GetScrollMaxY() - 1f;
if (compact)
+47
View File
@@ -2,6 +2,7 @@ using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility.Raii;
using HellionChat.Code;
using HellionChat.Themes;
using HellionChat.Ui.StyleEngine;
using HellionChat.Util;
@@ -164,9 +165,55 @@ internal sealed class Sidebar
!string.IsNullOrWhiteSpace(tab.Icon) && IconByName.TryGetValue(tab.Icon, out var mapped)
)
return mapped;
// Auto-tell tabs always show the envelope, regardless of what their
// SelectedChannels filter is set to.
if (tab.IsTempTab)
return FontAwesomeIcon.Envelope;
// Channel-type fallback. The v1.5.6 TabIconGlyphResolver did the
// same thing — picks the first selected channel and maps its
// ChatType to a category icon so tabs without a user-set icon
// still look distinct.
if (tab.SelectedChannels.Count > 0)
return ResolveByChannelType(tab.SelectedChannels.Keys.First());
return FontAwesomeIcon.Comment;
}
private static FontAwesomeIcon ResolveByChannelType(ChatType type) =>
type switch
{
ChatType.TellIncoming or ChatType.TellOutgoing => FontAwesomeIcon.Envelope,
ChatType.FreeCompany
or ChatType.FreeCompanyAnnouncement
or ChatType.FreeCompanyLoginLogout => FontAwesomeIcon.Users,
ChatType.Linkshell1
or ChatType.Linkshell2
or ChatType.Linkshell3
or ChatType.Linkshell4
or ChatType.Linkshell5
or ChatType.Linkshell6
or ChatType.Linkshell7
or ChatType.Linkshell8
or ChatType.CrossLinkshell1
or ChatType.CrossLinkshell2
or ChatType.CrossLinkshell3
or ChatType.CrossLinkshell4
or ChatType.CrossLinkshell5
or ChatType.CrossLinkshell6
or ChatType.CrossLinkshell7
or ChatType.CrossLinkshell8 => FontAwesomeIcon.Link,
ChatType.Party or ChatType.CrossParty => FontAwesomeIcon.UserFriends,
ChatType.Alliance => FontAwesomeIcon.Users,
ChatType.NoviceNetwork or ChatType.NoviceNetworkSystem => FontAwesomeIcon.Users,
ChatType.PvpTeam or ChatType.PvpTeamAnnouncement or ChatType.PvpTeamLoginLogout =>
FontAwesomeIcon.Users,
ChatType.System or ChatType.Echo => FontAwesomeIcon.Cog,
ChatType.CustomEmote or ChatType.StandardEmote => FontAwesomeIcon.Comments,
_ => FontAwesomeIcon.Comment,
};
private void LogPopOutStub(Tab tab)
{
// The channel-popout pool is built in a later cycle; logging here