refactor(ui): retire ChatLogWindow and the v1.5.6 chat-window layer

The legacy ChatLogWindow.cs and its tightly coupled neighbours are gone:
PayloadHandler, Popout, ChatInputBar, AutoCompleteInfo, AutoTellTabTint,
the three tab-icon helpers, the old Ui/StatusBar and Ui/SymbolPicker
behind the components-layer replacements, HellionStyle + helpers, the
CompactInputSubmitter test mirror and the QuickPickerSelfTestStep. The
new component layer (MainWindow + the five components + GlobalStyleScope)
now drives the whole chat surface.

InputPreview, CommandHelpWindow and Debugger lose their ChatLogWindow
backref. The first two are skeleton windows for now — DrawConditions
always returns false until the new chat layer exposes equivalent state.
Debugger keeps the current-tab and vanilla-chat blocks; the payload
counters are explicitly marked offline. DbViewer renders Sender/Content
columns as plain TextValue strings instead of the removed DrawChunks.

GameFunctions.Chat and GameFunctions.KeybindManager keep the hook
plumbing intact but mark every ChatLogWindow.Activated /
ChangeTabDelta / TellSpecial site as offline so the FFXIV-side
integration still compiles and runs without an Activated entry point.
TypingIpc.BuildState reports the IPC state as not-typing / not-focused
until the new chat layer surfaces real focus and buffer state again.

Plugin.cs Draw uses StyleEngine.GlobalStyleScope.Push for the per-frame
theme push and stops calling BeginFrame / FinalizeFrame / HideStateCheck
/ DefaultText through the dead window. ImGuiUtil drops PostPayload +
WrapText + the surrounding word-wrap pipeline. PluginHostFactory and
PluginLifecycle drop the legacy DI singletons and AddWindow entries.

