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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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>()));
|
||||
|
||||
@@ -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() { }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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() { }
|
||||
}
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()}"
|
||||
|
||||
@@ -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
@@ -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() { }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user