Build is clean and csharpier is clean across the trimmed 131-file tree.
This commit is contained in:
2026-05-23 20:31:12 +02:00
parent c9e746a8e3
commit cf4705e01f
27 changed files with 98 additions and 6342 deletions
+5 -23
View File
@@ -256,17 +256,9 @@ internal sealed class AutoTellTabsService : IDisposable
return;
}
// Clean up pop-out window if tab is popped out
if (victim.Tab.PopOut)
{
var popout = _plugin.ChatLogWindow.ActivePopouts.FirstOrDefault(p =>
p.TabIdentifier == victim.Tab.Identifier
);
if (popout != null)
{
popout.IsOpen = false;
}
}
// Pop-out-window cleanup is offline while the channel-popout pool
// is rebuilt — Tab.PopOut still flips on/off, the visible window
// disappears once the new pool comes online.
Plugin.Config.Tabs.RemoveAt(victim.Index);
@@ -435,18 +427,8 @@ internal sealed class AutoTellTabsService : IDisposable
.Config.Tabs.Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t) && t.PopOut)
.Select(t => t.Identifier)
.ToList();
if (poppedTempTabIds.Count > 0)
{
var poppedSet = poppedTempTabIds.ToHashSet();
foreach (
var popout in _plugin
.ChatLogWindow.ActivePopouts.Where(p => poppedSet.Contains(p.TabIdentifier))
.ToList()
)
{
popout.IsOpen = false;
}
}
// Pop-out-window cleanup is offline; see Disconnect path above.
_ = poppedTempTabIds;
Plugin.Config.Tabs.RemoveAll(TabLifecycleHelpers.IsInUnpinnedPool);
+21 -73
View File
@@ -232,16 +232,9 @@ internal sealed unsafe class Chat : IDisposable
if (c != '\0' && !char.IsControl(c))
input = c.ToString();
try
{
Plugin.ChatLogWindow.Activated(
new ChatActivatedArgs(new ChannelSwitchInfo(null)) { Input = input }
);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in chat Activated event");
}
// Chat-window Activated integration is offline until the
// new chat layer surfaces an Activated entry point.
_ = input;
});
}
@@ -255,23 +248,9 @@ internal sealed unsafe class Chat : IDisposable
addIfNotPresent = add;
}
try
{
// Prevent duplicate calls
if (Plugin.ChatLogWindow.TellSpecial)
return ChatLogRefreshHook!.Original(log, eventId, value);
Plugin.ChatLogWindow.Activated(
new ChatActivatedArgs(new ChannelSwitchInfo(null))
{
AddIfNotPresent = addIfNotPresent,
}
);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in chat Activated event");
}
// Chat-window Activated integration is offline until the new chat
// layer surfaces an Activated entry point.
_ = addIfNotPresent;
return 1; // Prevent vanilla chat log from gaining focus
}
@@ -342,28 +321,13 @@ internal sealed unsafe class Chat : IDisposable
{
if (playerName != null)
{
try
{
var target = new TellTarget(
playerName->ToString(),
worldId,
contentId,
(TellReason)reason
);
Plugin.ChatLogWindow.Activated(
new ChatActivatedArgs(
new ChannelSwitchInfo(InputChannel.Tell, permanent: setChatType)
)
{
TellReason = (TellReason)reason,
TellTarget = target,
}
);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in chat Activated event");
}
// Chat-window Activated integration is offline; tell-target
// routing returns when the new chat layer is wired up.
_ = playerName;
_ = worldId;
_ = contentId;
_ = reason;
_ = setChatType;
}
return SetChatLogTellTargetHook!.Original(
@@ -393,27 +357,12 @@ internal sealed unsafe class Chat : IDisposable
if (playerName != null)
{
try
{
var target = new TellTarget(
playerName->ToString(),
worldId,
contentId,
(TellReason)reason
);
Plugin.ChatLogWindow.Activated(
new ChatActivatedArgs(new ChannelSwitchInfo(InputChannel.Tell))
{
TellReason = (TellReason)reason,
TellTarget = target,
TellSpecial = Sheets.IsInForay(), // Handle Eureka/Bozja special
}
);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in chat Activated event");
}
// Chat-window Activated integration is offline; tell-target
// routing returns when the new chat layer is wired up.
_ = playerName;
_ = worldId;
_ = contentId;
_ = reason;
}
ContextMenuTellInForayHook!.Original(
@@ -570,9 +519,8 @@ internal sealed unsafe class Chat : IDisposable
if (!Plugin.CurrentTab.CurrentChannel.UseTempChannel)
Plugin.CurrentTab.CurrentChannel.UseTempChannel = true;
// Send tell via CommandInner later and let the game handle it
// Only works because we use the SetTellTargetInForay function to set all required information
Plugin.ChatLogWindow.TellSpecial = true;
// Send tell via CommandInner later and let the game handle it.
// TellSpecial gate is offline until the new chat layer reads it.
var utfName = Utf8String.FromString(name);
var utfWorld = Utf8String.FromString(worldName);
+6 -23
View File
@@ -504,33 +504,16 @@ internal unsafe class KeybindManager : IDisposable
if (!KeybindsToIntercept.TryGetValue(currentBest.Item2, out var info))
return;
try
{
TellReason? reason = info.Channel == InputChannel.Tell ? TellReason.Reply : null;
Plugin.ChatLogWindow.Activated(new ChatActivatedArgs(info) { TellReason = reason });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in chat Activated event");
}
// Chat-window Activated integration is offline until the new chat
// layer surfaces an Activated entry point.
_ = info;
}
// v0.6.0 — central dispatch for ChatTabForward/Backward. If a pop-out
// window currently has its compact input focused, the keybind is
// forwarded into that pop-out's ChatInputBar so the user navigates
// tabs in the window they are typing in. Otherwise the main window
// handles it (= v0.5.x behavior).
// Tab-cycle dispatch is offline until the new chat layer surfaces a
// ChangeTabDelta entry point and pop-out input bars come back online.
private void DispatchTabDelta(int delta)
{
foreach (var popout in Plugin.ChatLogWindow.ActivePopouts)
{
if (popout.HasFocusedInputBar && popout.InputBar != null)
{
popout.InputBar.HandleKeybindForward(delta);
return;
}
}
Plugin.ChatLogWindow.ChangeTabDelta(delta);
_ = delta;
}
private static Keybind GetKeybind(string id)
+8 -7
View File
@@ -62,8 +62,9 @@ internal sealed class TypingIpc : IDisposable
private ChatInputState BuildState()
{
var log = Plugin.ChatLogWindow;
// Input visibility and focus come back when the new chat layer
// exposes the matching state. The channel type still resolves
// from the active tab so IPC consumers can read it today.
var usedChannel = Plugin.CurrentTab.CurrentChannel;
var inputChannel = usedChannel.UseTempChannel
? usedChannel.TempChannel
@@ -71,11 +72,11 @@ internal sealed class TypingIpc : IDisposable
var channelType = inputChannel.ToChatType();
return (
InputVisible: !log.IsHidden,
log.InputFocused,
HasText: log.Chat.Length > 0,
IsTyping: log is { InputFocused: true, Chat.Length: > 0 },
TextLength: log.Chat.Length,
InputVisible: false,
InputFocused: false,
HasText: false,
IsTyping: false,
TextLength: 0,
ChannelType: channelType
);
}
-900
View File
@@ -1,900 +0,0 @@
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.Config;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.Textures;
using Dalamud.Interface.Textures.TextureWraps;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Ui;
using HellionChat.Util;
using Lumina.Excel.Sheets;
using Microsoft.Extensions.Logging;
using Action = System.Action;
using ChatTwoPartyFinderPayload = HellionChat.Util.PartyFinderPayload;
using DalamudPartyFinderPayload = Dalamud.Game.Text.SeStringHandling.Payloads.PartyFinderPayload;
namespace HellionChat;
public sealed class PayloadHandler
{
private const string PopupId = "hellionchat-context-popup";
private ChatLogWindow LogWindow { get; }
private (Chunk, Payload?)? Popup { get; set; }
public bool HandleTooltips;
public uint HoveredItem;
public uint HoverCounter;
public uint LastHoverCounter;
private const uint PopupSfx = 1;
private readonly ILogger<PayloadHandler> _logger;
internal PayloadHandler(ChatLogWindow logWindow, ILogger<PayloadHandler> logger)
{
LogWindow = logWindow;
_logger = logger;
}
internal void Draw()
{
DrawPopups();
if (HandleTooltips && ++HoverCounter - LastHoverCounter > 1)
{
GameFunctions.GameFunctions.CloseItemTooltip();
HoveredItem = 0;
HoverCounter = LastHoverCounter = 0;
HandleTooltips = false;
}
}
private void DrawPopups()
{
if (Popup == null)
return;
var (chunk, payload) = Popup.Value;
using var popup = ImRaii.Popup(PopupId);
if (!popup.Success)
{
Popup = null;
return;
}
using var id = ImRaii.PushId(PopupId);
var drawn = false;
switch (payload)
{
case PlayerPayload player:
DrawPlayerPopup(chunk, player);
drawn = true;
break;
case ItemPayload item:
DrawItemPopup(item);
drawn = true;
break;
case UriPayload uri:
DrawUriPopup(uri);
drawn = true;
break;
case StatusPayload status:
DrawStatusPopup(status);
drawn = true;
break;
}
ContextFooter(drawn, chunk);
Integrations(chunk, payload);
}
private void Integrations(Chunk chunk, Payload? payload)
{
var registered = LogWindow.Plugin.Ipc.Registered;
if (registered.Count == 0)
return;
ImGui.Separator();
var contentId = chunk.Message?.ContentId ?? 0;
var sender =
chunk.Message?.Sender.Select(c => c.Link).FirstOrDefault(p => p is PlayerPayload)
as PlayerPayload;
using var menu = ImRaii.Menu(Language.Context_Integrations);
if (!menu.Success)
return;
var cursor = ImGui.GetCursorPos();
foreach (var id in registered)
{
try
{
LogWindow.Plugin.Ipc.Invoke(
id,
sender,
contentId,
payload,
chunk.Message?.SenderSource,
chunk.Message?.ContentSource
);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing integration");
}
}
if (cursor == ImGui.GetCursorPos())
{
using var pushedColor = ImRaii.PushColor(
ImGuiCol.Text,
ImGui.GetStyle().Colors[(int)ImGuiCol.TextDisabled]
);
ImGui.Text("No integrations available");
}
}
private void ContextFooter(bool didCustomContext, Chunk chunk)
{
ImRaii.MenuDisposable menu = default;
if (didCustomContext)
{
ImGui.Separator();
// Only place these menu items in a submenu if we've already drawn
// custom context menu items based on the payload.
//
// It makes it much more convenient in the majority of cases to
// copy the message content without having to open a submenu.
menu = ImRaii.Menu(Plugin.PluginName);
if (!menu.Success)
return;
}
ImGui.Checkbox(Language.Context_ScreenshotMode, ref LogWindow.ScreenshotMode);
if (ImGui.Selectable(Language.Context_HideChat))
LogWindow.UserHide();
if (chunk.Message is { } message)
{
if (ImGui.Selectable(Language.Context_Copy))
{
ImGui.SetClipboardText(StringifyMessage(message, true));
WrapperUtil.AddNotification(Language.Context_CopySuccess, NotificationType.Info);
}
// Only show a separate "Copy content" option if the message has
// Sender chunks, so it doesn't show for system messages.
if (message.Sender.Count > 0 && ImGui.Selectable(Language.Context_CopyContent))
{
ImGui.SetClipboardText(StringifyMessage(message));
WrapperUtil.AddNotification(
Language.Context_CopyContentSuccess,
NotificationType.Info
);
}
using var pushedColor = ImRaii.PushColor(
ImGuiCol.Text,
ImGui.GetStyle().Colors[(int)ImGuiCol.TextDisabled]
);
ImGui.TextUnformatted(message.Code.Type.Name());
}
menu.Dispose();
}
private static string StringifyMessage(Message? message, bool withSender = false)
{
if (message == null)
return string.Empty;
var chunks = withSender ? message.Sender.Concat(message.Content) : message.Content;
return chunks
.Where(chunk => chunk is TextChunk)
.Cast<TextChunk>()
.Select(text => text.Content)
.Aggregate(string.Concat);
}
internal unsafe void Click(Chunk chunk, Payload? payload, ImGuiMouseButton button)
{
if (Plugin.Config.PlaySounds)
UIGlobals.PlaySoundEffect(PopupSfx);
switch (button)
{
case ImGuiMouseButton.Left:
LeftClickPayload(chunk, payload);
break;
case ImGuiMouseButton.Right:
RightClickPayload(chunk, payload);
break;
}
}
internal void Hover(Payload payload)
{
var hoverSize = 350f * ImGuiHelpers.GlobalScale;
switch (payload)
{
case StatusPayload status:
DoHover(() => HoverStatus(status), hoverSize);
break;
case ItemPayload item:
if (Plugin.Config.NativeItemTooltips)
{
if (!HandleTooltips || HoveredItem != item.RawItemId)
{
HandleTooltips = true;
HoveredItem = item.RawItemId;
HoverCounter = LastHoverCounter = 0;
GameFunctions.GameFunctions.OpenItemTooltip(item.RawItemId, item.Kind);
}
else
{
LastHoverCounter = HoverCounter;
}
return;
}
DoHover(() => HoverItem(item), hoverSize);
break;
case UriPayload uri:
DoHover(() => HoverUri(uri), hoverSize);
break;
}
}
private void DoHover(Action inside, float width)
{
ImGui.SetNextWindowSize(new Vector2(width, -1f));
using (ImRaii.Tooltip())
using (ImRaii.TextWrapPos(0.0f))
using (ImRaii.PushColor(ImGuiCol.Text, LogWindow.DefaultText))
inside();
}
public unsafe void MoveTooltip(AddonEvent type, AddonArgs args)
{
// Only move if the user has the "Next to Cursor" option selected
if (
!Plugin.GameConfig.TryGet(UiControlOption.DetailTrackingType, out uint selected)
|| selected != 0
)
return;
if (LogWindow.LastViewport != ImGuiHelpers.MainViewport.Handle)
return;
var atk = args.Addon;
if (atk.IsNull)
return;
var atkBase = (AtkUnitBase*)atk.Address;
if (atkBase->WindowNode == null)
return;
if (!atkBase->IsVisible)
return;
var component = atkBase->WindowNode->AtkResNode;
var atkPos = new Vector2(component.ScreenX, component.ScreenY);
var atkSize = new Vector2(
component.GetWidth() * component.ScaleX,
component.GetHeight() * component.GetScaleY()
);
var chatRect = new MathUtil.Rectangle(LogWindow.LastWindowPos, LogWindow.LastWindowSize);
var addonRect = new MathUtil.Rectangle(atkPos, atkSize);
if (!chatRect.HasOverlap(addonRect))
return;
var viewportSize = ImGuiHelpers.MainViewport.Size;
var isLeft = chatRect.SizeX < viewportSize.X / 2;
var isTop = chatRect.SizeY < viewportSize.Y / 2;
var mousePos = ImGui.GetMousePos();
// addon spawned left of mouse cursor
if (addonRect.X < mousePos.X)
{
if (isLeft)
addonRect.X = (short)mousePos.X + 5;
}
else
{
if (!isLeft)
addonRect.X = Math.Max(0, (short)mousePos.X - 5 - addonRect.Width);
}
if (!chatRect.HasOverlap(addonRect))
{
atkBase->SetPosition((short)addonRect.X, (short)addonRect.Y);
return;
}
// addon spawned above mouse cursor
if (addonRect.Y < mousePos.Y)
{
if (isTop)
addonRect.Y = (short)mousePos.Y + 5;
}
else
{
if (!isTop)
addonRect.Y = Math.Max(0, (short)mousePos.Y - 5 - addonRect.Height); // prevent it going below 0
}
if (!chatRect.HasOverlap(addonRect))
{
atkBase->SetPosition((short)addonRect.X, (short)addonRect.Y);
return;
}
// Spawning right/bottom of mouse cursor didn't solve the overlap, so we spawn it next to the chat
var x = isLeft ? chatRect.SizeX : LogWindow.LastWindowPos.X - atkSize.X;
var y = Math.Clamp(chatRect.SizeY - atkSize.Y, 0, float.MaxValue);
y -= isTop ? 0 : Plugin.Config.TooltipOffset; // offset to prevent cut-off on the bottom
atkBase->SetPosition((short)x, (short)y);
}
private const float MaxInlineIconSize = 32f;
private static void InlineIcon(IDalamudTextureWrap icon)
{
if (icon.Size.X <= 0 || icon.Size.Y <= 0)
return;
var width = (float)icon.Size.X;
var height = (float)icon.Size.Y;
var scale = Math.Min(1f, Math.Min(MaxInlineIconSize / width, MaxInlineIconSize / height));
var size = ImGuiHelpers.ScaledVector2(width * scale, height * scale);
var cursor = ImGui.GetCursorPos();
ImGui.Image(icon.Handle, size);
ImGui.SameLine();
ImGui.SetCursorPos(
cursor + new Vector2(size.X + 4, size.Y - ImGui.GetTextLineHeightWithSpacing())
);
}
private void HoverStatus(StatusPayload status)
{
if (
Plugin.TextureProvider.GetFromGameIcon(status.Status.Value.Icon).GetWrapOrDefault() is
{ } icon
)
InlineIcon(icon);
var builder = new SeStringBuilder();
var nameValue = status.Status.Value.Name.ToString();
switch (status.Status.Value.StatusCategory)
{
case 1:
builder.AddUiForeground($"{SeIconChar.Buff.ToIconString()}{nameValue}", 517);
break;
case 2:
builder.AddUiForeground($"{SeIconChar.Debuff.ToIconString()}{nameValue}", 518);
break;
default:
builder.AddUiForeground(nameValue, 1);
break;
}
var name = ChunkUtil.ToChunks(builder.BuiltString, ChunkSource.None, null);
LogWindow.DrawChunks(name.ToList());
ImGui.Separator();
var desc = ChunkUtil.ToChunks(
status.Status.Value.Description.ToDalamudString(),
ChunkSource.None,
null
);
LogWindow.DrawChunks(desc.ToList());
}
private void HoverItem(ItemPayload item)
{
if (item.Kind == ItemKind.EventItem)
{
HoverEventItem(item);
return;
}
if (!item.Item.TryGetValue(out Item resolvedItem))
return;
if (
Plugin
.TextureProvider.GetFromGameIcon(new GameIconLookup(resolvedItem.Icon, item.IsHQ))
.GetWrapOrDefault() is
{ } icon
)
InlineIcon(icon);
var name = ChunkUtil.ToChunks(resolvedItem.Name.ToDalamudString(), ChunkSource.None, null);
LogWindow.DrawChunks(name.ToList());
ImGui.Separator();
var desc = ChunkUtil.ToChunks(
resolvedItem.Description.ToDalamudString(),
ChunkSource.None,
null
);
LogWindow.DrawChunks(desc.ToList());
}
private void HoverEventItem(ItemPayload payload)
{
if (!Sheets.EventItemSheet.TryGetRow(payload.RawItemId, out var itemRow))
return;
if (
Plugin
.TextureProvider.GetFromGameIcon(new GameIconLookup(itemRow.Icon))
.GetWrapOrDefault() is
{ } icon
)
InlineIcon(icon);
var name = ChunkUtil.ToChunks(itemRow.Name.ToDalamudString(), ChunkSource.None, null);
LogWindow.DrawChunks(name.ToList());
ImGui.Separator();
if (!Sheets.EventItemHelpSheet.TryGetRow(payload.RawItemId, out var itemHelpRow))
return;
LogWindow.DrawChunks(
ChunkUtil
.ToChunks(itemHelpRow.Description.ToDalamudString(), ChunkSource.None, null)
.ToList()
);
}
private void HoverUri(UriPayload uri)
{
ImGui.TextUnformatted(string.Format(Language.Context_URLDomain, uri.Uri.Authority));
ImGuiUtil.WarningText(Language.Context_URLWarning);
}
private void LeftClickPayload(Chunk chunk, Payload? payload)
{
switch (payload)
{
case MapLinkPayload map:
Plugin.GameGui.OpenMapWithMapLink(map);
break;
case QuestPayload quest:
GameFunctions.GameFunctions.OpenQuestLog(quest.Quest);
break;
case DalamudLinkPayload link:
ClickLinkPayload(chunk, payload, link);
break;
case DalamudPartyFinderPayload pf:
if (
pf.LinkType
== DalamudPartyFinderPayload.PartyFinderLinkType.PartyFinderNotification
)
GameFunctions.GameFunctions.OpenPartyFinder();
else
GameFunctions.GameFunctions.OpenPartyFinder(pf.ListingId);
break;
case ChatTwoPartyFinderPayload pf:
GameFunctions.GameFunctions.OpenPartyFinder(pf.Id);
break;
case AchievementPayload achievement:
GameFunctions.GameFunctions.OpenAchievement(achievement.Id);
break;
case RawPayload raw:
if (Equals(raw, ChunkUtil.PeriodicRecruitmentLink))
GameFunctions.GameFunctions.OpenPartyFinder();
break;
case UriPayload uri:
WrapperUtil.TryOpenUri(uri.Uri);
break;
default:
RightClickPayload(chunk, payload);
break;
}
}
private void ClickLinkPayload(Chunk chunk, Payload payload, DalamudLinkPayload link)
{
if (chunk.GetSeString() is not { } source)
return;
var start = source.Payloads.IndexOf(payload);
var end = source.Payloads.IndexOf(RawPayload.LinkTerminator, start == -1 ? 0 : start);
if (start == -1 || end == -1)
return;
var payloads = source.Payloads.Skip(start).Take(end - start + 1).ToList();
if (
!Plugin.ChatGui.RegisteredLinkHandlers.TryGetValue(
(link.Plugin, link.CommandId),
out var value
)
)
{
_logger.LogWarning("Could not find DalamudLinkHandlers");
return;
}
try
{
// Running XivCommon SendChat instantly, without RunOnTick, leads to a game freeze, for whatever reason
Plugin.Framework.RunOnTick(() => value.Invoke(link.CommandId, new SeString(payloads)));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing DalamudLinkPayload handler");
}
}
private void RightClickPayload(Chunk chunk, Payload? payload)
{
Popup = (chunk, payload);
ImGui.OpenPopup(PopupId);
}
private void DrawItemPopup(ItemPayload payload)
{
if (payload.Kind == ItemKind.EventItem)
{
DrawEventItemPopup(payload);
return;
}
if (!Sheets.ItemSheet.TryGetRow(payload.ItemId, out var itemRow))
return;
var hq = payload.Kind == ItemKind.Hq;
if (
Plugin
.TextureProvider.GetFromGameIcon(new GameIconLookup(itemRow.Icon, hq))
.GetWrapOrDefault() is
{ } icon
)
InlineIcon(icon);
var name = itemRow.Name.ToDalamudString();
// hq symbol
if (hq)
name.Payloads.Add(new TextPayload(" "));
else if (payload.Kind == ItemKind.Collectible)
name.Payloads.Add(new TextPayload(" "));
LogWindow.DrawChunks(ChunkUtil.ToChunks(name, ChunkSource.None, null).ToList(), false);
ImGui.Separator();
var realItemId = payload.RawItemId;
if (itemRow.EquipSlotCategory.RowId != 0)
{
if (ImGui.Selectable(Language.Context_TryOn))
GameFunctions.Context.TryOn(realItemId, 0);
if (ImGui.Selectable(Language.Context_ItemComparison))
GameFunctions.Context.OpenItemComparison(realItemId);
}
if (itemRow.ItemSearchCategory.Value.Category == 3)
if (ImGui.Selectable(Language.Context_SearchRecipes))
GameFunctions.Context.SearchForRecipesUsingItem(payload.ItemId);
if (ImGui.Selectable(Language.Context_SearchForItem))
GameFunctions.Context.SearchForItem(realItemId);
if (ImGui.Selectable(Language.Context_Link))
GameFunctions.Context.LinkItem(realItemId);
if (ImGui.Selectable(Language.Context_CopyItemName))
ImGui.SetClipboardText(name.TextValue);
}
private void DrawEventItemPopup(ItemPayload payload)
{
if (payload.Kind != ItemKind.EventItem)
return;
if (!Sheets.EventItemSheet.HasRow(payload.ItemId))
return;
var item = Sheets.EventItemSheet.GetRow(payload.ItemId);
if (
Plugin
.TextureProvider.GetFromGameIcon(new GameIconLookup(item.Icon))
.GetWrapOrDefault() is
{ } icon
)
InlineIcon(icon);
LogWindow.DrawChunks(
ChunkUtil.ToChunks(item.Name.ToDalamudString(), ChunkSource.None, null).ToList(),
false
);
ImGui.Separator();
var realItemId = payload.RawItemId;
if (ImGui.Selectable(Language.Context_Link))
GameFunctions.Context.LinkItem(realItemId);
if (ImGui.Selectable(Language.Context_CopyItemName))
ImGui.SetClipboardText(item.Name.ToString());
}
private void DrawPlayerPopup(Chunk chunk, PlayerPayload player)
{
// Possible that GMs return a null payload
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
if (player == null)
return;
var world = player.World;
if (chunk.Message?.Code.Type == ChatType.FreeCompanyLoginLogout)
if (Plugin.PlayerState.HomeWorld.IsValid)
world = Plugin.PlayerState.HomeWorld;
var name = new List<Chunk> { new TextChunk(ChunkSource.None, null, player.PlayerName) };
if (world.Value.IsPublic)
{
name.AddRange([
new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld),
new TextChunk(ChunkSource.None, null, world.Value.Name.ExtractText()),
]);
}
LogWindow.DrawChunks(name, false);
ImGui.Separator();
var validContentId = chunk.Message?.ContentId is not (null or 0);
if (ImGui.Selectable(Language.Context_SendTell))
{
// Eureka, Bozja and Occult need special handling as tells work different
if (!Sheets.IsInForay())
{
LogWindow.Chat = $"/tell {player.PlayerName}";
if (world.Value.IsPublic)
LogWindow.Chat += $"@{world.Value.Name}";
LogWindow.Chat += " ";
}
else if (validContentId)
{
LogWindow.Plugin.Functions.Chat.SetEurekaTellChannel(
player.PlayerName,
world.Value.Name.ToString(),
(ushort)world.RowId,
0,
chunk.Message!.ContentId,
0,
false
);
}
LogWindow.Activate = true;
}
if (world.Value.IsPublic)
{
var party = Plugin.PartyList;
var leader = party[(int)party.PartyLeaderIndex]?.ContentId;
var isLeader = party.Length == 0 || Plugin.PlayerState.ContentId == leader;
var member = party.FirstOrDefault(member =>
member.Name.TextValue == player.PlayerName && member.World.RowId == world.RowId
);
var isInParty = member != null;
var inInstance = GameFunctions.GameFunctions.IsInInstance();
var inPartyInstance =
Sheets
.TerritorySheet.GetRow(Plugin.ClientState.TerritoryType)
.TerritoryIntendedUse.RowId
is (41 or 47 or 48 or 52 or 53 or 61);
if (isLeader)
{
if (!isInParty)
{
if (inInstance && inPartyInstance)
{
if (validContentId && ImGui.Selectable(Language.Context_InviteToParty))
GameFunctions.Party.InviteInInstance(chunk.Message!.ContentId);
}
else if (!inInstance)
{
using var menu = ImRaii.Menu(Language.Context_InviteToParty);
if (menu.Success)
{
if (ImGui.Selectable(Language.Context_InviteToParty_SameWorld))
GameFunctions.Party.InviteSameWorld(
player.PlayerName,
(ushort)world.RowId,
chunk.Message?.ContentId ?? 0
);
if (
validContentId
&& ImGui.Selectable(Language.Context_InviteToParty_DifferentWorld)
)
GameFunctions.Party.InviteOtherWorld(
chunk.Message!.ContentId,
(ushort)world.RowId
);
}
}
}
if (isInParty && member != null && (!inInstance || (inInstance && inPartyInstance)))
{
if (ImGui.Selectable(Language.Context_Promote))
GameFunctions.Party.Promote(player.PlayerName, member.ContentId);
if (ImGui.Selectable(Language.Context_KickFromParty))
GameFunctions.Party.Kick(player.PlayerName, member.ContentId);
}
}
var isFriend = GameFunctions
.GameFunctions.GetFriends()
.Any(friend =>
friend.NameString == player.PlayerName && friend.HomeWorld == world.RowId
);
if (!isFriend && ImGui.Selectable(Language.Context_SendFriendRequest))
LogWindow.Plugin.Functions.SendFriendRequest(
player.PlayerName,
(ushort)world.RowId
);
using (var menuBlockFunctions = ImRaii.Menu(Language.Context_BlockFunctions))
{
if (menuBlockFunctions.Success)
{
if (ImGui.Selectable(Language.Context_AddToBlacklist))
LogWindow.Plugin.Functions.AddToBlacklist(
player.PlayerName,
(ushort)world.RowId
);
if (chunk.Message != null)
{
var message = chunk.Message;
if (
message.AccountId != 0
&& ImGui.Selectable(Language.Context_AddToMuteList)
)
LogWindow.Plugin.Functions.AddToMuteList(
message.AccountId,
message.ContentId,
player.PlayerName,
(short)world.RowId
);
if (ImGui.Selectable(Language.Context_AddToTermsFilter))
LogWindow.Plugin.Functions.AddToTermsList(message.ContentSource);
}
}
}
if (
GameFunctions.GameFunctions.IsMentor()
&& ImGui.Selectable(Language.Context_InviteToNoviceNetwork)
)
GameFunctions.Context.InviteToNoviceNetwork(player.PlayerName, (ushort)world.RowId);
}
var inputChannel = chunk.Message?.Code.Type.ToInputChannel();
if (inputChannel != null && ImGui.Selectable(Language.Context_ReplyInSelectedChatMode))
{
LogWindow.SetChannel(inputChannel.Value);
LogWindow.Activate = true;
}
if (ImGui.Selectable(Language.Context_Target) && FindCharacterForPayload(player) is { } obj)
Plugin.TargetManager.Target = obj;
if (validContentId && ImGui.Selectable(Language.Context_AdventurerPlate))
if (!GameFunctions.GameFunctions.TryOpenAdventurerPlate(chunk.Message!.ContentId))
WrapperUtil.AddNotification(
Language.Context_AdventurerPlateError,
NotificationType.Warning
);
}
private IPlayerCharacter? FindCharacterForPayload(PlayerPayload payload)
{
foreach (var obj in Plugin.ObjectTable)
{
if (obj is not IPlayerCharacter character)
continue;
if (character.Name.TextValue != payload.PlayerName)
continue;
if (payload.World.Value.IsPublic && character.HomeWorld.RowId != payload.World.RowId)
continue;
return character;
}
return null;
}
private void DrawUriPopup(UriPayload uri)
{
ImGui.TextUnformatted(string.Format(Language.Context_URLDomain, uri.Uri.Authority));
ImGuiUtil.WarningText(Language.Context_URLWarning, false);
ImGui.Separator();
if (ImGui.Selectable(Language.Context_OpenInBrowser))
WrapperUtil.TryOpenUri(uri.Uri);
if (ImGui.Selectable(Language.Context_CopyLink))
{
ImGui.SetClipboardText(uri.Uri.ToString());
WrapperUtil.AddNotification(
Language.Context_CopyLinkNotification,
NotificationType.Info
);
}
}
private void DrawStatusPopup(StatusPayload status)
{
if (
Plugin
.TextureProvider.GetFromGameIcon(new GameIconLookup(status.Status.Value.Icon))
.GetWrapOrDefault() is
{ } icon
)
InlineIcon(icon);
var builder = new SeStringBuilder();
var nameValue = status.Status.Value.Name.ToString();
switch (status.Status.Value.StatusCategory)
{
case 1:
builder.AddUiForeground($"{SeIconChar.Buff.ToIconString()}{nameValue}", 517);
break;
case 2:
builder.AddUiForeground($"{SeIconChar.Debuff.ToIconString()}{nameValue}", 518);
break;
default:
builder.AddUiForeground(nameValue, 1);
break;
}
LogWindow.DrawChunks(
ChunkUtil.ToChunks(builder.BuiltString, ChunkSource.None, null).ToList(),
false
);
ImGui.Separator();
if (ImGui.Selectable(Language.Context_Link))
{
GameFunctions.Context.LinkStatus(status.Status.RowId);
LogWindow.Chat += " <status>";
}
}
}
+1 -19
View File
@@ -97,7 +97,6 @@ public sealed class Plugin : IAsyncDalamudPlugin
// consistent across all properties for clarity.
internal Ui.Windows.MainWindow MainWindow { get; private set; } = null!;
public SettingsWindow SettingsWindow { get; private set; } = null!;
public ChatLogWindow ChatLogWindow { get; private set; } = null!;
public DbViewer DbViewer { get; private set; } = null!;
public InputPreview InputPreview { get; private set; } = null!;
public CommandHelpWindow CommandHelpWindow { get; private set; } = null!;
@@ -114,7 +113,6 @@ public sealed class Plugin : IAsyncDalamudPlugin
internal TypingIpc TypingIpc { get; private set; } = null!;
internal FontManager FontManager { get; private set; } = null!;
internal Themes.ThemeRegistry ThemeRegistry { get; private set; } = null!;
internal Ui.StatusBar StatusBar { get; private set; } = null!;
internal Integrations.HonorificService HonorificService { get; private set; } = null!;
internal Integrations.CustomAudioPlayer CustomAudioPlayer { get; private set; } = null!;
@@ -291,12 +289,10 @@ public sealed class Plugin : IAsyncDalamudPlugin
ExtraChat = _host.Services.GetRequiredService<ExtraChat>();
HonorificService = _host.Services.GetRequiredService<Integrations.HonorificService>();
CustomAudioPlayer = _host.Services.GetRequiredService<Integrations.CustomAudioPlayer>();
StatusBar = _host.Services.GetRequiredService<Ui.StatusBar>();
MessageManager = _host.Services.GetRequiredService<MessageManager>();
AutoTellTabsService = _host.Services.GetRequiredService<AutoTellTabsService>();
MainWindow = _host.Services.GetRequiredService<Ui.Windows.MainWindow>();
ChatLogWindow = _host.Services.GetRequiredService<ChatLogWindow>();
SettingsWindow = _host.Services.GetRequiredService<SettingsWindow>();
DbViewer = _host.Services.GetRequiredService<DbViewer>();
InputPreview = _host.Services.GetRequiredService<InputPreview>();
@@ -342,7 +338,6 @@ public sealed class Plugin : IAsyncDalamudPlugin
new SelfTests.FontManagerCtorSmokeStep(this),
new SelfTests.FontPushSmokeStep(this),
new SelfTests.WizardStateSmokeStep(this),
new SelfTests.QuickPickerSelfTestStep(this),
new SelfTests.FoxBannerTextureSmokeStep(this),
]);
@@ -946,18 +941,14 @@ public sealed class Plugin : IAsyncDalamudPlugin
// free on built-in themes and ~1 stat/second on custom themes.
ThemeRegistry.RefreshActiveIfStale();
// Theme engine is always active; Classic is a theme, not a disabled state.
using IDisposable _style = HellionStyle.PushGlobal(
using IDisposable _style = Ui.StyleEngine.GlobalStyleScope.Push(
ThemeRegistry.Active,
ThemeRegistry,
Config.WindowOpacity
);
ChatLogWindow.BeginFrame();
if (Config.HideInLoadingScreens && Condition[ConditionFlag.BetweenAreas])
{
ChatLogWindow.FinalizeFrame();
TypingIpc.Update();
return;
}
@@ -970,28 +961,19 @@ public sealed class Plugin : IAsyncDalamudPlugin
)
)
{
ChatLogWindow.FinalizeFrame();
TypingIpc.Update();
return;
}
ChatLogWindow.HideStateCheck();
Interface.UiBuilder.DisableUserUiHide = !Config.HideWhenUiHidden;
ChatLogWindow.DefaultText = ImGui.GetStyle().Colors[(int)ImGuiCol.Text];
// RegularFont is nullable only because the live rebuild path
// disposes it before reassigning; both ends of that swap happen on
// this same draw thread, so it cannot be null here.
// v1.5.3 fix: also push RegularFont when the bundled Inter Light is
// selected. Without this, UseHellionFont=true silently fell back to
// the FFXIV Axis font because the Appearance tab forces FontsEnabled
// off in that branch, and the bundled font never made it into draw.
var useRegularFont = Config.FontsEnabled || Config.UseHellionFont;
using ((useRegularFont ? FontManager.RegularFont! : FontManager.Axis).Push())
WindowSystem.Draw();
ChatLogWindow.FinalizeFrame();
TypingIpc.Update();
FileDialogManager.Draw();
+2 -8
View File
@@ -80,7 +80,6 @@ internal static class PluginHostFactory
services.AddSingleton(sp => new FontManager(
sp.GetRequiredService<IDalamudPluginInterface>()
));
services.AddSingleton(_ => new StatusBar());
services.AddSingleton(sp => new IpcManager(sp.GetRequiredService<ILogger<IpcManager>>()));
services.AddSingleton(sp => new ExtraChat(sp.GetRequiredService<ILogger<ExtraChat>>()));
@@ -181,11 +180,6 @@ internal static class PluginHostFactory
// Block C — Windows. WindowSystem.AddWindow is called from
// PluginLifecycle.LoadAsync on the framework thread.
services.AddSingleton(sp => new ChatLogWindow(
sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<ILogger<ChatLogWindow>>(),
sp.GetRequiredService<ILoggerFactory>()
));
services.AddSingleton(sp => new SettingsWindow(
sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<ILoggerFactory>()
@@ -194,8 +188,8 @@ internal static class PluginHostFactory
sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<ILogger<DbViewer>>()
));
services.AddSingleton(sp => new InputPreview(sp.GetRequiredService<ChatLogWindow>()));
services.AddSingleton(sp => new CommandHelpWindow(sp.GetRequiredService<ChatLogWindow>()));
services.AddSingleton(sp => new InputPreview(sp.GetRequiredService<Plugin>()));
services.AddSingleton(sp => new CommandHelpWindow(sp.GetRequiredService<Plugin>()));
services.AddSingleton(sp => new SeStringDebugger(sp.GetRequiredService<Plugin>()));
services.AddSingleton(sp => new DebuggerWindow(sp.GetRequiredService<Plugin>()));
services.AddSingleton(sp => new FirstRunWizard(sp.GetRequiredService<Plugin>()));
+1 -1
View File
@@ -58,7 +58,7 @@ internal sealed class PluginLifecycle : IAsyncDisposable
private static void RegisterWindows(Plugin plugin)
{
plugin.WindowSystem.AddWindow(plugin.ChatLogWindow);
plugin.WindowSystem.AddWindow(plugin.MainWindow);
plugin.WindowSystem.AddWindow(plugin.SettingsWindow);
plugin.WindowSystem.AddWindow(plugin.DbViewer);
plugin.WindowSystem.AddWindow(plugin.InputPreview);
@@ -1,64 +0,0 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Plugin.SelfTest;
using HellionChat.Resources;
namespace HellionChat.SelfTests;
// Verifies the v1.5.4 PM-2 quick-picker plumbing without rendering:
// resource strings resolve, the theme registry yields the expected
// minimum built-in count, and Config.Tabs is populated.
internal sealed class QuickPickerSelfTestStep : ISelfTestStep
{
private readonly Plugin plugin;
public QuickPickerSelfTestStep(Plugin plugin)
{
this.plugin = plugin;
}
public string Name => "Hellion Chat - Quick picker plumbing";
public SelfTestStepResult RunStep()
{
if (string.IsNullOrWhiteSpace(HellionStrings.Settings_QuickPicker_Tooltip))
{
ImGui.Text("Settings_QuickPicker_Tooltip is empty in the active locale.");
return SelfTestStepResult.Fail;
}
if (string.IsNullOrWhiteSpace(HellionStrings.Settings_QuickPicker_Themes_Header))
{
ImGui.Text("Settings_QuickPicker_Themes_Header is empty in the active locale.");
return SelfTestStepResult.Fail;
}
if (string.IsNullOrWhiteSpace(HellionStrings.Settings_QuickPicker_Tabs_Header))
{
ImGui.Text("Settings_QuickPicker_Tabs_Header is empty in the active locale.");
return SelfTestStepResult.Fail;
}
var registry = this.plugin.ThemeRegistry;
if (registry is null)
{
ImGui.Text("ThemeRegistry not resolved.");
return SelfTestStepResult.Fail;
}
var builtIns = registry.AllBuiltIns().ToList();
if (builtIns.Count < 10)
{
ImGui.Text($"Expected at least 10 built-in themes, found {builtIns.Count}.");
return SelfTestStepResult.Fail;
}
var tabs = Plugin.Config.Tabs;
if (tabs is null || tabs.Count == 0)
{
ImGui.Text("Config.Tabs is empty.");
return SelfTestStepResult.Fail;
}
return SelfTestStepResult.Pass;
}
public void CleanUp() { }
}
-15
View File
@@ -1,15 +0,0 @@
namespace HellionChat.Ui;
internal class AutoCompleteInfo
{
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;
}
}
-70
View File
@@ -1,70 +0,0 @@
namespace HellionChat.Ui;
// Deterministic hash-based color and icon tinting for Auto-Tell sidebar tabs.
// Same tell partner (name+world) always produces the same color and icon across
// sessions. Pure string logic, no Dalamud dependency — testable without game refs.
internal static class AutoTellTabTint
{
// Fallback for invalid input (empty name or world=0). White matches
// TextPrimary default so the sidebar stays visually consistent.
public const uint Fallback = 0xFFFFFFFFu;
// 12 saturated mid-bright colors from the built-in theme pool, readable
// on dark backgrounds. Collision risk is low at realistic 1-5 active tells.
// RGBA format, matching ColourUtil.RgbaToAbgr convention.
public static readonly IReadOnlyList<uint> Palette = new uint[]
{
0x00BED2FFu, // Arctic Cyan
0xF97316FFu, // Ember Orange
0xB585FFFFu, // Light Cosmic Purple
0xE374E8FFu, // Bloom Magenta
0x5DD39EFFu, // Mint Green
0xF0AD4EFFu, // Warning Yellow
0xE85C6AFFu, // Coral
0x5CB85CFFu, // Status Green
0x6278FFFFu, // Bloom Blue
0xC9982EFFu, // Warm Gold
0x9CCB7CFFu, // Soft Sage
0xE85D04FFu, // Deep Ember
};
public static uint For(string name, uint world)
{
if (string.IsNullOrEmpty(name) || world == 0)
return Fallback;
// Mask to positive range so modulo always yields a valid index.
var key = $"{name}@{world}";
var hash = (uint)(key.GetHashCode() & 0x7FFFFFFF);
return Palette[(int)(hash % Palette.Count)];
}
// 7 visually distinct FA glyphs that make sense in a tell context.
// Excludes cog/comment/users — those read as system or group tabs.
public static readonly IReadOnlyList<string> IconPool = new[]
{
"envelope",
"star",
"heart",
"bell",
"bookmark",
"flag",
"fire",
};
// "envelope" matches the tell context better than the old hardcoded "clock".
public const string IconFallback = "envelope";
public static string IconFor(string name, uint world)
{
if (string.IsNullOrEmpty(name) || world == 0)
return IconFallback;
// Reversed key ("world@name") gives icon and color independent variation
// so the same tell partner doesn't always get the same color+icon pair.
// 7 icons x 12 colors = 84 distinct combinations.
var key = $"{world}@{name}";
var hash = (uint)(key.GetHashCode() & 0x7FFFFFFF);
return IconPool[(int)(hash % IconPool.Count)];
}
}
-251
View File
@@ -1,251 +0,0 @@
using System;
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility.Raii;
using HellionChat._Helpers;
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Util;
namespace HellionChat.Ui;
// Input bar component for pop-out windows. Render() is a stub — the main
// window input layer stays in ChatLogWindow to avoid a high-risk extract.
// RenderCompact() is the only v0.6.0 deliverable; Render() can be filled
// in a later cycle if needed.
public sealed class ChatInputBar
{
private readonly Plugin _plugin;
private readonly ChatLogWindow _host;
private readonly Func<Tab?> _activeTabAccessor;
private readonly InputState _state = new();
// UI-11: the buffer for which a plugin-disclosure warning was already
// shown. A second Enter on the same buffer sends it anyway; editing the
// buffer clears the arming so the next send is re-checked.
private string? _disclosureArmedBuffer;
public ChatInputBar(Plugin plugin, ChatLogWindow host, Func<Tab?> activeTabAccessor)
{
_plugin = plugin;
_host = host;
_activeTabAccessor = activeTabAccessor;
}
public InputState State => _state;
public bool IsFocused { get; private set; }
// Stub — main window input is handled in ChatLogWindow.
public void Render() { }
// Compact layout for pop-out windows: channel icon button left, text
// input right. Auto-translate is intentionally excluded — the upstream
// popup isn't instanciable per window without a larger refactor, and
// typical pop-out use cases rarely need it. Can be added later if
// tester feedback warrants it.
//
// Channel switching is global via Plugin.Functions.Chat (FFXIV API).
// Text buffer and history cursor are independent per pop-out.
public void RenderCompact()
{
var tab = _activeTabAccessor();
if (tab == null)
return;
DrawChannelIconButton(tab);
ImGui.SameLine();
DrawCompactInput(tab);
}
private void DrawCompactInput(Tab tab)
{
var inputWidth = ImGui.GetContentRegionAvail().X;
if (inputWidth < 60f)
inputWidth = 60f;
ImGui.SetNextItemWidth(inputWidth);
// CallbackHistory wires Up/Down navigation to InputHistoryService.
// Submit detected via IsItemDeactivated + Enter, not EnterReturnsTrue
// (matches ChatLogWindow behavior).
const ImGuiInputTextFlags flags = ImGuiInputTextFlags.CallbackHistory;
ImGui.InputText(
$"##chat-compact-input-{tab.Identifier}",
ref _state.Buffer,
500,
flags,
CompactCallback
);
IsFocused = ImGui.IsItemActive();
if (
ImGui.IsItemDeactivated()
&& (ImGui.IsKeyDown(ImGuiKey.Enter) || ImGui.IsKeyDown(ImGuiKey.KeypadEnter))
)
{
SubmitCompact(tab);
}
// UI-11: disclosure warning, visible only while an armed buffer is held
// unchanged. Editing the buffer clears the condition automatically.
if (
Plugin.Config.NotifyPluginDisclosure
&& _disclosureArmedBuffer is not null
&& _state.Buffer == _disclosureArmedBuffer
)
{
ImGui.TextColored(
ImGuiColors.DalamudYellow,
HellionStrings.ChatInput_PluginDisclosure_Warning
);
}
}
// TEST-MIRROR: ../_Helpers/CompactInputSubmitter.cs
private void SubmitCompact(Tab tab)
{
if (
Plugin.Config.NotifyPluginDisclosure
&& _state.Buffer != _disclosureArmedBuffer
&& PluginDisclosureScanner.ContainsPrivateUseGlyph(_state.Buffer)
)
{
// First send attempt on this exact buffer: arm and hold. The buffer
// is kept, the warning renders, the user can press Enter again.
_disclosureArmedBuffer = _state.Buffer;
return;
}
_disclosureArmedBuffer = null;
CompactInputSubmitter.TrySubmit(_state, tab, _host.SendChatBoxFromExternal);
}
// History navigation callback. Cursor math delegated to
// CompactInputHistoryNavigator; ImGui buffer splice stays here.
// TEST-MIRROR: ../_Helpers/CompactInputHistoryNavigator.cs
private int CompactCallback(scoped ref ImGuiInputTextCallbackData data)
{
if (data.EventFlag != ImGuiInputTextFlags.CallbackHistory)
return 0;
var direction = data.EventKey switch
{
ImGuiKey.UpArrow => CompactInputHistoryNavigator.Direction.Up,
ImGuiKey.DownArrow => CompactInputHistoryNavigator.Direction.Down,
_ => (CompactInputHistoryNavigator.Direction?)null,
};
if (direction is null)
return 0;
var (cursor, replacement) = CompactInputHistoryNavigator.Navigate(
direction.Value,
_state.HistoryCursor,
_state.Buffer,
() => InputHistoryService.Count,
InputHistoryService.Push,
InputHistoryService.GetByCursor
);
_state.HistoryCursor = cursor;
if (replacement is null)
return 0;
data.DeleteChars(0, data.BufTextLen);
data.InsertChars(0, replacement);
return 0;
}
private void DrawChannelIconButton(Tab tab)
{
var inputType = tab.CurrentChannel.UseTempChannel
? tab.CurrentChannel.TempChannel.ToChatType()
: tab.CurrentChannel.Channel.ToChatType();
var rgba = Plugin.Config.ChatColours.TryGetValue(inputType, out var c)
? c
: (inputType.DefaultColor() ?? 0xFFFFFFFFu);
var v3 = ColourUtil.RgbaToVector3(rgba);
var bg = new Vector4(v3.X, v3.Y, v3.Z, 1f);
// Black foreground on bright backgrounds, white on dark.
var luminance = 0.2126f * v3.X + 0.7152f * v3.Y + 0.0722f * v3.Z;
var fg = luminance > 0.55f ? new Vector4(0f, 0f, 0f, 1f) : new Vector4(1f, 1f, 1f, 1f);
const string popupId = "chat-channel-picker-compact";
const float buttonSize = 22f;
using (ImRaii.PushColor(ImGuiCol.Button, bg))
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, bg))
using (ImRaii.PushColor(ImGuiCol.ButtonActive, bg))
using (ImRaii.PushColor(ImGuiCol.Text, fg))
{
// Single-letter glyph as a quick visual cue until a proper icon font lands.
var label = ChannelGlyph(inputType);
if (
ImGui.Button($"{label}##chan-compact", new Vector2(buttonSize, buttonSize))
&& tab.Channel is null
)
ImGui.OpenPopup(popupId);
}
if (tab.Channel is not null && ImGui.IsItemHovered())
ImGui.SetTooltip(Resources.Language.ChatLog_SwitcherDisabled);
else if (ImGui.IsItemHovered())
ImGui.SetTooltip(inputType.Name());
using (var popup = ImRaii.Popup(popupId))
{
if (popup)
{
var channels = _host.GetValidChannels();
foreach (var (name, channel) in channels)
if (ImGui.Selectable(name))
_host.SetChannel(channel);
}
}
}
private static string ChannelGlyph(ChatType type) =>
type switch
{
ChatType.Say => "S",
ChatType.Yell => "Y",
ChatType.Shout => "!",
ChatType.TellIncoming or ChatType.TellOutgoing => "T",
ChatType.Party or ChatType.CrossParty => "P",
ChatType.Alliance => "A",
ChatType.FreeCompany => "F",
ChatType.NoviceNetwork => "N",
ChatType.Linkshell1 => "1",
ChatType.Linkshell2 => "2",
ChatType.Linkshell3 => "3",
ChatType.Linkshell4 => "4",
ChatType.Linkshell5 => "5",
ChatType.Linkshell6 => "6",
ChatType.Linkshell7 => "7",
ChatType.Linkshell8 => "8",
ChatType.CrossLinkshell1 => "①",
ChatType.CrossLinkshell2 => "②",
ChatType.CrossLinkshell3 => "③",
ChatType.CrossLinkshell4 => "④",
ChatType.CrossLinkshell5 => "⑤",
ChatType.CrossLinkshell6 => "⑥",
ChatType.CrossLinkshell7 => "⑦",
ChatType.CrossLinkshell8 => "⑧",
_ => "?",
};
// Forwards a tab-cycle keybind delta to the host (single source of truth).
public void HandleKeybindForward(int delta) => _host.ChangeTabDelta(delta);
}
// Per-window input state. Each ChatInputBar owns one so pop-outs and the
// main window keep independent buffers and history cursors.
public sealed class InputState
{
public string Buffer = string.Empty;
public InputChannel? Channel;
public int HistoryCursor = -1;
}
File diff suppressed because it is too large Load Diff
+7 -51
View File
@@ -1,23 +1,20 @@
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Windowing;
using Dalamud.Utility;
using HellionChat.Util;
using Lumina.Text.ReadOnly;
namespace HellionChat.Ui;
// Slash-command help popup is offline while the chat input pipeline is
// rebuilt. UpdateContent stays callable so the input layer can keep its
// integration shape, but it always leaves the window closed for now.
public class CommandHelpWindow : Window
{
private ChatLogWindow LogWindow { get; }
private ReadOnlySeString? CommandDescription { get; set; }
private readonly Plugin _plugin;
internal CommandHelpWindow(ChatLogWindow logWindow)
internal CommandHelpWindow(Plugin plugin)
: base("command help##chat2-commandhelp")
{
LogWindow = logWindow;
_plugin = plugin;
Flags =
ImGuiWindowFlags.NoSavedSettings
| ImGuiWindowFlags.NoTitleBar
@@ -25,55 +22,14 @@ public class CommandHelpWindow : Window
| ImGuiWindowFlags.NoResize
| ImGuiWindowFlags.NoFocusOnAppearing
| ImGuiWindowFlags.AlwaysAutoResize;
RespectCloseHotkey = false;
DisableWindowSounds = true;
}
// Sets IsOpen to true if it should be drawn
public void UpdateContent(ReadOnlySeString commandDesc)
{
CommandDescription = commandDesc;
var width = 350;
var scaledWidth = width * ImGuiHelpers.GlobalScale;
var pos = LogWindow.LastWindowPos;
switch (Plugin.Config.CommandHelpSide)
{
case CommandHelpSide.Right:
pos.X += LogWindow.LastWindowSize.X;
break;
case CommandHelpSide.Left:
pos.X -= scaledWidth;
break;
case CommandHelpSide.None:
default:
IsOpen = false;
return;
}
Position = pos;
SizeConstraints = new WindowSizeConstraints
{
// Use scaledWidth here so the size constraints stay in the same
// coordinate space as Position above; otherwise the help window
// ends up the wrong width at non-100% DPI.
MinimumSize = new Vector2(scaledWidth, 0),
MaximumSize = LogWindow.LastWindowSize with { X = scaledWidth },
};
IsOpen = true;
}
public override void Draw()
{
if (CommandDescription == null)
return;
LogWindow.DrawChunks(
ChunkUtil
.ToChunks(CommandDescription.Value.ToDalamudString(), ChunkSource.None, null)
.ToList()
);
}
public override void Draw() { }
}
+2 -2
View File
@@ -391,10 +391,10 @@ public class DbViewer : Window
ImGuiUtil.Tooltip(message.Code.Type.Name());
ImGui.TableNextColumn();
Plugin.ChatLogWindow.DrawChunks(message.Sender);
ImGui.TextUnformatted(string.Join("", message.Sender.Select(c => c.StringValue())));
ImGui.TableNextColumn();
Plugin.ChatLogWindow.DrawChunks(message.Content);
ImGui.TextWrapped(string.Join("", message.Content.Select(c => c.StringValue())));
}
}
+7 -17
View File
@@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility;
@@ -9,16 +9,18 @@ using Lumina.Text.ReadOnly;
namespace HellionChat.Ui;
// Dev tool. Reduced to the parts that survive without the legacy chat
// window: current-tab channel state and the vanilla chat channel label.
// The chat-window cursor and payload-handler counters come back once the
// new chat layer surfaces equivalent state.
public class DebuggerWindow : Window, IDisposable
{
private readonly Plugin Plugin;
private readonly ChatLogWindow ChatLogWindow;
public DebuggerWindow(Plugin plugin)
: base("Debugger###chat2-debugger")
{
Plugin = plugin;
ChatLogWindow = plugin.ChatLogWindow;
SizeConstraints = new WindowSizeConstraints
{
@@ -30,29 +32,18 @@ public class DebuggerWindow : Window, IDisposable
DisableWindowSounds = true;
}
public void Dispose()
{
// Slash-command tear-down moved to Plugin.TearDownCommands.
}
public void Dispose() { }
public override unsafe void Draw()
{
var agent = (nint)AgentItemDetail.Instance();
ImGui.TextUnformatted($"Current Cursor Pos: {ChatLogWindow.CursorPos}");
if (ImGui.Selectable($"Agent Address: {agent:X}"))
ImGui.SetClipboardText(agent.ToString("X"));
ImGuiHelpers.ScaledDummy(5.0f);
ImGui.TextUnformatted($"Handle Tooltips: {ChatLogWindow.PayloadHandler.HandleTooltips}");
ImGui.TextUnformatted($"Hovered Item: {ChatLogWindow.PayloadHandler.HoveredItem}");
ImGui.TextUnformatted($"Hover Counter: {ChatLogWindow.PayloadHandler.HoverCounter}");
ImGui.TextUnformatted(
$"Last Hover Counter: {ChatLogWindow.PayloadHandler.LastHoverCounter}"
);
ImGui.TextDisabled("Payload handler counters: offline during the chat rebuild.");
ImGuiHelpers.ScaledDummy(5.0f);
ImGui.TextColored(ImGuiColors.DalamudOrange, "Current Tab");
ImGui.TextUnformatted($"Name: {Plugin.CurrentTab.Name}");
ImGui.TextUnformatted(
@@ -74,7 +65,6 @@ public class DebuggerWindow : Window, IDisposable
);
ImGuiHelpers.ScaledDummy(5.0f);
ImGui.TextColored(ImGuiColors.DalamudOrange, "Vanilla Chat");
ImGui.TextUnformatted(
$"Channel: {new ReadOnlySeString(AgentChatLog.Instance()->ChannelLabel).ExtractText()}"
-17
View File
@@ -1,17 +0,0 @@
namespace HellionChat.Ui;
internal static class HellionStyleHelpers
{
// Child surfaces are drawn over WindowBg, so at partial window opacity
// the theme's own ChildBg alpha would double-multiply and read too solid.
// Above ~full opacity we preserve the theme alpha; below it we wipe to 0
// so WindowBg alone carries the coverage. The 0.999f threshold is a
// float-imprecision guard around the user-facing 100% slider value.
// TEST-MIRROR: ../../Hellion Build test/_Helpers/HellionStyleHelpersTests.cs
public static uint ResolveChildBgAlpha(uint themeChildBgRgba, float windowOpacity)
{
var alphaPreserved = windowOpacity >= 0.999f;
var childBgAlpha = alphaPreserved ? (themeChildBgRgba & 0xFFu) : 0u;
return (themeChildBgRgba & 0xFFFFFF00u) | childBgAlpha;
}
}
+12 -275
View File
@@ -1,40 +1,20 @@
using System.Numerics;
using System.Text;
using System.Text.RegularExpressions;
using Dalamud.Bindings.ImGui;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing;
using Dalamud.Plugin.Services;
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Util;
namespace HellionChat.Ui;
public partial class InputPreview : Window
// Pre-send chunk preview is offline while the chat input pipeline is
// rebuilt. The window stays in the system so the DI graph keeps a single
// shape across cycles, but DrawConditions always returns false until the
// new preview lands on top of the components layer.
public class InputPreview : Window
{
private ChatLogWindow LogWindow { get; }
private readonly Plugin _plugin;
private bool Drawing;
private bool HasEvaluation;
internal float PreviewHeight;
private int LastLength;
private Message? PreviewMessage;
private int CursorPosition;
private bool NextChunkIsAutoTranslate;
internal int SelectedCursorPos = -1;
internal InputPreview(ChatLogWindow logWindow)
internal InputPreview(Plugin plugin)
: base("##chat2-inputpreview")
{
LogWindow = logWindow;
_plugin = plugin;
Flags =
ImGuiWindowFlags.NoSavedSettings
| ImGuiWindowFlags.NoTitleBar
@@ -42,257 +22,14 @@ public partial class InputPreview : Window
| ImGuiWindowFlags.NoResize
| ImGuiWindowFlags.NoFocusOnAppearing
| ImGuiWindowFlags.NoScrollbar;
RespectCloseHotkey = false;
DisableWindowSounds = true;
IsOpen = true;
Plugin.Framework.Update += UpdateConditionCheck;
IsOpen = false;
}
public void Dispose()
{
Plugin.Framework.Update -= UpdateConditionCheck;
}
public void Dispose() { }
private bool ValidDraw =>
!string.IsNullOrEmpty(LogWindow.Chat)
&& LogWindow.Chat.Length >= Plugin.Config.PreviewMinimum;
public override bool DrawConditions() => false;
private void UpdateConditionCheck(IFramework framework)
{
Drawing = ValidDraw;
if (!Drawing)
{
LastLength = 0;
PreviewHeight = 0;
PreviewMessage = null;
HasEvaluation = false;
return;
}
if (PreviewMessage == null || LastLength != LogWindow.Chat.Length)
{
LastLength = LogWindow.Chat.Length;
var bytes = Encoding.UTF8.GetBytes(LogWindow.Chat.Trim());
AutoTranslate.ReplaceWithPayload(ref bytes);
var chunks = ChunkUtil
.ToChunks(SeString.Parse(bytes), ChunkSource.Content, ChatType.Say)
.ToList();
PreviewMessage = Message.FakeMessage(chunks, new ChatCode(XivChatType.Say, 0, 0));
PreviewMessage.DecodeTextParam();
}
HasEvaluation = !Plugin.Config.OnlyPreviewIf || PreviewMessage.Content.Count > 1;
}
internal bool IsDrawable => ValidDraw && HasEvaluation;
private static bool IsWindowMode =>
Plugin.Config.PreviewPosition is PreviewPosition.Top or PreviewPosition.Bottom;
public override bool DrawConditions()
{
return IsWindowMode && IsDrawable;
}
public override void PreDraw()
{
var pos = LogWindow.LastWindowPos;
var size = LogWindow.LastWindowSize;
Size = size with { Y = PreviewHeight };
var y = Plugin.Config.PreviewPosition switch
{
PreviewPosition.Top => pos.Y - PreviewHeight,
PreviewPosition.Bottom => pos.Y + size.Y,
_ => throw new ArgumentOutOfRangeException(
nameof(Plugin.Config.PreviewPosition),
Plugin.Config.PreviewPosition,
null
),
};
Position = pos with { Y = y };
PositionCondition = ImGuiCond.Always;
}
public override void Draw()
{
CalculatePreview();
DrawPreview();
}
internal void CalculatePreview()
{
// We Pre-draw this once to get the actual height :HideThePain:
PreviewHeight = 0;
var pos = ImGui.GetCursorPos();
ImGui.SetCursorPos(new Vector2(-500, -500));
var before = ImGui.GetCursorPosY();
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero))
{
ImGui.TextUnformatted(Language.Options_Preview_Header);
DrawChunksPreview(PreviewMessage!.Content);
}
var after = ImGui.GetCursorPosY();
ImGui.SetCursorPos(pos);
PreviewHeight = after - before;
PreviewHeight += IsWindowMode ? ImGui.GetStyle().WindowPadding.Y * 2 : 0;
}
internal void DrawPreview()
{
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero))
{
ImGui.TextUnformatted(Language.Options_Preview_Header);
var handler = LogWindow.HandlerLender.Borrow();
DrawChunksPreview(PreviewMessage!.Content, handler, unique: 10000);
handler.Draw();
}
}
private void DrawChunksPreview(
IReadOnlyList<Chunk> chunks,
PayloadHandler? handler = null,
float lineWidth = 0f,
int unique = 0
)
{
CursorPosition = 0;
using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero);
for (var i = 0; i < chunks.Count; i++)
{
if (chunks[i] is TextChunk text && string.IsNullOrEmpty(text.Content))
continue;
DrawChunkPreview(chunks[i], handler, lineWidth, unique);
if (i < chunks.Count - 1)
{
ImGui.SameLine();
}
else if (chunks[i].Link is EmotePayload && Plugin.Config.ShowEmotes)
{
// Emote payloads seem to not automatically put newlines, which
// is an issue when modern mode is disabled.
ImGui.SameLine();
// Use default ImGui behavior for newlines.
ImGui.TextUnformatted("");
}
}
}
private void DrawChunkPreview(
Chunk chunk,
PayloadHandler? handler = null,
float lineWidth = 0f,
int unique = 0
)
{
if (chunk is IconChunk icon)
{
LogWindow.DrawIcon(chunk, icon, handler);
if (icon.Icon != BitmapFontIcon.AutoTranslateBegin)
return;
NextChunkIsAutoTranslate = true;
// Malformed chunks could carry an AutoTranslateBegin icon without the matching
// payload; bail out instead of dereferencing a null Link.
if (chunk.Link is not AutoTranslatePayload payload)
return;
CursorPosition += $"<at:{payload.Group},{payload.Key}>".Length;
return;
}
if (chunk is not TextChunk text)
return;
if (chunk.Link is EmotePayload emotePayload && Plugin.Config.ShowEmotes)
{
var emoteSize = ImGui.CalcTextSize("W");
emoteSize = emoteSize with { Y = emoteSize.X } * 1.5f;
// TextWrap doesn't work for emotes, so we have to wrap them manually
if (ImGui.GetContentRegionAvail().X < emoteSize.X)
ImGui.NewLine();
// We only draw a dummy if it is still loading, in case it failed, we draw the actual name
var image = EmoteCache.GetEmote(emotePayload.Code);
if (image is { Failed: false })
{
if (image.IsLoaded)
image.Draw(emoteSize);
else
ImGui.Dummy(emoteSize);
if (ImGui.IsItemHovered())
ImGuiUtil.Tooltip(emotePayload.Code);
CursorPosition += emotePayload.Code.Length;
return;
}
}
if (NextChunkIsAutoTranslate)
{
NextChunkIsAutoTranslate = false;
ImGuiUtil.WrapText(text.Content, chunk, handler, LogWindow.DefaultText, lineWidth);
return;
}
if (text.Link != null)
{
if (text.Link is ItemPayload)
CursorPosition += "<item>".Length;
else if (text.Link is MapLinkPayload)
CursorPosition += "<flag>".Length;
else if (text.Link is EmotePayload emote)
CursorPosition += emote.Code.Length;
else if (text.Link is UriPayload)
CursorPosition += text.Content.Length;
ImGuiUtil.WrapText(text.Content, chunk, handler, LogWindow.DefaultText, lineWidth);
return;
}
foreach (var word in WhitespaceRegex().Split(text.Content).Where(s => s != string.Empty))
{
var wordSize = ImGui.CalcTextSize(word);
if (ImGui.GetContentRegionAvail().X < wordSize.X)
ImGui.NewLine();
foreach (var letter in word)
{
var letterSize = ImGui.CalcTextSize(letter.ToString());
CursorPosition++;
if (
ImGui.Selectable(
$"{letter}##{CursorPosition + unique}",
false,
ImGuiSelectableFlags.None,
letterSize
)
)
{
SelectedCursorPos = CursorPosition;
LogWindow.FocusedPreview = true;
}
ImGui.SameLine();
}
}
ImGui.NewLine();
}
[GeneratedRegex(@"(\s)")]
private static partial Regex WhitespaceRegex();
public override void Draw() { }
}
-271
View File
@@ -1,271 +0,0 @@
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Style;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing;
using Microsoft.Extensions.Logging;
namespace HellionChat.Ui;
internal class Popout : Window
{
private readonly ChatLogWindow ChatLogWindow;
private readonly Tab Tab;
private readonly int Idx;
private readonly ILogger<Popout> _logger;
private long FrameTime;
private long LastActivityTime = Environment.TickCount64;
// Optional input bar inside the pop-out. Lazy-allocated when enabled,
// torn down on toggle-off (buffer discarded intentionally).
public ChatInputBar? InputBar { get; private set; }
public bool HasFocusedInputBar => InputBar?.IsFocused ?? false;
// Exposed so AutoTellTabsService can locate this window during LRU eviction.
internal Guid TabIdentifier => Tab.Identifier;
public Popout(ChatLogWindow chatLogWindow, Tab tab, int idx, ILogger<Popout> logger)
: base($"{tab.Name}##popout")
{
ChatLogWindow = chatLogWindow;
Tab = tab;
Idx = idx;
_logger = logger;
Size = new Vector2(350, 350);
SizeCondition = ImGuiCond.FirstUseEver;
IsOpen = true;
RespectCloseHotkey = false;
DisableWindowSounds = true;
// AllowBackgroundBlur is intentionally off: Dalamud blurs the entire
// tab container, not just this window, which would affect adjacent plugins.
// Users can enable blur per-window via the Dalamud hamburger menu.
}
public override void PreOpenCheck()
{
if (!Tab.PopOut)
IsOpen = false;
}
public override bool DrawConditions()
{
FrameTime = Environment.TickCount64;
if (Tab.IndependentHide ? HideStateCheck() : ChatLogWindow.IsHidden)
return false;
if (
!Plugin.Config.HideWhenInactive
|| (!Plugin.Config.InactivityHideActiveDuringBattle && Plugin.InBattle)
|| !Tab.UnhideOnActivity
)
{
LastActivityTime = FrameTime;
return true;
}
var lastActivityTime = Math.Max(Tab.LastActivity, LastActivityTime);
lastActivityTime = Math.Max(lastActivityTime, ChatLogWindow.LastActivityTime);
return FrameTime - lastActivityTime <= 1000 * Plugin.Config.InactivityHideTimeout;
}
public override void PreDraw()
{
// Theme engine pushes the active theme globally in Plugin.Draw;
// pop-outs draw consistently without per-window overrides.
Flags = ImGuiWindowFlags.None;
if (!Plugin.Config.ShowPopOutTitleBar)
Flags |= ImGuiWindowFlags.NoTitleBar;
if (!Tab.CanMove)
Flags |= ImGuiWindowFlags.NoMove;
if (!Tab.CanResize)
Flags |= ImGuiWindowFlags.NoResize;
// Guard against Idx pointing past the end if PopOutDocked was resized mid-frame.
if (Idx >= 0 && Idx < ChatLogWindow.PopOutDocked.Count && !ChatLogWindow.PopOutDocked[Idx])
{
BgAlpha = Tab.IndependentOpacity ? Tab.Opacity / 100f : Plugin.Config.WindowOpacity;
}
}
public override void Draw()
{
using var id = ImRaii.PushId($"popout-{Tab.Identifier}");
if (!Plugin.Config.ShowPopOutTitleBar)
{
ImGui.TextUnformatted(Tab.Name);
ImGui.Separator();
}
var hintBannerHeight = DrawHintBannerIfNeeded();
// Toggle-OFF resets InputBar so the next toggle-ON starts with a fresh buffer.
var inputEnabled = Plugin.Config.PopOutInputEnabled;
if (!inputEnabled && InputBar != null)
InputBar = null;
if (inputEnabled)
InputBar ??= new ChatInputBar(ChatLogWindow.Plugin, ChatLogWindow, () => Tab);
var inputBarHeight = inputEnabled
? ImGui.GetFrameHeightWithSpacing() + ImGui.GetStyle().ItemSpacing.Y
: 0f;
var handler = ChatLogWindow.HandlerLender.Borrow();
var logHeight = ImGui.GetContentRegionAvail().Y - inputBarHeight - hintBannerHeight;
ChatLogWindow.DrawMessageLog(Tab, handler, logHeight, false, updateScrollState: false);
if (inputEnabled && InputBar != null)
{
ImGui.Separator();
InputBar.RenderCompact();
}
if (ImGui.IsWindowHovered(ImGuiHoveredFlags.ChildWindows))
LastActivityTime = FrameTime;
}
// Returns the vertical space consumed by the banner (0 when not shown).
private float DrawHintBannerIfNeeded()
{
if (Plugin.Config.SeenPopOutInputHint)
return 0f;
var hintText = Resources.HellionStrings.Popout_v060_HintText;
var ackLabel = Resources.HellionStrings.Popout_v060_HintAck;
var openLabel = Resources.HellionStrings.Popout_v060_HintOpenSettings;
var startY = ImGui.GetCursorPosY();
var bg = new System.Numerics.Vector4(0.16f, 0.20f, 0.28f, 1f);
ImGui.PushStyleColor(ImGuiCol.ChildBg, bg);
ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f);
var dismiss = false;
var openSettings = false;
using (
var child = ImRaii.Child(
"##v060-pop-out-hint",
new System.Numerics.Vector2(0f, 64f),
true
)
)
{
if (child)
{
ImGui.TextWrapped(hintText);
if (ImGui.Button(ackLabel))
dismiss = true;
ImGui.SameLine();
if (ImGui.Button(openLabel))
{
dismiss = true;
openSettings = true;
}
}
}
ImGui.PopStyleVar();
ImGui.PopStyleColor();
ImGui.Spacing();
if (dismiss)
{
Plugin.Config.SeenPopOutInputHint = true;
ChatLogWindow.Plugin.SaveConfig();
_logger.LogDebug("Pop-Out input hint dismissed");
if (openSettings)
ChatLogWindow.Plugin.SettingsWindow.Toggle();
}
return ImGui.GetCursorPosY() - startY;
}
public override void PostDraw()
{
if (Idx >= 0 && Idx < ChatLogWindow.PopOutDocked.Count)
ChatLogWindow.PopOutDocked[Idx] = ImGui.IsWindowDocked();
}
public override void OnClose()
{
ChatLogWindow.PopOutWindows.Remove(Tab.Identifier);
ChatLogWindow.Plugin.WindowSystem.RemoveWindow(this);
Tab.PopOut = false;
ChatLogWindow.Plugin.SaveConfig();
}
private enum HideState
{
None,
Cutscene,
CutsceneOverride,
User,
Battle,
}
private HideState CurrentHideState = HideState.None;
private bool HideStateCheck()
{
if (Tab.HideInBattle && CurrentHideState == HideState.None && Plugin.InBattle)
{
CurrentHideState = HideState.Battle;
_logger.LogTrace($"Popout HideState [{Tab.Name}]: None -> Battle");
}
if (CurrentHideState is HideState.Battle && !Plugin.InBattle)
{
CurrentHideState = HideState.None;
_logger.LogTrace($"Popout HideState [{Tab.Name}]: Battle -> None");
}
if (
Tab.HideDuringCutscenes
&& CurrentHideState == HideState.None
&& (Plugin.CutsceneActive || Plugin.GposeActive)
)
{
if (ChatLogWindow.Plugin.Functions.Chat.CheckHideFlags())
{
CurrentHideState = HideState.Cutscene;
_logger.LogTrace($"Popout HideState [{Tab.Name}]: None -> Cutscene");
}
}
if (
CurrentHideState is HideState.Cutscene or HideState.CutsceneOverride
&& !Plugin.CutsceneActive
&& !Plugin.GposeActive
)
{
_logger.LogTrace(
$"Popout HideState [{Tab.Name}]: {CurrentHideState} -> None (cutscene/gpose ended)"
);
CurrentHideState = HideState.None;
}
if (CurrentHideState == HideState.Cutscene && ChatLogWindow.Activate)
{
CurrentHideState = HideState.CutsceneOverride;
_logger.LogTrace(
$"Popout HideState [{Tab.Name}]: Cutscene -> CutsceneOverride (user activate)"
);
}
if (CurrentHideState == HideState.User && ChatLogWindow.Activate)
{
CurrentHideState = HideState.None;
_logger.LogTrace($"Popout HideState [{Tab.Name}]: User -> None (activate)");
}
return CurrentHideState is HideState.Cutscene or HideState.User or HideState.Battle
|| (Tab.HideWhenNotLoggedIn && !Plugin.ClientState.IsLoggedIn);
}
}
-191
View File
@@ -1,191 +0,0 @@
using System.Globalization;
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Util;
namespace HellionChat.Ui;
// Bottom status bar. Slots left to right: channel indicator, privacy badge,
// counts, tells (hidden at 0), version (right-aligned). Updates at 1Hz;
// format strings are cached between updates.
internal sealed class StatusBar
{
// DPI-aware bar height. The previous fixed 22px constant clipped on
// Windows display-scaling >100% because ImGui renders the font bigger
// than the reservation. GetTextLineHeightWithSpacing scales with the
// current ImGui font; the 2px spacer is GlobalScale-rounded to stay
// on integer pixel boundaries (same idiom as v1.4.6 F7.2 underline-pill
// in ChatLogWindow.cs:1639-1653).
public static float Height =>
ImGui.GetTextLineHeightWithSpacing() + MathF.Round(2f * ImGuiHelpers.GlobalScale);
private const long UpdateIntervalMs = 1000;
// Initially outdated so the first frame always computes fresh.
private long _lastUpdateMs = -UpdateIntervalMs;
private string _cachedCountsText = string.Empty;
private string _cachedTellsText = string.Empty;
// Pure string logic, testable without ImGui init.
public static string FormatCounts(int tabs, int messages)
{
// InvariantCulture so locale doesn't affect the format (e.g. de_DE "1,2k").
var msgPart =
messages >= 1000
? string.Format(CultureInfo.InvariantCulture, "{0:0.0}k msg", messages / 1000.0)
: $"{messages} msg";
var tabsPart = $"{tabs} {(tabs == 1 ? "tab" : "tabs")}";
return $"{tabsPart} · {msgPart}";
}
// Pure string logic, testable without ImGui init. Returns empty string at 0 tells.
public static string FormatTells(int count)
{
if (count <= 0)
return string.Empty;
return $"{count} {(count == 1 ? "tell" : "tells")}";
}
// Single-pass replacement for a LINQ Sum+Count pair. Pure helper for unit testing.
internal static (int messages, int tells) AggregateForStatusBar(IList<Tab> tabs)
{
int messages = 0,
tells = 0;
foreach (var t in tabs)
{
messages += t.Messages.Count;
if (t.IsTempTab)
tells++;
}
return (messages, tells);
}
// Test hook to verify cache logic without a real time source.
internal (string counts, string tells) SnapshotForTest(
long now,
int tabs,
int messages,
int tells
)
{
UpdateCacheIfDue(now, tabs, messages, tells);
return (_cachedCountsText, _cachedTellsText);
}
private void UpdateCacheIfDue(long now, int tabs, int messages, int tells)
{
if (now - _lastUpdateMs < UpdateIntervalMs)
return;
_cachedCountsText = FormatCounts(tabs, messages);
_cachedTellsText = FormatTells(tells);
_lastUpdateMs = now;
}
public void Draw(Plugin plugin)
{
var theme = plugin.ThemeRegistry.Active;
var now = Environment.TickCount64;
if (now - _lastUpdateMs >= UpdateIntervalMs)
{
var (messages, tells) = AggregateForStatusBar(Plugin.Config.Tabs);
UpdateCacheIfDue(now, Plugin.Config.Tabs.Count, messages, tells);
}
// Border top via DrawList -- ImGui.Separator has too much padding.
var cursorY = ImGui.GetCursorScreenPos().Y;
var winLeft = ImGui.GetWindowPos().X;
var winRight = winLeft + ImGui.GetWindowSize().X;
ImGui
.GetWindowDrawList()
.AddLine(
new Vector2(winLeft, cursorY),
new Vector2(winRight, cursorY),
ColourUtil.RgbaToAbgr(theme.Colors.Border),
1f
);
ImGui.Dummy(new Vector2(0, 2));
// Slot 1: active channel indicator
var inputCh = plugin.CurrentTab?.CurrentChannel?.Channel ?? InputChannel.Invalid;
var hasChannel = inputCh != InputChannel.Invalid;
var chatType = inputCh.ToChatType();
var channelName = hasChannel ? chatType.Name() : "—";
var channelColor = hasChannel
? (plugin.Functions.Chat.GetChannelColor(chatType) ?? theme.Colors.TextMuted)
: theme.Colors.TextMuted;
DrawDot(channelColor);
ImGui.SameLine();
ImGui.TextUnformatted(channelName);
// Slot 2: privacy badge
ImGui.SameLine();
DrawSeparator();
ImGui.SameLine();
using (plugin.FontManager.FontAwesome.Push())
{
ImGui.TextUnformatted(FontAwesomeIcon.Lock.ToIconString());
}
ImGui.SameLine();
var privacyLabel = Plugin.Config.PrivacyFilterEnabled
? HellionStrings.StatusBar_Privacy_Enabled
: HellionStrings.StatusBar_Privacy_Open;
ImGui.TextUnformatted(privacyLabel);
// Slot 3: counts
ImGui.SameLine();
DrawSeparator();
ImGui.SameLine();
ImGui.TextUnformatted(_cachedCountsText);
// Slot 4: tells (hidden at 0)
if (!string.IsNullOrEmpty(_cachedTellsText))
{
ImGui.SameLine();
DrawSeparator();
ImGui.SameLine();
ImGui.TextUnformatted(_cachedTellsText);
}
// Slot 5: version, right-aligned, muted. Hidden when the window is
// too narrow to fit all five slots — the other four need ~200 px
// before the version text starts clipping into them.
var versionText = $"v{Plugin.Interface.Manifest.AssemblyVersion} · Hellion";
var versionWidth = ImGui.CalcTextSize(versionText).X;
var contentRegionMax = ImGui.GetContentRegionMax().X;
const float MinOtherSlotsWidth = 200f;
if (contentRegionMax - versionWidth > MinOtherSlotsWidth)
{
ImGui.SameLine(contentRegionMax - versionWidth);
using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(theme.Colors.TextMuted)))
{
ImGui.TextUnformatted(versionText);
}
}
}
private static void DrawDot(uint rgba)
{
var pos = ImGui.GetCursorScreenPos();
const float radius = 4f;
ImGui
.GetWindowDrawList()
.AddCircleFilled(
new Vector2(pos.X + radius, pos.Y + ImGui.GetTextLineHeight() / 2f),
radius,
ColourUtil.RgbaToAbgr(rgba)
);
ImGui.Dummy(new Vector2(radius * 2 + 4, ImGui.GetTextLineHeight()));
}
private static void DrawSeparator()
{
ImGui.TextDisabled("·");
}
}
@@ -3,72 +3,36 @@ using Dalamud.Interface.Utility.Raii;
using HellionChat.Themes;
using HellionChat.Util;
namespace HellionChat.Ui;
namespace HellionChat.Ui.StyleEngine;
// Theme-driven ImGui style override. PushGlobal is pushed once per frame
// in Plugin.Draw and drives every Hellion-rendered window.
internal static class HellionStyle
// Global theme style push, owned by the StyleEngine layer. Plugin.Draw
// wraps every WindowSystem.Draw call in this scope so all Hellion windows
// inherit the active theme's colours and layout. Crossfade reads through
// ThemeRegistry.TryGetActiveCrossfade to lerp the ABGR cache during the
// 300ms transition window without re-styling individual windows.
//
// Child surfaces draw over WindowBg, so the per-frame Window opacity
// modulates ChildBg's alpha down to zero once the user goes below full
// opacity — otherwise the theme alpha would double-multiply and the
// child read would look too solid.
internal static class GlobalStyleScope
{
// Local color stack for the active theme. Use inside a
// `using var _ = HellionStyle.Push(theme);` block.
internal static IDisposable Push(Theme theme)
{
var a = theme.AbgrCache;
var stack = new StackHandle();
stack.PushColorAbgr(ImGuiCol.Button, a.Primary);
stack.PushColorAbgr(ImGuiCol.ButtonHovered, a.PrimaryLight);
stack.PushColorAbgr(ImGuiCol.ButtonActive, a.PrimaryDark);
stack.PushColorAbgr(ImGuiCol.FrameBg, a.FrameBg);
stack.PushColorAbgr(ImGuiCol.FrameBgHovered, a.SurfaceHover);
stack.PushColorAbgr(ImGuiCol.FrameBgActive, a.Surface);
stack.PushColorAbgr(ImGuiCol.Border, a.Border);
stack.PushColorAbgr(ImGuiCol.Header, a.Surface);
stack.PushColorAbgr(ImGuiCol.HeaderHovered, a.SurfaceHover);
stack.PushColorAbgr(ImGuiCol.HeaderActive, a.Identity);
stack.PushColorAbgr(ImGuiCol.CheckMark, a.Primary);
stack.PushColorAbgr(ImGuiCol.SliderGrab, a.Primary);
stack.PushColorAbgr(ImGuiCol.SliderGrabActive, a.PrimaryLight);
return stack;
}
// Global color and style stack pushed once per frame.
// windowOpacity: window background alpha (0.5-1.0).
internal static IDisposable PushGlobal(
Theme theme,
ThemeRegistry registry,
float windowOpacity = 1.0f
)
public static IDisposable Push(Theme theme, ThemeRegistry registry, float windowOpacity)
{
var c = theme.Colors;
var l = theme.Layout;
// Crossfade: PM-1 reads a lerped snapshot during the 300ms window
// following a Switch (TryGetActiveCrossfade returns false outside
// the window or while ReduceMotion is on). Only the ABGR-slot path
// crossfades -- WindowBg/ChildBg RGBA stays bound to the user's
// per-window opacity override and must not fade. See
// feedback_dalamud_pinning_override.
ThemeAbgrCache a;
if (!Plugin.Config.ReduceMotion && registry.TryGetActiveCrossfade(out var lerped))
{
a = lerped;
}
else
{
a = theme.AbgrCache;
}
var stack = new StackHandle();
var alphaByte = (uint)Math.Clamp((int)(windowOpacity * 255f), 0x55, 0xFF);
var windowBgWithAlpha = (c.WindowBg & 0xFFFFFF00u) | alphaByte;
var childBgWithAlpha = ResolveChildBgAlpha(c.ChildBg, windowOpacity);
// ChildBg alpha resolution lives in HellionStyleHelpers so the
// threshold logic can be covered by a pure-helper test in the
// build suite.
var childBgWithAlpha = HellionStyleHelpers.ResolveChildBgAlpha(c.ChildBg, windowOpacity);
// Layout
var stack = new StackHandle();
stack.PushStyleVar(ImGuiStyleVar.WindowRounding, l.WindowRounding);
stack.PushStyleVar(ImGuiStyleVar.ChildRounding, l.ChildRounding);
stack.PushStyleVar(ImGuiStyleVar.PopupRounding, l.PopupRounding);
@@ -79,58 +43,47 @@ internal static class HellionStyle
stack.PushStyleVar(ImGuiStyleVar.WindowBorderSize, l.WindowBorderSize);
stack.PushStyleVar(ImGuiStyleVar.FrameBorderSize, l.FrameBorderSize);
// Surfaces — WindowBg/ChildBg use opacity-modulated values (RGBA path);
// everything else reads from the pre-computed ABGR cache.
stack.PushColor(ImGuiCol.WindowBg, windowBgWithAlpha);
stack.PushColor(ImGuiCol.ChildBg, childBgWithAlpha);
stack.PushColorAbgr(ImGuiCol.PopupBg, a.ChildBg);
stack.PushColorAbgr(ImGuiCol.Border, a.Border);
stack.PushColorAbgr(ImGuiCol.BorderShadow, 0u);
// Frames
stack.PushColorAbgr(ImGuiCol.FrameBg, a.FrameBg);
stack.PushColorAbgr(ImGuiCol.FrameBgHovered, a.SurfaceHover);
stack.PushColorAbgr(ImGuiCol.FrameBgActive, a.Surface);
// Title bars
stack.PushColorAbgr(ImGuiCol.TitleBg, a.WindowBg);
stack.PushColorAbgr(ImGuiCol.TitleBgActive, a.Identity);
stack.PushColorAbgr(ImGuiCol.TitleBgCollapsed, a.WindowBg);
// Buttons
stack.PushColorAbgr(ImGuiCol.Button, a.Primary);
stack.PushColorAbgr(ImGuiCol.ButtonHovered, a.PrimaryLight);
stack.PushColorAbgr(ImGuiCol.ButtonActive, a.PrimaryDark);
// Headers / selectables
stack.PushColorAbgr(ImGuiCol.Header, a.Surface);
stack.PushColorAbgr(ImGuiCol.HeaderHovered, a.SurfaceHover);
stack.PushColorAbgr(ImGuiCol.HeaderActive, a.Identity);
// Tabs
stack.PushColorAbgr(ImGuiCol.Tab, a.FrameBg);
stack.PushColorAbgr(ImGuiCol.TabHovered, a.PrimaryLight);
stack.PushColorAbgr(ImGuiCol.TabActive, a.Identity);
stack.PushColorAbgr(ImGuiCol.TabUnfocused, a.ChildBg);
stack.PushColorAbgr(ImGuiCol.TabUnfocusedActive, a.PrimaryDark);
// Scrollbar
stack.PushColorAbgr(ImGuiCol.ScrollbarBg, a.WindowBg);
stack.PushColorAbgr(ImGuiCol.ScrollbarGrab, a.Surface);
stack.PushColorAbgr(ImGuiCol.ScrollbarGrabHovered, a.AccentLight);
stack.PushColorAbgr(ImGuiCol.ScrollbarGrabActive, a.Accent);
// Resize grip
stack.PushColorAbgr(ImGuiCol.ResizeGrip, a.FrameBg);
stack.PushColorAbgr(ImGuiCol.ResizeGripHovered, a.AccentLight);
stack.PushColorAbgr(ImGuiCol.ResizeGripActive, a.Accent);
// Check mark + slider grab
stack.PushColorAbgr(ImGuiCol.CheckMark, a.Primary);
stack.PushColorAbgr(ImGuiCol.SliderGrab, a.Primary);
stack.PushColorAbgr(ImGuiCol.SliderGrabActive, a.PrimaryLight);
// Separator
stack.PushColorAbgr(ImGuiCol.Separator, a.Border);
stack.PushColorAbgr(ImGuiCol.SeparatorHovered, a.PrimaryLight);
stack.PushColorAbgr(ImGuiCol.SeparatorActive, a.Primary);
@@ -138,6 +91,16 @@ internal static class HellionStyle
return stack;
}
// Child alpha is wiped to zero below full window opacity so WindowBg
// alone carries the coverage. 0.999f guards the user-facing 100% slider
// against float imprecision.
private static uint ResolveChildBgAlpha(uint themeChildBgRgba, float windowOpacity)
{
var alphaPreserved = windowOpacity >= 0.999f;
var childBgAlpha = alphaPreserved ? (themeChildBgRgba & 0xFFu) : 0u;
return (themeChildBgRgba & 0xFFFFFF00u) | childBgAlpha;
}
private sealed class StackHandle : IDisposable
{
private readonly List<IDisposable> _items = new(64);
-308
View File
@@ -1,308 +0,0 @@
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Game.Text;
using Dalamud.Interface.Utility.Raii;
namespace HellionChat.Ui;
// Popup picker for chat-input symbol insertion. Two tabs:
// PUA — Dalamud's SeIconChar enum (161 server-safe FFXIV glyphs)
// BMP — server-verified Unicode symbols (whitelist built 2026-05-16)
//
// Render-only — the Settings-Guard for showing the trigger button lives on
// the caller side (ChatLogWindow). Recent-Used is session state by design.
internal sealed class SymbolPicker
{
private const string PopupId = "HellionSymbolPicker";
private const int RecentCapacity = 16;
private string _search = string.Empty;
private readonly List<uint> _recentUsed = new(capacity: RecentCapacity);
// FFXIV server-safe BMP symbols, verified 2026-05-16 via /echo + /say.
// Filtered ranges: U+2694-26C4 (Misc Symbols Extended), U+2700+ (Dingbats
// Extended), diagonal arrows, U+2153+ fractions, chess pieces.
// Full probe log: Cycles/v1.4.10 BMP-Whitelist Notes.md.
private static readonly (uint Codepoint, string Name)[] BmpWhitelist = new[]
{
(0x00A1u, "Inverted Exclamation"),
(0x00A2u, "Cent Sign"),
(0x00A3u, "Pound Sign"),
(0x00A4u, "Currency Sign"),
(0x00A5u, "Yen Sign"),
(0x00A7u, "Section Sign"),
(0x00A9u, "Copyright Sign"),
(0x00ABu, "Left Angle Quote"),
(0x00AEu, "Registered Sign"),
(0x00B0u, "Degree Sign"),
(0x00B1u, "Plus-Minus Sign"),
(0x00B6u, "Pilcrow Sign"),
(0x00BBu, "Right Angle Quote"),
(0x00BCu, "One Quarter"),
(0x00BDu, "One Half"),
(0x00BEu, "Three Quarters"),
(0x00BFu, "Inverted Question"),
(0x00D7u, "Multiplication Sign"),
(0x00F7u, "Division Sign"),
(0x0393u, "Greek Capital Gamma"),
(0x0394u, "Greek Capital Delta"),
(0x0398u, "Greek Capital Theta"),
(0x039Bu, "Greek Capital Lambda"),
(0x039Eu, "Greek Capital Xi"),
(0x03A0u, "Greek Capital Pi"),
(0x03A3u, "Greek Capital Sigma"),
(0x03A6u, "Greek Capital Phi"),
(0x03A8u, "Greek Capital Psi"),
(0x03A9u, "Greek Capital Omega"),
(0x03B1u, "Greek Small Alpha"),
(0x03B2u, "Greek Small Beta"),
(0x03B3u, "Greek Small Gamma"),
(0x03B4u, "Greek Small Delta"),
(0x03B5u, "Greek Small Epsilon"),
(0x03B6u, "Greek Small Zeta"),
(0x03B7u, "Greek Small Eta"),
(0x03B8u, "Greek Small Theta"),
(0x03B9u, "Greek Small Iota"),
(0x03BAu, "Greek Small Kappa"),
(0x03BBu, "Greek Small Lambda"),
(0x03BCu, "Greek Small Mu"),
(0x03BDu, "Greek Small Nu"),
(0x03BEu, "Greek Small Xi"),
(0x03BFu, "Greek Small Omicron"),
(0x03C0u, "Greek Small Pi"),
(0x03C1u, "Greek Small Rho"),
(0x03C3u, "Greek Small Sigma"),
(0x03C4u, "Greek Small Tau"),
(0x03C5u, "Greek Small Upsilon"),
(0x03C6u, "Greek Small Phi"),
(0x03C7u, "Greek Small Chi"),
(0x03C8u, "Greek Small Psi"),
(0x03C9u, "Greek Small Omega"),
(0x2013u, "En Dash"),
(0x2014u, "Em Dash"),
(0x2020u, "Dagger"),
(0x2021u, "Double Dagger"),
(0x2026u, "Horizontal Ellipsis"),
(0x203Bu, "Reference Mark"),
(0x20ACu, "Euro Sign"),
(0x2122u, "Trade Mark Sign"),
(0x2190u, "Leftwards Arrow"),
(0x2191u, "Upwards Arrow"),
(0x2192u, "Rightwards Arrow"),
(0x2193u, "Downwards Arrow"),
(0x21D2u, "Rightwards Double Arrow"),
(0x21D4u, "Left Right Double Arrow"),
(0x2202u, "Partial Differential"),
(0x2207u, "Nabla"),
(0x2211u, "Summation"),
(0x221Au, "Square Root"),
(0x221Eu, "Infinity"),
(0x222Bu, "Integral"),
(0x2260u, "Not Equal To"),
(0x25A0u, "Black Square"),
(0x25A1u, "White Square"),
(0x25B2u, "Black Up Triangle"),
(0x25B3u, "White Up Triangle"),
(0x25BCu, "Black Down Triangle"),
(0x25C6u, "Black Diamond"),
(0x25C7u, "White Diamond"),
(0x25CBu, "White Circle"),
(0x25CFu, "Black Circle"),
(0x2600u, "Black Sun With Rays"),
(0x2601u, "Cloud"),
(0x2602u, "Umbrella"),
(0x2603u, "Snowman"),
(0x2605u, "Black Star"),
(0x2606u, "White Star"),
(0x2640u, "Female Sign"),
(0x2642u, "Male Sign"),
(0x2660u, "Black Spade Suit"),
(0x2661u, "White Heart Suit"),
(0x2663u, "Black Club Suit"),
(0x2665u, "Black Heart Suit"),
(0x266Au, "Eighth Note"),
(0x2713u, "Check Mark"),
};
public void OpenPopup() => ImGui.OpenPopup(PopupId);
// Returns the inserted codepoint as a string fragment if the user clicked
// one this frame, or null otherwise. Caller splices the fragment into the
// chat-input buffer at the current cursor position.
public string? DrawAndConsume()
{
// ImRaii.Popup auto-disposes EndPopup, same idiom as other popups in
// ChatLogWindow.
using var popup = ImRaii.Popup(PopupId);
if (!popup)
return null;
string? inserted = null;
// Recent-Used-Row sits above the tabs so both PUA and BMP picks share
// one fast-access strip. Session-only by design (see TrackRecent).
if (_recentUsed.Count > 0)
{
ImGui.TextDisabled("Recent");
ImGui.SameLine();
foreach (var codepoint in _recentUsed)
{
var glyph = char.ConvertFromUtf32((int)codepoint);
if (
ImGui.Selectable(
glyph,
false,
ImGuiSelectableFlags.DontClosePopups,
new Vector2(20, 20)
)
)
{
inserted = glyph;
}
ImGui.SameLine();
}
ImGui.NewLine();
ImGui.Separator();
}
using (var tabs = ImRaii.TabBar("##symbolpicker-tabs"))
{
if (tabs)
{
inserted = DrawPuaTab() ?? inserted;
inserted = DrawBmpTab() ?? inserted;
}
}
if (inserted is not null)
TrackRecent(inserted);
return inserted;
}
private string? DrawPuaTab()
{
using var tab = ImRaii.TabItem("FFXIV Icons");
if (!tab)
return null;
ImGui.InputTextWithHint(
"##pua-search",
"Search by name (e.g. HighQuality)",
ref _search,
64
);
string? inserted = null;
if (ImGui.BeginChild("##pua-grid", new Vector2(0, 280), false))
{
var query = _search;
foreach (var icon in Enum.GetValues<SeIconChar>())
{
var label = icon.ToString();
if (
query.Length > 0
&& label.IndexOf(query, StringComparison.OrdinalIgnoreCase) < 0
)
{
continue;
}
// ToIconString gives the single-codepoint glyph; tooltip
// carries the enum name for discoverability.
if (
ImGui.Selectable(
icon.ToIconString(),
false,
ImGuiSelectableFlags.DontClosePopups,
new Vector2(24, 24)
)
)
{
inserted = icon.ToIconString();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip(label);
// Manually-wrapping pattern from imgui_demo.cpp;
// GetWindowContentRegionMax obsolete since ImGui 1.92, use
// GetContentRegionAvail (see ChatLogWindow.cs:840).
var style = ImGui.GetStyle();
var lastItemX2 = ImGui.GetItemRectMax().X;
var availableRightX =
ImGui.GetCursorScreenPos().X + ImGui.GetContentRegionAvail().X;
if (lastItemX2 + style.ItemSpacing.X + 24f < availableRightX)
ImGui.SameLine();
}
}
ImGui.EndChild();
return inserted;
}
private string? DrawBmpTab()
{
using var tab = ImRaii.TabItem("Symbols");
if (!tab)
return null;
ImGui.InputTextWithHint("##bmp-search", "Search by name (e.g. Heart)", ref _search, 64);
string? inserted = null;
if (ImGui.BeginChild("##bmp-grid", new Vector2(0, 280), false))
{
var query = _search;
foreach (var (codepoint, name) in BmpWhitelist)
{
if (query.Length > 0 && name.IndexOf(query, StringComparison.OrdinalIgnoreCase) < 0)
{
continue;
}
var glyph = char.ConvertFromUtf32((int)codepoint);
if (
ImGui.Selectable(
glyph,
false,
ImGuiSelectableFlags.DontClosePopups,
new Vector2(24, 24)
)
)
{
inserted = glyph;
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip(name);
// Same manually-wrapping pattern as DrawPuaTab — modern API
// since GetWindowContentRegionMax was deprecated in ImGui 1.92.
var style = ImGui.GetStyle();
var lastItemX2 = ImGui.GetItemRectMax().X;
var availableRightX =
ImGui.GetCursorScreenPos().X + ImGui.GetContentRegionAvail().X;
if (lastItemX2 + style.ItemSpacing.X + 24f < availableRightX)
ImGui.SameLine();
}
}
ImGui.EndChild();
return inserted;
}
private void TrackRecent(string fragment)
{
if (string.IsNullOrEmpty(fragment) || fragment.Length > 4)
return;
var codepoint = (uint)char.ConvertToUtf32(fragment, 0);
// Move-to-front so the head stays the freshest pick.
_recentUsed.RemoveAll(c => c == codepoint);
_recentUsed.Insert(0, codepoint);
if (_recentUsed.Count > RecentCapacity)
_recentUsed.RemoveAt(_recentUsed.Count - 1);
}
}
-72
View File
@@ -1,72 +0,0 @@
namespace HellionChat.Ui;
// Pure string resolver logic with no Dalamud dependency, kept in its own
// file so tests (HellionChat.Tests, no Dalamud reference) can call it directly.
// Used in the settings UI glyph picker and indirectly via TabIconMapping.Resolve.
internal static class TabIconGlyphResolver
{
// Single source of truth for the glyph set; order matches the settings combobox.
public static readonly IReadOnlyList<string> PickerOptions =
[
"comment",
"comments",
"cog",
"users",
"user-friends",
"link",
"envelope",
"clock",
"hashtag",
"star",
"heart",
"bell",
"bookmark",
"flag",
"fire",
];
// Derived from PickerOptions -- never maintain this manually.
private static readonly HashSet<string> KnownGlyphs = new(
PickerOptions,
StringComparer.OrdinalIgnoreCase
);
// Tab.Name is localised, so we match against a pool of DE/EN synonyms.
private static readonly Dictionary<string, string> NameDefaults = new(
StringComparer.OrdinalIgnoreCase
)
{
["allgemein"] = "comment",
["general"] = "comment",
["system"] = "cog",
["free company"] = "users",
["fc"] = "users",
["gruppe"] = "user-friends",
["group"] = "user-friends",
["party"] = "user-friends",
["linkshell"] = "link",
["ls"] = "link",
["cwls"] = "link",
["tells"] = "envelope",
["tell"] = "envelope",
};
// Resolves the glyph name for a tab. Priority order:
// 1. Tab.Icon override (if set): known glyph -> use it, unknown -> "hashtag"
// 2. Auto-tell tab -> autoTellGlyph if provided, else "clock"
// 3. Name default lookup
// 4. Fallback "hashtag"
public static string ResolveGlyphName(Tab tab, string? autoTellGlyph = null)
{
if (!string.IsNullOrWhiteSpace(tab.Icon))
return KnownGlyphs.Contains(tab.Icon) ? tab.Icon : "hashtag";
if (tab.IsTempTab)
return autoTellGlyph ?? "clock";
if (tab.Name is { } name && NameDefaults.TryGetValue(name, out var byName))
return byName;
return "hashtag";
}
}
-45
View File
@@ -1,45 +0,0 @@
using Dalamud.Interface;
namespace HellionChat.Ui;
// Default icon mapping for tabs, used in top-tabs (icon prefix) and sidebar (icon-only with tooltip).
// Users can override per tab via Settings -> Tabs -> Tab.Icon.
// Pure string resolver logic lives in TabIconGlyphResolver (no Dalamud dependency) for testability.
internal static class TabIconMapping
{
// Glyph name -> FontAwesomeIcon lookup for production resolve.
// Every key must also exist in TabIconGlyphResolver.PickerOptions.
// A missing key silently falls back to FontAwesomeIcon.Hashtag (degraded, no crash).
private static readonly Dictionary<string, FontAwesomeIcon> GlyphLookup = new(
StringComparer.OrdinalIgnoreCase
)
{
["comment"] = FontAwesomeIcon.Comment,
["comments"] = FontAwesomeIcon.Comments,
["cog"] = FontAwesomeIcon.Cog,
["users"] = FontAwesomeIcon.Users,
["user-friends"] = FontAwesomeIcon.UserFriends,
["link"] = FontAwesomeIcon.Link,
["envelope"] = FontAwesomeIcon.Envelope,
["clock"] = FontAwesomeIcon.Clock,
["hashtag"] = FontAwesomeIcon.Hashtag,
["star"] = FontAwesomeIcon.Star,
["heart"] = FontAwesomeIcon.Heart,
["bell"] = FontAwesomeIcon.Bell,
["bookmark"] = FontAwesomeIcon.Bookmark,
["flag"] = FontAwesomeIcon.Flag,
["fire"] = FontAwesomeIcon.Fire,
};
// Resolves the icon for a tab. Auto-tell tabs get a per-partner hashed icon
// from the tell pool so parallel tells differ by glyph shape, not just colour.
public static FontAwesomeIcon Resolve(Tab tab)
{
string? autoTellGlyph = null;
if (tab.IsTempTab && tab.TellTarget != null && tab.TellTarget.IsSet())
autoTellGlyph = TabTintCache.GetIcon(tab);
var glyph = TabIconGlyphResolver.ResolveGlyphName(tab, autoTellGlyph);
return GlyphLookup.TryGetValue(glyph, out var icon) ? icon : FontAwesomeIcon.Hashtag;
}
}
-38
View File
@@ -1,38 +0,0 @@
namespace HellionChat.Ui;
// Per-Tab cache wrapper around the pure AutoTellTabTint hash helpers.
// Each cache (tint, icon) carries its own name+world validation key so
// neither read path mutates the other's state — refilling one never
// invalidates the other. No string allocation in the steady-state lookup.
internal static class TabTintCache
{
public static uint GetTint(Tab tab)
{
var name = tab.TellTarget.Name;
var world = tab.TellTarget.World;
if (tab._cachedTintTellName != name || tab._cachedTintTellWorld != world)
{
tab._cachedTintTellName = name;
tab._cachedTintTellWorld = world;
tab._cachedTellTint = AutoTellTabTint.For(name, world);
}
return tab._cachedTellTint;
}
public static string GetIcon(Tab tab)
{
var name = tab.TellTarget.Name;
var world = tab.TellTarget.World;
if (
tab._cachedTellIcon is null
|| tab._cachedIconTellName != name
|| tab._cachedIconTellWorld != world
)
{
tab._cachedIconTellName = name;
tab._cachedIconTellWorld = world;
tab._cachedTellIcon = AutoTellTabTint.IconFor(name, world);
}
return tab._cachedTellIcon;
}
}
-228
View File
@@ -26,234 +26,6 @@ internal static class ImGuiUtil
Plugin = plugin;
}
private static readonly ImGuiMouseButton[] Buttons =
[
ImGuiMouseButton.Left,
ImGuiMouseButton.Middle,
ImGuiMouseButton.Right,
];
private static Payload? Hovered;
private static Payload? LastLink;
private static readonly List<(Vector2, Vector2)> PayloadBounds = [];
internal static void PostPayload(Chunk chunk, PayloadHandler? handler)
{
var payload = chunk.Link;
if (payload != null && ImGui.IsItemHovered())
{
Hovered = payload;
ImGui.SetMouseCursor(ImGuiMouseCursor.Hand);
handler?.Hover(payload);
}
else if (!ReferenceEquals(Hovered, payload))
{
Hovered = null;
}
if (handler == null)
return;
foreach (var button in Buttons)
if (ImGui.IsItemClicked(button))
handler.Click(chunk, payload, button);
}
// Ceiling on the byte buffer for a single rendered line. UTF-8 takes at
// most 4 bytes per char; ImGui's internal ImString limit is well below
// this and FFXIV's chat lines top out around a few hundred chars in
// practice. The cap prevents an unbounded ArrayPool rent if a caller
// ever feeds in a degenerate input.
private const int MaxLineByteCount = 16 * 1024;
internal static void WrapText(
string csText,
Chunk chunk,
PayloadHandler? handler,
Vector4 defaultText,
float lineWidth
)
{
if (csText.Length == 0)
return;
foreach (var part in csText.Split(["\r\n", "\r", "\n"], StringSplitOptions.None))
{
if (part.Length == 0)
{
ImGui.TextUnformatted("");
continue;
}
// Allocate against the encoder's own MaxByteCount so the buffer
// we hand to ImGui is sized by us. The actual byte count
// returned by GetBytes is then validated against that ceiling
// before any pointer arithmetic touches it; CodeQL recognises
// that comparison as a sanitiser for the
// cs/unvalidated-local-pointer-arithmetic taint flow.
var maxBytes = Encoding.UTF8.GetMaxByteCount(part.Length);
if (maxBytes <= 0 || maxBytes > MaxLineByteCount)
{
ImGui.TextUnformatted("");
continue;
}
var buffer = ArrayPool<byte>.Shared.Rent(maxBytes);
try
{
var written = Encoding.UTF8.GetBytes(part, 0, part.Length, buffer, 0);
if (written <= 0 || written > maxBytes)
{
ImGui.TextUnformatted("");
continue;
}
WrapEncodedLine(buffer.AsSpan(0, written), chunk, handler, defaultText, lineWidth);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
private static unsafe void WrapEncodedLine(
ReadOnlySpan<byte> bytes,
Chunk chunk,
PayloadHandler? handler,
Vector4 defaultText,
float lineWidth
)
{
var byteCount = bytes.Length;
if (byteCount == 0)
{
ImGui.TextUnformatted("");
return;
}
fixed (byte* basePtr = bytes)
{
var widthLeft = ImGui.GetContentRegionAvail().X;
var endPrev = CalcWordWrap(basePtr, 0, byteCount, widthLeft);
if (endPrev < 0)
return;
var firstSpace = FindFirstSpace(bytes, 0, byteCount);
var properBreak = firstSpace <= endPrev;
if (properBreak)
{
DrawText(basePtr, 0, endPrev, chunk, handler, defaultText);
}
else if (lineWidth == 0f)
{
ImGui.TextUnformatted("");
}
else
{
// Check whether the next chunk would wrap at or past the
// first space. If yes, force a line break.
var wrapPos = CalcWordWrap(basePtr, 0, firstSpace, lineWidth);
if (wrapPos >= firstSpace)
ImGui.TextUnformatted("");
}
widthLeft = ImGui.GetContentRegionAvail().X;
var lineStart = 0;
while (endPrev < byteCount)
{
if (properBreak)
lineStart = endPrev;
// Skip a leading space at the start of a wrapped line.
if (lineStart < byteCount && bytes[lineStart] == (byte)' ')
lineStart++;
var newEnd = CalcWordWrap(basePtr, lineStart, byteCount, widthLeft);
if (properBreak && newEnd == endPrev)
break;
if (newEnd < 0)
{
ImGui.TextUnformatted("");
ImGui.TextUnformatted("");
break;
}
endPrev = newEnd;
DrawText(basePtr, lineStart, endPrev, chunk, handler, defaultText);
if (!properBreak)
{
properBreak = true;
widthLeft = ImGui.GetContentRegionAvail().X;
}
}
}
}
private static unsafe int CalcWordWrap(byte* basePtr, int start, int end, float width)
{
var result = ImGuiNative.CalcWordWrapPositionA(
ImGui.GetFont().Handle,
ImGuiHelpers.GlobalScale,
basePtr + start,
basePtr + end,
width
);
if (result == null)
return -1;
return (int)(result - basePtr);
}
private static unsafe void DrawText(
byte* basePtr,
int start,
int end,
Chunk chunk,
PayloadHandler? handler,
Vector4 defaultText
)
{
var oldPos = ImGui.GetCursorScreenPos();
ImGuiNative.TextUnformatted(basePtr + start, basePtr + end);
PostPayload(chunk, handler);
if (!ReferenceEquals(LastLink, chunk.Link))
PayloadBounds.Clear();
LastLink = chunk.Link;
if (Hovered != null && ReferenceEquals(Hovered, chunk.Link))
{
defaultText.W = 0.25f;
var actualCol = ColourUtil.Vector4ToAbgr(defaultText);
ImGui
.GetWindowDrawList()
.AddRectFilled(oldPos, oldPos + ImGui.GetItemRectSize(), actualCol);
foreach (var (boundsStart, boundsSize) in PayloadBounds)
ImGui
.GetWindowDrawList()
.AddRectFilled(boundsStart, boundsStart + boundsSize, actualCol);
PayloadBounds.Clear();
}
if (Hovered == null && chunk.Link != null)
PayloadBounds.Add((oldPos, ImGui.GetItemRectSize()));
}
private static int FindFirstSpace(ReadOnlySpan<byte> bytes, int start, int end)
{
for (var i = start; i < end; i++)
if (char.IsWhiteSpace((char)bytes[i]))
return i;
return end;
}
// ---------------------------------------------------------------
// Inspired by ChatTwo upstream f35b7d3 (Infiziert90, 2026-05-12).
// Upstream dropped the width parameter (no callers there); we keep
@@ -1,26 +0,0 @@
using System;
using HellionChat.Ui;
namespace HellionChat._Helpers;
// Extracted submit logic from ChatInputBar.SubmitCompact to allow unit testing
// without a sealed ChatLogWindow dependency.
// TEST-MIRROR: ../../../Hellion Build test/Ui/CompactInputSubmitterTests.cs
public static class CompactInputSubmitter
{
public static bool TrySubmit(InputState state, Tab tab, Action<Tab, string> sender)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(tab);
ArgumentNullException.ThrowIfNull(sender);
if (string.IsNullOrWhiteSpace(state.Buffer))
return false;
var text = state.Buffer;
state.Buffer = string.Empty;
state.HistoryCursor = -1;
sender(tab, text);
return true;
}
}