Files
HellionChat/HellionChat/Ui/ChatLogWindow.cs
T
JonKazama-Hellion cddd29a986 fix(tabs): pin indicator, history preload, drop Promote from temp menu
Smoke-test round 2 feedback from Jin:
- Promote-to-permanent label "Dauerhaft behalten" was indistinguishable
  from Pin in German, leading to misclicks that dropped the tell-target.
  Removed the menu entry from TempTabs entirely — Promote stays as a
  service method for future use, but the user-facing path is gone. Anyone
  who wants a regular tab can still create one via the existing
  "neuen Tab anlegen" flow.
- No visual confirmation that pin took effect. Added a FontAwesome
  thumbtack overlay top-left of the sidebar icon, accent-coloured, and
  appended a "Pinned — survives relog" line to the hover tooltip.
- Pinned tabs came back empty after a full disable/enable cycle because
  Tab.Messages is NonSerialized. RehydratePinnedTabs now also runs the
  same MessageStore-backed PreloadHistory the spawn path uses, so the
  recent conversation window reappears alongside the rehydrated
  TellTarget.

Diagnose-logging on TryPin/Unpin/Promote/Rehydrate stays in so the next
smoke can confirm at a glance which path fired from the Dalamud console.
2026-05-13 10:08:33 +02:00

2774 lines
99 KiB
C#

using System.Diagnostics;
using System.Globalization;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Text;
using Dalamud.Bindings.ImGui;
using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface;
using Dalamud.Interface.Style;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing;
using Dalamud.Memory;
using FFXIVClientStructs.FFXIV.Client.UI;
using HellionChat.Code;
using HellionChat.GameFunctions;
using HellionChat.GameFunctions.Types;
using HellionChat.Integrations;
using HellionChat.Resources;
using HellionChat.Util;
using Lumina.Excel.Sheets;
using Lumina.Extensions;
namespace HellionChat.Ui;
public sealed class ChatLogWindow : Window
{
private const string ChatChannelPicker = "chat-channel-picker";
private const string AutoCompleteId = "##chat2-autocomplete";
private const ImGuiInputTextFlags InputFlags =
ImGuiInputTextFlags.CallbackAlways
| ImGuiInputTextFlags.CallbackCharFilter
| ImGuiInputTextFlags.CallbackCompletion
| ImGuiInputTextFlags.CallbackHistory;
internal Plugin Plugin { get; }
private readonly CommandWrapper _clearHellionCommand;
private readonly CommandWrapper _hellionCommand;
internal bool ScreenshotMode;
private string Salt { get; }
internal Vector4 DefaultText { get; set; }
internal bool FocusedPreview;
internal bool Activate;
internal bool InputFocused { get; private set; }
private int ActivatePos = -1;
internal string Chat = string.Empty;
// Input history extracted into InputHistoryService so pop-out windows share
// the same Up/Down history. Cursor stays window-local (independent navigation).
private int InputBacklogIdx = -1;
public bool TellSpecial;
private readonly Stopwatch LastResize = new();
private AutoCompleteInfo? AutoCompleteInfo;
private bool AutoCompleteOpen;
private List<AutoTranslateEntry>? AutoCompleteList;
private bool FixCursor;
private int AutoCompleteSelection;
private bool AutoCompleteShouldScroll;
// Used to detect channel changes for the webinterface
public Chunk[] PreviousChannel = [];
public int CursorPos;
public Vector2 LastWindowPos { get; private set; } = Vector2.Zero;
public Vector2 LastWindowSize { get; private set; } = Vector2.Zero;
// Guards against off-screen positions after a display layout change.
// One-shot bounds check on first draw; manual reset button bypasses it.
private bool DidOnLoadBoundsCheck;
internal bool RequestPositionReset { get; set; }
public unsafe ImGuiViewport* LastViewport;
private bool WasDocked;
public PayloadHandler PayloadHandler { get; }
internal Lender<PayloadHandler> HandlerLender { get; }
private Dictionary<string, ChatType> TextCommandChannels { get; } = new();
private Dictionary<string, TextCommand> AllCommands { get; } = [];
private const uint ChatOpenSfx = 35u;
private const uint ChatCloseSfx = 3u;
private bool PlayedClosingSound = true;
private bool DrewThisFrame;
// One-shot guard so a recurring draw failure doesn't spam the
// notification stack frame-by-frame. Resets only on next plugin reload.
private bool NotifiedDrawFailure;
private long FrameTime; // set every frame
internal long LastActivityTime = Environment.TickCount64;
internal ChatLogWindow(Plugin plugin)
: base($"{Plugin.PluginName}###chat2")
{
Plugin = plugin;
Salt = new Random().Next().ToString();
Size = new Vector2(500, 250);
SizeCondition = ImGuiCond.FirstUseEver;
PositionCondition = ImGuiCond.Always;
IsOpen = true;
RespectCloseHotkey = false;
DisableWindowSounds = true;
// AllowBackgroundBlur is set centrally in Plugin.Setup after AddWindow.
PayloadHandler = new PayloadHandler(this);
HandlerLender = new Lender<PayloadHandler>(() => new PayloadHandler(this));
SetUpTextCommandChannels();
SetUpAllCommands();
// Cache wrapper instances so Dispose can detach the same event objects
// without going through Register() again.
_clearHellionCommand = Plugin.Commands.Register(
"/clearhellion",
"Clear the Hellion Chat log"
);
_hellionCommand = Plugin.Commands.Register("/hellion");
_clearHellionCommand.Execute += ClearLog;
_hellionCommand.Execute += ToggleChat;
Plugin.ClientState.Login += Login;
Plugin.ClientState.Logout += Logout;
Plugin.AddonLifecycle.RegisterListener(
AddonEvent.PostUpdate,
"ItemDetail",
PayloadHandler.MoveTooltip
);
Plugin.AddonLifecycle.RegisterListener(
AddonEvent.PostUpdate,
"ActionDetail",
PayloadHandler.MoveTooltip
);
}
public void Dispose()
{
Plugin.AddonLifecycle.UnregisterListener(
AddonEvent.PostUpdate,
"ItemDetail",
PayloadHandler.MoveTooltip
);
Plugin.AddonLifecycle.UnregisterListener(
AddonEvent.PostUpdate,
"ActionDetail",
PayloadHandler.MoveTooltip
);
Plugin.ClientState.Logout -= Logout;
Plugin.ClientState.Login -= Login;
_hellionCommand.Execute -= ToggleChat;
_clearHellionCommand.Execute -= ClearLog;
}
private void Logout(int _, int __)
{
Plugin.MessageManager.ClearAllTabs();
}
private void Login()
{
Plugin.MessageManager.FilterAllTabsAsync();
}
internal unsafe void Activated(ChatActivatedArgs args)
{
TellSpecial = args.TellSpecial;
Activate = true;
PlayedClosingSound = false;
if (Plugin.Config.PlaySounds)
UIGlobals.PlaySoundEffect(ChatOpenSfx);
// Don't set the channel or text content when activating a disabled tab.
if (Plugin.CurrentTab.InputDisabled)
{
// The closing sound would've been immediately played in this case.
PlayedClosingSound = true;
return;
}
if (args.AddIfNotPresent != null && !Chat.Contains(args.AddIfNotPresent))
Chat += args.AddIfNotPresent;
if (args.Input != null)
Chat += args.Input;
var (info, reason, target) = (args.ChannelSwitchInfo, args.TellReason, args.TellTarget);
if (info.Channel != null)
{
var targetChannel = info.Channel;
if (info.Channel is InputChannel.Tell)
{
if (info.Rotate != RotateMode.None)
{
var idx =
Plugin.CurrentTab.CurrentChannel.TempChannel != InputChannel.Tell ? 0
: info.Rotate == RotateMode.Reverse ? -1
: 1;
var tellInfo = Plugin.Functions.Chat.GetTellHistoryInfo(idx);
if (tellInfo != null && reason != null)
Plugin.CurrentTab.CurrentChannel.TempTellTarget = new TellTarget(
tellInfo.Name,
(ushort)tellInfo.World,
tellInfo.ContentId,
reason.Value
);
}
else
{
Plugin.CurrentTab.CurrentChannel.TellTarget = null;
if (target != null)
{
if (info.Permanent)
{
Plugin.CurrentTab.CurrentChannel.TellTarget = target;
}
else
{
Plugin.CurrentTab.CurrentChannel.UseTempChannel = true;
Plugin.CurrentTab.CurrentChannel.TempTellTarget = target;
}
}
}
}
else
{
Plugin.CurrentTab.CurrentChannel.TellTarget = null;
}
if (
info.Channel is InputChannel.Linkshell1 or InputChannel.CrossLinkshell1
&& info.Rotate != RotateMode.None
)
{
var module = UIModule.Instance();
// If any of these operations fail, do nothing.
if (info.Permanent)
{
// Rotate using the game's code.
if (info.Channel == InputChannel.Linkshell1)
{
GameFunctions.Chat.RotateLinkshellHistory(info.Rotate);
targetChannel = info.Channel + (uint)module->LinkshellCycle;
}
else
{
GameFunctions.Chat.RotateCrossLinkshellHistory(info.Rotate);
targetChannel = info.Channel + (uint)module->CrossWorldLinkshellCycle;
}
}
else
{
targetChannel = GameFunctions.Chat.ResolveTempInputChannel(
Plugin.CurrentTab.CurrentChannel.TempChannel,
info.Channel.Value,
info.Rotate
);
}
}
if (
targetChannel == null
|| !GameFunctions.Chat.IsChannelOrExistingLinkshell(targetChannel.Value)
)
{
Plugin.LogProxy.Warning(
$"Channel was set to an invalid value '{targetChannel}', ignoring"
);
return;
}
if (info.Permanent)
{
SetChannel(targetChannel);
}
else
{
Plugin.CurrentTab.CurrentChannel.UseTempChannel = true;
Plugin.CurrentTab.CurrentChannel.TempChannel = targetChannel.Value;
}
}
if (info.Text != null && Chat.Length == 0)
Chat = info.Text;
}
private bool IsValidCommand(string command)
{
return Plugin.CommandManager.Commands.ContainsKey(command)
|| AllCommands.ContainsKey(command);
}
private void ClearLog(string command, string arguments)
{
switch (arguments)
{
case "all":
Plugin.MessageManager.ClearAllTabs();
break;
case "help":
Plugin.ChatGui.Print("- /clearlog2: clears the active tab's log");
Plugin.ChatGui.Print(
"- /clearlog2 all: clears all tabs' logs and the global history"
);
Plugin.ChatGui.Print("- /clearlog2 help: shows this help");
break;
default:
if (Plugin.LastTab > -1 && Plugin.LastTab < Plugin.Config.Tabs.Count)
Plugin.Config.Tabs[Plugin.LastTab].Clear();
break;
}
}
private void ToggleChat(string _, string arguments)
{
switch (arguments)
{
case "hide":
CurrentHideState = HideState.User;
Plugin.LogProxy.Verbose("HideState: → User (chat hide command)");
break;
case "show":
CurrentHideState = HideState.None;
Plugin.LogProxy.Verbose("HideState: → None (chat show command)");
break;
case "toggle":
CurrentHideState = CurrentHideState switch
{
HideState.User or HideState.CutsceneOverride => HideState.None,
HideState.Cutscene => HideState.CutsceneOverride,
HideState.None => HideState.User,
_ => CurrentHideState,
};
Plugin.LogProxy.Verbose($"HideState: → {CurrentHideState} (chat toggle command)");
break;
}
}
private void SetUpTextCommandChannels()
{
TextCommandChannels.Clear();
foreach (var input in Enum.GetValues<InputChannel>())
{
var commands = input.TextCommands();
if (commands == null)
continue;
var type = input.ToChatType();
foreach (var command in commands)
AddTextCommandChannel(command, type);
}
if (Sheets.TextCommandSheet.TryGetRow(116, out var row))
AddTextCommandChannel(row, ChatType.Echo);
}
private void AddTextCommandChannel(TextCommand command, ChatType type)
{
TextCommandChannels[command.Command.ExtractText()] = type;
TextCommandChannels[command.ShortCommand.ExtractText()] = type;
TextCommandChannels[command.Alias.ExtractText()] = type;
TextCommandChannels[command.ShortAlias.ExtractText()] = type;
}
private void SetUpAllCommands()
{
foreach (var command in Sheets.TextCommandSheet)
{
if (!command.Command.IsEmpty)
AllCommands.TryAdd(command.Command.ToString(), command);
if (!command.ShortCommand.IsEmpty)
AllCommands.TryAdd(command.ShortCommand.ToString(), command);
if (!command.Alias.IsEmpty)
AllCommands.TryAdd(command.Alias.ToString(), command);
if (!command.ShortAlias.IsEmpty)
AllCommands.TryAdd(command.ShortAlias.ToString(), command);
}
}
// Delegates to InputHistoryService so pop-out ChatInputBar instances share
// history. Deduplication lives inside the service.
private void AddBacklog(string message)
{
InputHistoryService.Push(message);
}
private float GetRemainingHeightForMessageLog()
{
var lineHeight = ImGui.CalcTextSize("A").Y;
var height =
ImGui.GetContentRegionAvail().Y
- lineHeight * 2
- ImGui.GetStyle().ItemSpacing.Y
- ImGui.GetStyle().FramePadding.Y * 2;
if (Plugin.Config.PreviewPosition is PreviewPosition.Inside)
height -= Plugin.InputPreview.PreviewHeight;
// Header toolbar height is not subtracted by GetContentRegionAvail automatically
// (it renders outside the normal layout path), so we subtract it explicitly.
// The hint banner renders before this block so ImGui already accounts for it.
height -= ImGui.GetFrameHeightWithSpacing();
// Status bar at the window bottom reserves 22px + 2px spacing.
height -= StatusBar.Height + 2;
return height;
}
internal void ChangeTab(int index)
{
Plugin.WantedTab = index;
LastActivityTime = FrameTime;
}
internal void ChangeTabDelta(int offset)
{
var newIndex = (Plugin.LastTab + offset) % Plugin.Config.Tabs.Count;
while (newIndex < 0)
newIndex += Plugin.Config.Tabs.Count;
ChangeTab(newIndex);
}
private void TabSwitched(Tab newTab, Tab previousTab)
{
// Use the fixed channel if set by the user, or set it to the current tabs channel if this tab wasn't accessed before
if (newTab.Channel is not null)
newTab.CurrentChannel.Channel = newTab.Channel.Value;
else if (newTab.CurrentChannel.Channel is InputChannel.Invalid)
newTab.CurrentChannel = previousTab.CurrentChannel;
SetChannel(newTab.CurrentChannel.Channel);
}
private enum HideState
{
None,
Cutscene,
CutsceneOverride,
User,
Battle,
}
private HideState CurrentHideState = HideState.None;
public bool IsHidden;
public void HideStateCheck()
{
// if the chat has no hide state set, and the player has entered battle, we hide chat if they have configured it
if (Plugin.Config.HideInBattle && CurrentHideState == HideState.None && Plugin.InBattle)
{
CurrentHideState = HideState.Battle;
Plugin.LogProxy.Verbose("HideState: None → Battle");
}
// If the chat is hidden because of battle, we reset it here
if (CurrentHideState is HideState.Battle && !Plugin.InBattle)
{
CurrentHideState = HideState.None;
Plugin.LogProxy.Verbose("HideState: Battle → None");
}
// if the chat has no hide state and in a cutscene, set the hide state to cutscene
if (
Plugin.Config.HideDuringCutscenes
&& CurrentHideState == HideState.None
&& (Plugin.CutsceneActive || Plugin.GposeActive)
)
{
if (Plugin.Functions.Chat.CheckHideFlags())
{
CurrentHideState = HideState.Cutscene;
Plugin.LogProxy.Verbose("HideState: None → Cutscene");
}
}
// if the chat is hidden because of a cutscene and no longer in a cutscene, set the hide state to none
if (
CurrentHideState is HideState.Cutscene or HideState.CutsceneOverride
&& !Plugin.CutsceneActive
&& !Plugin.GposeActive
)
{
Plugin.LogProxy.Verbose($"HideState: {CurrentHideState} → None (cutscene/gpose ended)");
CurrentHideState = HideState.None;
}
// if the chat is hidden because of a cutscene and the chat has been activated, show chat
if (CurrentHideState == HideState.Cutscene && Activate)
{
CurrentHideState = HideState.CutsceneOverride;
Plugin.LogProxy.Verbose("HideState: Cutscene → CutsceneOverride (user activate)");
}
// if the user hid the chat and is now activating chat, reset the hide state
if (CurrentHideState == HideState.User && Activate)
{
CurrentHideState = HideState.None;
Plugin.LogProxy.Verbose("HideState: User → None (activate)");
}
if (
CurrentHideState is HideState.Cutscene or HideState.User or HideState.Battle
|| (Plugin.Config.HideWhenNotLoggedIn && !Plugin.ClientState.IsLoggedIn)
)
{
IsHidden = true;
return;
}
IsHidden = false;
}
internal void BeginFrame()
{
DrewThisFrame = false;
}
internal void FinalizeFrame()
{
if (!DrewThisFrame)
InputFocused = false;
}
public override unsafe void PreOpenCheck()
{
Flags =
ImGuiWindowFlags.NoScrollbar
| ImGuiWindowFlags.NoScrollWithMouse
| ImGuiWindowFlags.NoFocusOnAppearing;
if (!Plugin.Config.CanMove)
Flags |= ImGuiWindowFlags.NoMove;
if (!Plugin.Config.CanResize)
Flags |= ImGuiWindowFlags.NoResize;
if (!Plugin.Config.ShowTitleBar)
Flags |= ImGuiWindowFlags.NoTitleBar;
// BgAlpha wird auf den Style-WindowBg-Alpha aus HellionStyle.PushGlobal
// multipliziert (HellionStyle pusht eine voll-deckende Theme-Color, der
// tatsächliche transparent-Effekt entsteht über BgAlpha). Wenn der User
// im Dalamud-Pinning-Menü (Hamburger oben rechts) eine eigene
// Window-Deckkraft eingestellt hat, hat dieses Per-Window-Override
// Vorrang über unseren Slider — wir dokumentieren das im HelpMarker.
if (LastViewport == ImGuiHelpers.MainViewport.Handle && !WasDocked)
BgAlpha = Plugin.Config.WindowOpacity;
LastViewport = ImGui.GetWindowViewport().Handle;
WasDocked = ImGui.IsWindowDocked();
}
public override bool DrawConditions()
{
FrameTime = Environment.TickCount64;
if (IsHidden)
return false;
if (
!Plugin.Config.HideWhenInactive
|| (!Plugin.Config.InactivityHideActiveDuringBattle && Plugin.InBattle)
|| Activate
)
{
LastActivityTime = FrameTime;
return true;
}
var currentTab = Plugin.CurrentTab; // local to avoid calling the getter repeatedly
var lastActivityTime = Plugin
.Config.Tabs.Where(tab => !tab.PopOut && (tab.UnhideOnActivity || tab == currentTab))
.Select(tab => tab.LastActivity)
.Append(LastActivityTime)
.Max();
return FrameTime - lastActivityTime <= 1000 * Plugin.Config.InactivityHideTimeout;
}
public override void PreDraw()
{
if (Plugin.Config.KeepInputFocus && Activate)
ImGui.SetWindowFocus(WindowName);
// Hellion Chat v1.1.0+ — Theme-Engine ist Source-of-Truth, kein
// zusätzlicher Dalamud-StyleModel-Override mehr pro Window. Plugin.Draw
// pusht das aktive Hellion-Theme global; ChatLogWindow zeichnet sich
// damit konsistent zu Settings/Pop-Out/Wizard. Wer den Upstream-Look
// will, wählt das Built-In-Theme "Chat 2 Klassik" in Settings → Themes.
}
public override void PostDraw()
{
// Set Activate to false after draw to avoid repeatedly trying to focus
// the text input in a tab with input disabled. The usual way that
// Activate gets disabled is via the text input callback, but that
// doesn't get called if the input is disabled.
if (Plugin.CurrentTab.InputDisabled)
Activate = false;
}
public override void OnClose()
{
// We force the main log to be always open
IsOpen = true;
}
public override void Draw()
{
DrewThisFrame = true;
try
{
DrawChatLog();
AddPopOutsToDraw();
DrawAutoComplete();
}
catch (Exception ex)
{
Plugin.LogProxy.Error(ex, "Error drawing Chat Log window");
if (!NotifiedDrawFailure)
{
Plugin.Notification.AddNotification(
new Dalamud.Interface.ImGuiNotification.Notification
{
Title = "Hellion Chat",
Content = "A drawing error occurred. Check /xllog for details.",
Type = Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
InitialDuration = TimeSpan.FromSeconds(20),
}
);
NotifiedDrawFailure = true;
}
// Prevent recurring draw failures from constantly trying to grab
// input focus, which breaks every other ImGui window.
Activate = false;
}
}
private static bool IsChatMode =>
Plugin.Config.PreviewPosition is PreviewPosition.Inside or PreviewPosition.Tooltip;
private unsafe void DrawChatLog()
{
// Position change has applied, so we set it to null again
Position = null;
var currentSize = ImGui.GetWindowSize();
var resized = LastWindowSize != currentSize;
LastWindowSize = currentSize;
LastWindowPos = ImGui.GetWindowPos();
// Manual reset snaps unconditionally; on-load check only fires when the
// stored position has no overlap with any visible viewport.
if (RequestPositionReset)
{
RequestPositionReset = false;
DidOnLoadBoundsCheck = true;
ApplySafeDefaultPosition("manual-reset");
}
else if (!DidOnLoadBoundsCheck)
{
DidOnLoadBoundsCheck = true;
EnsureWindowOnScreen("on-load");
}
if (resized)
LastResize.Restart();
LastViewport = ImGui.GetWindowViewport().Handle;
WasDocked = ImGui.IsWindowDocked();
if (IsChatMode && Plugin.InputPreview.IsDrawable)
Plugin.InputPreview.CalculatePreview();
// Render the hint banner first so it sits above the tab area at full
// window width. ImGui accounts for its height automatically.
DrawV061HintBannerIfNeeded();
if (Plugin.Config.SidebarTabView)
DrawTabSidebar();
else
DrawTabBar();
var activeTab = Plugin.CurrentTab;
// This tab has a fixed channel, so we force this channel to be always set as current
if (activeTab.Channel is not null)
activeTab.CurrentChannel.SetChannel(activeTab.Channel.Value);
if (
Plugin.Config.PreviewPosition is PreviewPosition.Inside
&& Plugin.InputPreview.IsDrawable
)
Plugin.InputPreview.DrawPreview();
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero))
{
DrawChannelName(activeTab);
}
// inputColour computed up front so the channel selector button can share it.
var inputType = activeTab.CurrentChannel.UseTempChannel
? activeTab.CurrentChannel.TempChannel.ToChatType()
: activeTab.CurrentChannel.Channel.ToChatType();
var isCommand = Chat.Trim().StartsWith('/');
if (isCommand)
{
var command = Chat.Split(' ')[0];
if (TextCommandChannels.TryGetValue(command, out var channel))
inputType = channel;
if (!IsValidCommand(command))
inputType = ChatType.Error;
}
var inputColour = Plugin.Config.ChatColours.TryGetValue(inputType, out var inputCol)
? inputCol
: inputType.DefaultColor();
if (!isCommand && Plugin.ExtraChat.ChannelOverride is var (_, overrideColour))
inputColour = overrideColour;
if (
isCommand
&& Plugin.ExtraChat.ChannelCommandColours.TryGetValue(
Chat.Split(' ')[0],
out var ecColour
)
)
inputColour = ecColour;
var beforeIcon = ImGui.GetCursorPos();
var tintSelector = Plugin.Config.ColorSelectedInputChannelButton && inputColour.HasValue;
var selectorAbgr = tintSelector ? ColourUtil.RgbaToAbgr(inputColour!.Value) : 0u;
using (ImRaii.PushColor(ImGuiCol.Button, selectorAbgr, tintSelector))
using (
ImRaii.PushColor(
ImGuiCol.ButtonHovered,
ColourUtil.AdjustBrightness(selectorAbgr, 1.15f),
tintSelector
)
)
using (
ImRaii.PushColor(
ImGuiCol.ButtonActive,
ColourUtil.AdjustBrightness(selectorAbgr, 0.85f),
tintSelector
)
)
{
if (ImGuiUtil.IconButton(FontAwesomeIcon.Comment) && activeTab.Channel is null)
ImGui.OpenPopup(ChatChannelPicker);
}
if (activeTab.Channel is not null && ImGui.IsItemHovered())
ImGuiUtil.Tooltip(Language.ChatLog_SwitcherDisabled);
using (var popup = ImRaii.Popup(ChatChannelPicker))
{
if (popup)
{
var channels = GetValidChannels();
foreach (var (name, channel) in channels)
if (ImGui.Selectable(name))
SetChannel(channel);
}
}
ImGui.SameLine();
var afterIcon = ImGui.GetCursorPos();
var buttonWidth = afterIcon.X - beforeIcon.X;
var showNovice = Plugin.Config.ShowNoviceNetwork && GameFunctions.GameFunctions.IsMentor();
var buttonsRight = (showNovice ? 1 : 0) + (Plugin.Config.ShowHideButton ? 1 : 0);
var inputWidth = ImGui.GetContentRegionAvail().X - buttonWidth * (1 + buttonsRight);
var normalColor = ImGui.GetColorU32(ImGuiCol.Text);
var push = inputColour != null;
using (
ImRaii.PushColor(
ImGuiCol.Text,
push ? ColourUtil.RgbaToAbgr(inputColour!.Value) : 0,
push
)
)
{
var isChatEnabled = activeTab is { InputDisabled: false };
if (isChatEnabled && (Activate || FocusedPreview))
{
FocusedPreview = false;
ImGui.SetKeyboardFocusHere();
}
var chatCopy = Chat;
using (ImRaii.Disabled(!isChatEnabled))
{
var flags =
InputFlags
| (!isChatEnabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None);
ImGui.SetNextItemWidth(inputWidth);
ImGui.InputTextWithHint(
"##chat2-input",
isChatEnabled ? "" : Language.ChatLog_DisabledInput,
ref Chat,
500,
flags,
Callback
);
}
var inputActive = ImGui.IsItemActive();
InputFocused = isChatEnabled && inputActive;
var tooltipDraw =
Plugin.Config.PreviewPosition is PreviewPosition.Tooltip
&& Plugin.InputPreview.IsDrawable;
if (tooltipDraw && ImGui.IsItemHovered())
{
ImGui.SetNextWindowSize(new Vector2(500 * ImGuiHelpers.GlobalScale, -1));
using var tooltip = ImRaii.Tooltip();
Plugin.InputPreview.DrawPreview();
}
if (ImGui.IsItemDeactivated())
{
if (ImGui.IsKeyDown(ImGuiKey.Escape))
{
Chat = chatCopy;
if (activeTab.CurrentChannel.UseTempChannel)
{
activeTab.CurrentChannel.ResetTempChannel();
SetChannel(activeTab.CurrentChannel.Channel);
}
}
if (ImGui.IsKeyDown(ImGuiKey.Enter) || ImGui.IsKeyDown(ImGuiKey.KeypadEnter))
{
Plugin.CommandHelpWindow.IsOpen = false;
SendChatBox(activeTab);
if (activeTab.CurrentChannel.UseTempChannel)
{
activeTab.CurrentChannel.ResetTempChannel();
SetChannel(activeTab.CurrentChannel.Channel);
}
}
}
// Process keybinds that have modifiers while the chat is focused.
if (inputActive)
{
Plugin.Functions.KeybindManager.HandleKeybinds(KeyboardSource.ImGui, true, true);
LastActivityTime = FrameTime;
}
// Only trigger unfocused if we are currently not calling the auto complete
if (!Activate && !inputActive && AutoCompleteInfo == null)
{
if (Plugin.Config.PlaySounds && !PlayedClosingSound)
{
PlayedClosingSound = true;
UIGlobals.PlaySoundEffect(ChatCloseSfx);
}
if (activeTab.CurrentChannel.UseTempChannel)
{
activeTab.CurrentChannel.ResetTempChannel();
SetChannel(Plugin.CurrentTab.CurrentChannel.Channel);
}
}
using (var context = ImRaii.ContextPopupItem("ChatInputContext"))
{
if (context)
{
using var pushedColor = ImRaii.PushColor(ImGuiCol.Text, normalColor);
if (ImGui.Selectable(Language.ChatLog_HideChat))
UserHide();
}
}
}
ImGui.SameLine();
if (ImGuiUtil.IconButton(FontAwesomeIcon.Cog, width: (int)buttonWidth))
Plugin.SettingsWindow.Toggle();
if (Plugin.Config.ShowHideButton)
{
ImGui.SameLine();
if (ImGuiUtil.IconButton(FontAwesomeIcon.EyeSlash, width: (int)buttonWidth))
UserHide();
}
if (ImGui.IsWindowHovered(ImGuiHoveredFlags.ChildWindows))
LastActivityTime = FrameTime;
if (showNovice)
{
ImGui.SameLine();
if (ImGuiUtil.IconButton(FontAwesomeIcon.Leaf))
GameFunctions.GameFunctions.ClickNoviceNetworkButton();
}
// v1.2.0 — Bottom-Status-Bar. Letzter Render-Step in DrawChatLog,
// damit alle Zeilen-Operationen davor keine Layout-Sprünge auslösen.
Plugin.StatusBar.Draw(Plugin);
}
internal Dictionary<string, InputChannel> GetValidChannels()
{
var channels = new Dictionary<string, InputChannel>();
foreach (var channel in Enum.GetValues<InputChannel>())
{
if (!channel.IsValid())
continue;
var name =
Sheets
.LogFilterSheet.FirstOrNull(row => row.LogKind == (byte)channel.ToChatType())
?.Name.ToString()
?? channel.ToChatType().Name();
if (channel.IsLinkshell())
{
var lsName = Plugin.Functions.Chat.GetLinkshellName(channel.LinkshellIndex());
if (string.IsNullOrWhiteSpace(lsName))
continue;
name += $": {lsName}";
}
if (channel.IsCrossLinkshell())
{
var lsName = Plugin.Functions.Chat.GetCrossLinkshellName(channel.LinkshellIndex());
if (string.IsNullOrWhiteSpace(lsName))
continue;
name += $": {lsName}";
}
// Check if the linkshell with this index is registered in
// the ExtraChat plugin by seeing if the command is
// registered. The command gets registered only if a
// linkshell is assigned (and even gets unassigned if the
// index changes!).
if (channel.IsExtraChatLinkshell())
if (!Plugin.CommandManager.Commands.ContainsKey(channel.Prefix()))
continue;
channels.Add(name, channel);
}
return channels;
}
private void DrawChannelName(Tab activeTab)
{
var currentChannel = ReadChannelName(activeTab);
if (!currentChannel.SequenceEqual(PreviousChannel))
PreviousChannel = currentChannel;
DrawChunks(currentChannel);
}
private Chunk[] ReadChannelName(Tab activeTab)
{
Chunk[] channelNameChunks;
// Check the temp channel before others
if (activeTab.CurrentChannel.UseTempChannel)
{
if (
activeTab.CurrentChannel.TempTellTarget != null
&& activeTab.CurrentChannel.TempTellTarget.IsSet()
)
{
channelNameChunks = GenerateTellTargetName(activeTab.CurrentChannel.TempTellTarget);
}
else
{
string name;
if (activeTab.CurrentChannel.TempChannel.IsLinkshell())
{
var idx =
(uint)activeTab.CurrentChannel.TempChannel - (uint)InputChannel.Linkshell1;
var lsName = Plugin.Functions.Chat.GetLinkshellName(idx);
name = $"LS #{idx + 1}: {lsName}";
}
else if (activeTab.CurrentChannel.TempChannel.IsCrossLinkshell())
{
var idx =
(uint)activeTab.CurrentChannel.TempChannel
- (uint)InputChannel.CrossLinkshell1;
var cwlsName = Plugin.Functions.Chat.GetCrossLinkshellName(idx);
name = $"CWLS [{idx + 1}]: {cwlsName}";
}
else
{
name = activeTab.CurrentChannel.TempChannel.ToChatType().Name();
}
channelNameChunks = [new TextChunk(ChunkSource.None, null, name)];
}
}
else if (activeTab.CurrentChannel.TellTarget?.IsSet() == true)
{
channelNameChunks = GenerateTellTargetName(activeTab.CurrentChannel.TellTarget);
}
else if (activeTab is { Channel: { } channel })
{
if (channel == InputChannel.Tell && activeTab.TellTarget.IsSet())
{
channelNameChunks = GenerateTellTargetName(activeTab.TellTarget);
}
else
{
// ExtraChat channel names aren't available over IPC by index,
// so we skip the name lookup and show the short form instead.
channelNameChunks =
[
new TextChunk(
ChunkSource.None,
null,
channel.IsExtraChatLinkshell()
? $"ECLS [{channel.LinkshellIndex() + 1}]"
: channel.ToChatType().Name()
),
];
}
}
else if (Plugin.ExtraChat.ChannelOverride is var (overrideName, _))
{
// If the current channel is not an ExtraChat Linkshell add a warning for the user
var warning = activeTab.CurrentChannel.Channel.IsExtraChatLinkshell()
? ""
: $" (Warning: {activeTab.CurrentChannel.Channel.ToChatType().Name()})";
channelNameChunks = [new TextChunk(ChunkSource.None, null, $"{overrideName}{warning}")];
}
else if (
ScreenshotMode
&& activeTab.CurrentChannel.Channel is InputChannel.Tell
&& activeTab.CurrentChannel.TellTarget != null
)
{
if (
!string.IsNullOrWhiteSpace(activeTab.CurrentChannel.TellTarget.Name)
&& activeTab.CurrentChannel.TellTarget.World != 0
)
{
// Note: don't use HidePlayerInString here because abbreviation settings do not affect this.
var playerName = HashPlayer(
activeTab.CurrentChannel.TellTarget.Name,
activeTab.CurrentChannel.TellTarget.World
);
var world = Sheets.WorldSheet.TryGetRow(
activeTab.CurrentChannel.TellTarget.World,
out var worldRow
)
? worldRow.Name.ExtractText()
: "???";
channelNameChunks =
[
new TextChunk(ChunkSource.None, null, "Tell "),
new TextChunk(ChunkSource.None, null, playerName),
new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld),
new TextChunk(ChunkSource.None, null, world),
];
}
else
{
// We still need to censor the name if we couldn't read valid data.
channelNameChunks = [new TextChunk(ChunkSource.None, null, "Tell")];
}
}
else
{
channelNameChunks =
activeTab.CurrentChannel.Name.Count > 0
? activeTab.CurrentChannel.Name.ToArray()
:
[
new TextChunk(
ChunkSource.None,
null,
activeTab.CurrentChannel.Channel.ToChatType().Name()
),
];
}
return channelNameChunks;
}
internal void SetChannel(InputChannel? channel)
{
channel ??= InputChannel.Say;
if (channel != InputChannel.Tell)
{
Plugin.CurrentTab.CurrentChannel.TellTarget = null;
Plugin.CurrentTab.CurrentChannel.TempTellTarget = null;
}
// ExtraChat linkshell channel switch: call the prefix command through the
// game chat because ExtraChat only registers stub handlers in Dalamud.
if (channel.Value.IsExtraChatLinkshell())
{
// Check that the command is registered in Dalamud so the game code
// never sees the command itself.
if (!Plugin.CommandManager.Commands.ContainsKey(channel.Value.Prefix()))
return;
// Send the command through the game chat. We can't call
// ICommandManager.ProcessCommand() here because ExtraChat only
// registers stub handlers and actually processes its commands in a
// SendMessage detour.
var bytes = Encoding.UTF8.GetBytes(channel.Value.Prefix());
ChatBox.SendMessageUnsafe(bytes);
Plugin.CurrentTab.CurrentChannel.Channel = channel.Value;
return;
}
var target =
Plugin.CurrentTab.CurrentChannel.TempTellTarget
?? Plugin.CurrentTab.CurrentChannel.TellTarget;
Plugin.Functions.Chat.SetChannel(channel.Value, target);
}
private Chunk[] GenerateTellTargetName(TellTarget tellTarget)
{
var playerName = tellTarget.Name;
if (ScreenshotMode)
// Note: don't use HidePlayerInString here because
// abbreviation settings do not affect this.
playerName = HashPlayer(tellTarget.Name, tellTarget.World);
var world = Sheets.WorldSheet.TryGetRow(tellTarget.World, out var worldRow)
? worldRow.Name.ToString()
: "???";
return
[
new TextChunk(ChunkSource.None, null, "Tell "),
new TextChunk(ChunkSource.None, null, playerName),
new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld),
new TextChunk(ChunkSource.None, null, world),
];
}
// Pop-out windows route submission here. The main Chat buffer is briefly
// used as a vehicle for SendChatBox and restored afterwards.
internal void SendChatBoxFromExternal(Tab tab, string text)
{
var saved = Chat;
Chat = text;
SendChatBox(tab);
Chat = saved;
}
internal void SendChatBox(Tab activeTab)
{
if (!string.IsNullOrWhiteSpace(Chat))
{
var trimmed = Chat.Trim();
AddBacklog(trimmed);
InputBacklogIdx = -1;
if (HasTranslationCommand(trimmed))
{
activeTab.CurrentChannel.ResetTempChannel();
Chat = string.Empty;
return;
}
if (TellSpecial)
{
var tellBytes = Encoding.UTF8.GetBytes(trimmed);
AutoTranslate.ReplaceWithPayload(ref tellBytes);
Plugin.Functions.Chat.SendTellUsingCommandInner(tellBytes);
TellSpecial = false;
activeTab.CurrentChannel.ResetTempChannel();
Chat = string.Empty;
return;
}
if (!trimmed.StartsWith('/'))
{
var target = activeTab.TellTarget.IsSet()
? activeTab.TellTarget
: activeTab.CurrentChannel.TempTellTarget
?? activeTab.CurrentChannel.TellTarget;
if (target != null)
{
// ContentId 0: can't send directly, so format as /tell and let the game handle it.
if (target.ContentId == 0)
{
trimmed = $"/tell {target.ToTargetString()} {trimmed}";
var tellBytes = Encoding.UTF8.GetBytes(trimmed);
AutoTranslate.ReplaceWithPayload(ref tellBytes);
ChatBox.SendMessageUnsafe(tellBytes);
activeTab.CurrentChannel.ResetTempChannel();
Chat = string.Empty;
return;
}
var reason = target.Reason;
var world = Sheets.WorldSheet.GetRow(target.World);
if (world is { IsPublic: true })
{
if (
reason == TellReason.Reply
&& GameFunctions
.GameFunctions.GetFriends()
.Any(friend => friend.ContentId == target.ContentId)
)
reason = TellReason.Friend;
var tellBytes = Encoding.UTF8.GetBytes(trimmed);
AutoTranslate.ReplaceWithPayload(ref tellBytes);
Plugin.Functions.Chat.SendTell(
reason,
target.ContentId,
target.Name,
(ushort)world.RowId,
tellBytes,
trimmed
);
}
activeTab.CurrentChannel.ResetTempChannel();
Chat = string.Empty;
return;
}
if (activeTab.CurrentChannel.UseTempChannel)
trimmed = $"{activeTab.CurrentChannel.TempChannel.Prefix()} {trimmed}";
else
trimmed = $"{activeTab.CurrentChannel.Channel.Prefix()} {trimmed}";
}
var bytes = Encoding.UTF8.GetBytes(trimmed);
AutoTranslate.ReplaceWithPayload(ref bytes);
ChatBox.SendMessageUnsafe(bytes);
}
activeTab.CurrentChannel.ResetTempChannel();
Chat = string.Empty;
}
private bool HasTranslationCommand(string trimmed)
{
var messageBytes = Encoding.UTF8.GetBytes(trimmed);
if (AutoTranslate.StartsWithCommand(ref messageBytes))
{
ChatBox.SendMessageUnsafe(messageBytes);
return true;
}
return false;
}
internal void UserHide()
{
CurrentHideState = HideState.User;
}
internal void DrawMessageLog(
Tab tab,
PayloadHandler handler,
float childHeight,
bool switchedTab
)
{
using var child = ImRaii.Child("##chat2-messages", new Vector2(-1, childHeight));
if (!child.Success)
return;
if (tab.DisplayTimestamp && Plugin.Config.PrettierTimestamps)
DrawLogTableStyle(tab, handler, switchedTab);
else
DrawLogNormalStyle(tab, handler, switchedTab);
}
private void DrawLogNormalStyle(Tab tab, PayloadHandler handler, bool switchedTab)
{
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero))
DrawMessages(tab, handler, false);
if (switchedTab || ImGui.GetScrollY() >= ImGui.GetScrollMaxY())
ImGui.SetScrollHereY(1f);
handler.Draw();
}
private void DrawLogTableStyle(Tab tab, PayloadHandler handler, bool switchedTab)
{
var compact = Plugin.Config.MoreCompactPretty;
var oldItemSpacing = ImGui.GetStyle().ItemSpacing;
var oldCellPadding = ImGui.GetStyle().CellPadding;
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero))
using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, oldCellPadding with { Y = 0 }, compact))
{
using var table = ImRaii.Table("timestamp-table", 2, ImGuiTableFlags.PreciseWidths);
if (!table.Success)
return;
ImGui.TableSetupColumn("timestamps", ImGuiTableColumnFlags.WidthFixed);
ImGui.TableSetupColumn("messages", ImGuiTableColumnFlags.WidthStretch);
DrawMessages(tab, handler, true, compact, oldCellPadding.Y);
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, oldItemSpacing))
using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, oldCellPadding))
{
// Custom styles can have cellPadding that go above 4, which GetScrollY isn't respecting
var cellPaddingOffset =
!compact && oldCellPadding.Y > 4f ? oldCellPadding.Y - 4f : 0f;
if (switchedTab || ImGui.GetScrollY() + cellPaddingOffset >= ImGui.GetScrollMaxY())
ImGui.SetScrollHereY(1f);
handler.Draw();
}
}
}
private void DrawMessages(
Tab tab,
PayloadHandler handler,
bool isTable,
bool moreCompact = false,
float oldCellPaddingY = 0
)
{
try
{
// This may produce ApplicationException which is catched below.
using var messages = tab.Messages.GetReadOnly(3);
var reset = false;
if (LastResize is { IsRunning: true, Elapsed.TotalSeconds: > 0.25 })
{
LastResize.Stop();
LastResize.Reset();
reset = true;
}
var lastPosY = ImGui.GetCursorPosY();
var lastTimestamp = string.Empty;
int? lastMessageHash = null;
var sameCount = 0;
var maxLines = Plugin.Config.MaxLinesToRender;
var startLine = messages.Count > maxLines ? messages.Count - maxLines : 0;
// Card-mode pre-loop: theme/drawList/winLeft/winRight/border are invariant
// per DrawMessages call; only cursorY moves per row.
var theme = Plugin.ThemeRegistry.Active;
var drawList = ImGui.GetWindowDrawList();
var winLeft = ImGui.GetWindowPos().X;
var winRight = winLeft + ImGui.GetWindowSize().X;
var borderColorAbgr = ColourUtil.RgbaToAbgr(
(theme.Colors.Border & 0xFFFFFF00u) | 0x33u
);
for (var i = startLine; i < messages.Count; i++)
{
var message = messages[i];
if (reset)
{
message.Height[tab.Identifier] = null;
message.IsVisible[tab.Identifier] = false;
}
if (Plugin.Config.CollapseDuplicateMessages)
{
var messageHash = message.Hash;
var same = lastMessageHash == messageHash;
if (same)
{
sameCount += 1;
message.IsVisible[tab.Identifier] = false;
if (i != messages.Count - 1)
continue;
}
if (sameCount > 0)
{
ImGui.SameLine();
DrawChunks(
[
new TextChunk(ChunkSource.None, null, $" ({sameCount + 1}x)")
{
FallbackColour = ChatType.System,
Italic = true,
},
],
true,
handler,
ImGui.GetContentRegionAvail().X
);
sameCount = 0;
}
lastMessageHash = messageHash;
if (same && i == messages.Count - 1)
continue;
}
// go to next row
if (isTable)
ImGui.TableNextColumn();
// Set the height of the previous message. `lastPosY` is set to
// the top of the previous message, and the current cursor is at
// the top of the current message.
if (i > 0)
{
var prevMessage = messages[i - 1];
prevMessage.Height.TryGetValue(tab.Identifier, out var prevHeight);
if (
prevHeight == null
|| (
prevMessage.IsVisible.TryGetValue(tab.Identifier, out var prevVisible)
&& prevVisible
)
)
{
var newHeight = ImGui.GetCursorPosY() - lastPosY;
// Remove the padding from the bottom of the previous row and the top of the current row.
if (isTable && !moreCompact)
newHeight -= oldCellPaddingY * 2;
if (newHeight != 0)
prevMessage.Height[tab.Identifier] = newHeight;
}
}
lastPosY = ImGui.GetCursorPosY();
// message has rendered once
// message isn't visible, so render dummy
message.Height.TryGetValue(tab.Identifier, out var height);
message.IsVisible.TryGetValue(tab.Identifier, out var visible);
if (height != null && !visible)
{
var beforeDummy = ImGui.GetCursorPos();
// skip to the message column for vis test
if (isTable)
ImGui.TableNextColumn();
ImGui.Dummy(new Vector2(10f, height.Value));
var nowVisible = ImGui.IsItemVisible();
if (!nowVisible)
continue;
if (isTable)
ImGui.TableSetColumnIndex(0);
ImGui.SetCursorPos(beforeDummy);
message.IsVisible[tab.Identifier] = nowVisible;
}
if (tab.DisplayTimestamp)
{
var localTime = message.Date.ToLocalTime();
// Force the format explicitly per setting. Relying on the
// current culture meant a German system locale always
// produced 24h regardless of the toggle, so the checkbox
// looked dead.
var timestamp = Plugin.Config.Use24HourClock
? localTime.ToString("HH:mm", CultureInfo.InvariantCulture)
: localTime.ToString("h:mm tt", CultureInfo.InvariantCulture);
if (isTable)
{
if (!Plugin.Config.HideSameTimestamps || timestamp != lastTimestamp)
{
lastTimestamp = timestamp;
ImGui.TextUnformatted(timestamp);
// We use an IsItemHovered() check here instead of
// just calling Tooltip() to avoid computing the
// tooltip string for all visible items on every
// frame.
if (ImGui.IsItemHovered())
ImGuiUtil.Tooltip(localTime.ToString("F"));
}
else
{
// Avoids rendering issues caused by emojis in
// message content.
ImGui.TextUnformatted("");
}
}
else
{
DrawChunk(
new TextChunk(ChunkSource.None, null, $"[{timestamp}] ")
{
Foreground = 0xFFFFFFFF,
}
);
ImGui.SameLine();
}
}
if (isTable)
ImGui.TableNextColumn();
var lineWidth = ImGui.GetContentRegionAvail().X;
// v1.2.0 card mode: sender on its own line in channel color, then body,
// then a subtle border as a card separator.
// Compact mode: sender + space + content on one line via SameLine.
var useCard = !Plugin.Config.UseCompactDensity;
if (useCard)
{
if (message.Sender.Count > 0)
{
var senderColor =
Plugin.Functions.Chat.GetChannelColor(message.Code.Type)
?? theme.Colors.TextPrimary;
using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(senderColor)))
{
DrawChunks(message.Sender, true, handler, lineWidth);
}
// No SameLine — body renders on its own line.
}
// We need to draw something otherwise the item visibility check below won't work.
if (message.Content.Count == 0)
DrawChunks(
[new TextChunk(ChunkSource.Content, null, " ")],
true,
handler,
lineWidth
);
else
DrawChunks(message.Content, true, handler, lineWidth);
// Border bottom as card separator. Alpha reduced to 0x33 for subtlety.
{
var rowEndY = ImGui.GetCursorScreenPos().Y;
drawList.AddLine(
new Vector2(winLeft + 4, rowEndY - 1),
new Vector2(winRight - 4, rowEndY - 1),
borderColorAbgr,
1f
);
ImGui.Dummy(new Vector2(0, 2));
}
}
else
{
if (message.Sender.Count > 0)
{
DrawChunks(message.Sender, true, handler, lineWidth);
ImGui.SameLine();
}
// We need to draw something otherwise the item visibility check below won't work.
if (message.Content.Count == 0)
DrawChunks(
[new TextChunk(ChunkSource.Content, null, " ")],
true,
handler,
lineWidth
);
else
DrawChunks(message.Content, true, handler, lineWidth);
}
message.IsVisible[tab.Identifier] = ImGui.IsItemVisible();
}
}
catch (ApplicationException)
{
// We couldn't get a reader lock on messages within 3ms, so
// don't draw anything (and don't log a warning either).
}
catch (Exception ex)
{
Plugin.LogProxy.Warning(ex, "Error drawing chat log");
}
}
private void DrawTabBar()
{
using var tabBar = ImRaii.TabBar("##chat2-tabs");
if (!tabBar.Success)
return;
var previousTab = Plugin.CurrentTab;
for (var tabI = 0; tabI < Plugin.Config.Tabs.Count; tabI++)
{
var tab = Plugin.Config.Tabs[tabI];
if (tab.PopOut)
continue;
var unread =
tabI == Plugin.LastTab || tab.UnreadMode == UnreadMode.None || tab.Unread == 0
? ""
: $" ({tab.Unread})";
var flags = ImGuiTabItemFlags.None;
if (Plugin.WantedTab == tabI)
flags |= ImGuiTabItemFlags.SetSelected;
using var tabItem = ImRaii.TabItem($"{tab.Name}{unread}###log-tab-{tabI}", flags);
DrawTabContextMenu(tab, tabI);
if (!tabItem.Success)
continue;
// Active-tab underline pill (2px accent). No native ImGui underline API,
// so we use a direct DrawList pass. Pill height scales with GlobalScale
// and all coordinates round to physical pixels so the line stays crisp
// on 125/150% DPI setups instead of bleeding into a sub-pixel blur.
{
var theme = Plugin.ThemeRegistry.Active;
var min = ImGui.GetItemRectMin();
var max = ImGui.GetItemRectMax();
var pillHeight = MathF.Max(1f, MathF.Round(2f * ImGuiHelpers.GlobalScale));
var yBottom = MathF.Round(max.Y);
var yTop = yBottom - pillHeight;
ImGui
.GetWindowDrawList()
.AddRectFilled(
new Vector2(MathF.Round(min.X), yTop),
new Vector2(MathF.Round(max.X), yBottom),
ColourUtil.RgbaToAbgr(theme.Colors.Accent)
);
}
var hasTabSwitched = Plugin.LastTab != tabI;
Plugin.LastTab = tabI;
if (hasTabSwitched)
TabSwitched(tab, previousTab);
tab.Unread = 0;
DrawChatHeaderToolbar(tab);
DrawMessageLog(tab, PayloadHandler, GetRemainingHeightForMessageLog(), hasTabSwitched);
}
Plugin.WantedTab = null;
}
private void DrawTabSidebar()
{
var currentTab = -1;
// Sidebar fixed at 44px, no resize.
using var tabTable = ImRaii.Table(
"tabs-table",
2,
ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingFixedFit
);
if (!tabTable.Success)
return;
ImGui.TableSetupColumn("tabs", ImGuiTableColumnFlags.WidthFixed, 44f);
ImGui.TableSetupColumn("chat", ImGuiTableColumnFlags.WidthStretch, 1);
ImGui.TableNextColumn();
var hasTabSwitched = false;
var childHeight = GetRemainingHeightForMessageLog();
// Sidebar child without ChildBg tint to avoid a colored block above the
// header toolbar area. Vertical separation is handled by BordersInnerV.
using (ImRaii.PushColor(ImGuiCol.ChildBg, 0u))
using (var child = ImRaii.Child("##chat2-tab-sidebar", new Vector2(-1, childHeight)))
{
if (child)
{
// Top padding mirrors the HeaderToolbar height so sidebar buttons
// align with the message log start.
ImGui.Dummy(new Vector2(0, ImGui.GetFrameHeightWithSpacing()));
var previousTab = Plugin.CurrentTab;
// Divider rendered once before the first temp tab with a live unit counter.
var tempTabHeaderRendered = false;
var tempTabCount = Plugin.Config.Tabs.Count(t => t.IsTempTab);
for (var tabI = 0; tabI < Plugin.Config.Tabs.Count; tabI++)
{
var tab = Plugin.Config.Tabs[tabI];
if (tab.PopOut)
continue;
if (tab.IsTempTab && !tempTabHeaderRendered)
{
ImGui.Separator();
if (!Plugin.Config.AutoTellTabsCompactDisplay)
{
ImGui.TextDisabled(
$"{HellionStrings.AutoTellTabs_SectionHeader} ({tempTabCount})"
);
}
tempTabHeaderRendered = true;
}
var unread =
tabI == Plugin.LastTab
|| tab.UnreadMode == UnreadMode.None
|| tab.Unread == 0
? ""
: $" ({tab.Unread})";
var isCurrentTab = Plugin.LastTab == tabI || Plugin.WantedTab == tabI;
var showGreetedAffordance =
tab.IsTempTab && Plugin.Config.AutoTellTabsShowGreetedToggle;
if (showGreetedAffordance)
{
// Greeted toggle left of the selectable to keep click areas separate.
// Compact padding keeps the icon next to the tab name.
var greetedIcon = tab.IsGreeted
? FontAwesomeIcon.CheckCircle
: FontAwesomeIcon.Check;
var greetedTooltip = tab.IsGreeted
? HellionStrings.AutoTellTabs_GreetedTooltip
: HellionStrings.AutoTellTabs_UnGreetedTooltip;
using (ImRaii.PushStyle(ImGuiStyleVar.FramePadding, new Vector2(2, 1)))
using (ImRaii.PushColor(ImGuiCol.Button, 0))
{
if (
ImGuiUtil.IconButton(greetedIcon, $"greeted-{tabI}", greetedTooltip)
)
{
if (tab.IsGreeted)
{
Plugin.AutoTellTabsService.UnmarkGreeted(tab);
}
else
{
Plugin.AutoTellTabsService.MarkGreeted(tab);
}
}
}
ImGui.SameLine();
}
// Icon-only sidebar with tooltip on hover. Active tab gets accent color;
// greeted tabs are dimmed; tell tabs get a hash-based tint.
var theme = Plugin.ThemeRegistry.Active;
var icon = TabIconMapping.Resolve(tab);
uint iconColor;
if (isCurrentTab)
{
iconColor = theme.Colors.Accent;
}
else if (showGreetedAffordance && tab.IsGreeted)
{
iconColor = theme.Colors.TextDim;
}
else if (tab.IsTempTab && tab.TellTarget != null && tab.TellTarget.IsSet())
{
// Hash-based color tint differentiates parallel Auto-Tell tabs
// without requiring manual icon assignment per tab.
iconColor = TabTintCache.GetTint(tab);
}
else
{
iconColor = theme.Colors.TextPrimary;
}
bool clicked;
using (ImRaii.PushColor(ImGuiCol.Button, 0u))
using (
ImRaii.PushColor(
ImGuiCol.ButtonHovered,
ColourUtil.RgbaToAbgr(theme.Colors.SurfaceHover)
)
)
using (
ImRaii.PushColor(
ImGuiCol.ButtonActive,
ColourUtil.RgbaToAbgr(theme.Colors.Surface)
)
)
using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(iconColor)))
using (Plugin.FontManager.FontAwesome.Push())
{
clicked = ImGui.Button(
$"{icon.ToIconString()}##sidebar-tab-{tabI}",
new Vector2(36f, ImGui.GetFrameHeight())
);
}
if (isCurrentTab)
{
// Vertical accent pill on the left window edge, 3px wide, half tab height,
// vertically centered. Direct DrawList pass, no native ImGui API for this.
var min = ImGui.GetItemRectMin();
var max = ImGui.GetItemRectMax();
const float pillWidth = 3f;
var pillHeight = (max.Y - min.Y) * 0.5f;
var pillCenterY = (min.Y + max.Y) * 0.5f;
ImGui
.GetWindowDrawList()
.AddRectFilled(
new Vector2(min.X, pillCenterY - pillHeight * 0.5f),
new Vector2(min.X + pillWidth, pillCenterY + pillHeight * 0.5f),
ColourUtil.RgbaToAbgr(theme.Colors.Accent),
1.5f
); // leichter Rounding
}
// Unread dot top-right of the icon. Active tabs have Unread=0 by convention
// so the dot never conflicts with the active pill.
if (!isCurrentTab && tab.UnreadMode != UnreadMode.None && tab.Unread > 0)
{
var min = ImGui.GetItemRectMin();
var max = ImGui.GetItemRectMax();
const float dotRadius = 4f;
const float dotPadding = 3f;
var dotCenter = new Vector2(
max.X - dotRadius - dotPadding,
min.Y + dotRadius + dotPadding
);
// Sin-based 2s pulse: alpha oscillates 60-100%. Skipped when ReduceMotion is on.
var dotColor = theme.Colors.StatusDanger;
if (!Plugin.Config.ReduceMotion)
{
// Sin-basierter 2s-Cycle: -1..1 → 0..1 → 0.6..1.0 Alpha-Skala.
var phase = (float)(
(Math.Sin(Environment.TickCount64 / 1000.0 * Math.PI) + 1.0) * 0.5
);
var alphaScale = 0.6f + 0.4f * phase;
var origAlpha = dotColor & 0xFFu;
var pulsedAlpha = (uint)(origAlpha * alphaScale);
dotColor = (dotColor & 0xFFFFFF00u) | pulsedAlpha;
}
ImGui
.GetWindowDrawList()
.AddCircleFilled(
dotCenter,
dotRadius,
ColourUtil.RgbaToAbgr(dotColor),
12
);
}
// Pin indicator: small thumbtack glyph top-left of the icon.
// Sits opposite the unread dot so they never collide.
if (tab.IsPinned)
{
var min = ImGui.GetItemRectMin();
const float pinPadding = 2f;
var pinPos = new Vector2(min.X + pinPadding, min.Y + pinPadding);
using (Plugin.FontManager.FontAwesome.Push())
{
ImGui
.GetWindowDrawList()
.AddText(
pinPos,
ColourUtil.RgbaToAbgr(theme.Colors.Accent),
FontAwesomeIcon.Thumbtack.ToIconString()
);
}
}
// Tooltip mit Tab-Name + Unread-Counter beim Hover.
if (ImGui.IsItemHovered())
{
using var tt = ImRaii.Tooltip();
ImGui.TextUnformatted($"{tab.Name}{unread}");
if (tab.IsPinned)
{
ImGui.TextUnformatted(HellionStrings.PinTab_PinnedTooltip);
}
}
DrawTabContextMenu(tab, tabI);
if (clicked)
Plugin.WantedTab = tabI;
if (!clicked && Plugin.WantedTab != tabI)
continue;
currentTab = tabI;
hasTabSwitched = Plugin.LastTab != tabI;
Plugin.LastTab = tabI;
if (hasTabSwitched)
TabSwitched(tab, previousTab);
}
}
}
ImGui.TableNextColumn();
if (currentTab == -1 && Plugin.LastTab < Plugin.Config.Tabs.Count)
{
currentTab = Plugin.LastTab;
Plugin.Config.Tabs[currentTab].Unread = 0;
}
if (currentTab > -1)
{
DrawChatHeaderToolbar(Plugin.Config.Tabs[currentTab]);
DrawMessageLog(
Plugin.Config.Tabs[currentTab],
PayloadHandler,
childHeight,
hasTabSwitched
);
}
Plugin.WantedTab = null;
}
// DrawChatHeaderToolbar: renders the pop-out button for the active tab.
// v1.3.0 also renders the optional Honorific title slot left of it.
private void DrawChatHeaderToolbar(Tab tab)
{
DrawHonorificTitleSlot();
DrawPopOutButton(tab);
}
private void DrawPopOutButton(Tab tab)
{
var avail = ImGui.GetContentRegionAvail().X;
var iconWidth = ImGui.GetFrameHeight();
ImGui.SetCursorPosX(ImGui.GetCursorPosX() + avail - iconWidth);
if (
ImGuiUtil.IconButton(
FontAwesomeIcon.WindowRestore,
tooltip: Language.ChatLog_Tabs_PopOut
)
)
{
tab.PopOut = true;
Plugin.SaveConfig();
}
}
// Title rendered first so DrawPopOutButton can anchor flush right via
// GetContentRegionAvail. Call order in DrawChatHeaderToolbar matters.
// SameLine keeps both on the same toolbar row.
private void DrawHonorificTitleSlot()
{
var service = Plugin.HonorificService;
var title = service.CurrentTitle;
if (
!HonorificService.ShouldRenderSlot(
Plugin.Config.ShowHonorificTitleInHeader,
service.IsAvailable,
title
)
)
{
return;
}
// Reserve space for the crown icon plus a small gap before the title,
// then the title itself, then the gap-to-pop-out-button. We measure the
// crown width inside the FontAwesome font push because FontAwesome
// glyphs render in a different font than the regular ImGui text.
const float gapAfterCrown = 4f;
const float gapBeforeButton = 8f;
var avail = ImGui.GetContentRegionAvail().X;
var iconWidth = ImGui.GetFrameHeight();
float crownWidth;
using (Plugin.FontManager.FontAwesome.Push())
{
crownWidth = ImGui.CalcTextSize(FontAwesomeIcon.Crown.ToIconString()).X;
}
var maxTitleWidth = avail - iconWidth - gapBeforeButton - crownWidth - gapAfterCrown;
if (maxTitleWidth <= 0)
{
return;
}
var rendered = "«" + title!.Title + "»";
rendered = StringUtil.TruncateToFitWidth(rendered, maxTitleWidth);
var titleColor = title.Color is { } c
? new Vector4(c.X, c.Y, c.Z, 1f)
: ImGui.GetStyle().Colors[(int)ImGuiCol.Text];
var theme = Plugin.ThemeRegistry.Active;
// Group so IsItemHovered covers both the crown icon and the title text.
ImGui.BeginGroup();
using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(theme.Colors.TextMuted)))
using (Plugin.FontManager.FontAwesome.Push())
{
ImGui.TextUnformatted(FontAwesomeIcon.Crown.ToIconString());
}
ImGui.SameLine(0f, gapAfterCrown);
DrawHonorificTitleText(rendered, titleColor, title.Glow);
ImGui.EndGroup();
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip(HellionStrings.ChatHeader_HonorificTitle_Tooltip);
}
ImGui.SameLine();
}
// Renders the title text, optionally with a glow outline pre-pass. Glow is
// drawn at 8 cardinal offsets (±1 px) in the glow colour at reduced alpha,
// then the primary text on top. The pre-pass uses the window draw list so
// it composites correctly with the regular ImGui text that follows.
private void DrawHonorificTitleText(string rendered, Vector4 titleColor, Vector3? glow)
{
if (Plugin.Config.ShowHonorificGlow && glow is { } g)
{
var pos = ImGui.GetCursorScreenPos();
var glowColor = new Vector4(g.X, g.Y, g.Z, 0.4f);
var glowAbgr = ImGui.ColorConvertFloat4ToU32(glowColor);
var drawList = ImGui.GetWindowDrawList();
for (var dy = -1; dy <= 1; dy++)
{
for (var dx = -1; dx <= 1; dx++)
{
if (dx == 0 && dy == 0)
continue;
drawList.AddText(new Vector2(pos.X + dx, pos.Y + dy), glowAbgr, rendered);
}
}
}
using (ImRaii.PushColor(ImGuiCol.Text, titleColor))
{
ImGui.TextUnformatted(rendered);
}
}
// One-time hint banner for the pop-out header button and right-click pathway.
private float DrawV061HintBannerIfNeeded()
{
if (Plugin.Config.SeenPopOutHeaderHint)
return 0f;
var hintText = Resources.HellionStrings.Hint_v061_PopOutHeader_Body;
var ackLabel = Resources.HellionStrings.Hint_v061_PopOutHeader_Ack;
var openLabel = Resources.HellionStrings.Hint_v061_PopOutHeader_OpenSettings;
var startY = ImGui.GetCursorPosY();
var bg = new System.Numerics.Vector4(0.16f, 0.20f, 0.28f, 1f);
var dismiss = false;
var openSettings = false;
// RAII style stack so an early return can never leave ImGui unbalanced.
using (ImRaii.PushColor(ImGuiCol.ChildBg, bg))
using (ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, 1f))
using (
var child = ImRaii.Child(
"##v061-pop-out-header-hint",
new System.Numerics.Vector2(0f, 84f),
true
)
)
{
if (child)
{
ImGui.TextWrapped(hintText);
if (ImGui.Button(ackLabel))
dismiss = true;
ImGui.SameLine();
if (ImGui.Button(openLabel))
{
dismiss = true;
openSettings = true;
}
}
}
ImGui.Spacing();
if (dismiss)
{
Plugin.Config.SeenPopOutHeaderHint = true;
Plugin.SaveConfig();
Plugin.LogProxy.Debug("v0.6.1 pop-out header hint dismissed");
if (openSettings)
Plugin.SettingsWindow.Toggle();
}
return ImGui.GetCursorPosY() - startY;
}
private void DrawTabContextMenu(Tab tab, int i)
{
using var contextMenu = ImRaii.ContextPopupItem($"tab-context-menu-{i}");
if (!contextMenu.Success)
return;
var anyChanged = false;
var tabs = Plugin.Config.Tabs;
ImGui.SetNextItemWidth(250f * ImGuiHelpers.GlobalScale);
if (ImGui.InputText("##tab-name", ref tab.Name, 128))
anyChanged = true;
if (ImGuiUtil.IconButton(FontAwesomeIcon.TrashAlt, tooltip: Language.ChatLog_Tabs_Delete))
{
tabs.RemoveAt(i);
Plugin.WantedTab = 0;
anyChanged = true;
}
ImGui.SameLine();
var (leftIcon, leftTooltip) = Plugin.Config.SidebarTabView
? (FontAwesomeIcon.ArrowUp, Language.ChatLog_Tabs_MoveUp)
: (FontAwesomeIcon.ArrowLeft, Language.ChatLog_Tabs_MoveLeft);
if (ImGuiUtil.IconButton(leftIcon, tooltip: leftTooltip) && i > 0)
{
(tabs[i - 1], tabs[i]) = (tabs[i], tabs[i - 1]);
ImGui.CloseCurrentPopup();
anyChanged = true;
}
ImGui.SameLine();
var (rightIcon, rightTooltip) = Plugin.Config.SidebarTabView
? (FontAwesomeIcon.ArrowDown, Language.ChatLog_Tabs_MoveDown)
: (FontAwesomeIcon.ArrowRight, Language.ChatLog_Tabs_MoveRight);
if (ImGuiUtil.IconButton(rightIcon, tooltip: rightTooltip) && i < tabs.Count - 1)
{
(tabs[i + 1], tabs[i]) = (tabs[i], tabs[i + 1]);
ImGui.CloseCurrentPopup();
anyChanged = true;
}
ImGui.SameLine();
if (
ImGuiUtil.IconButton(
FontAwesomeIcon.WindowRestore,
tooltip: Language.ChatLog_Tabs_PopOut
)
)
{
tab.PopOut = true;
anyChanged = true;
}
if (tab.IsTempTab)
{
ImGui.Separator();
DrawPinControls(tab);
}
if (anyChanged)
Plugin.SaveConfig();
}
private void DrawPinControls(Tab tab)
{
var svc = Plugin.AutoTellTabsService;
if (svc == null)
return;
if (tab.IsPinned)
{
if (ImGui.MenuItem(HellionStrings.PinTab_MenuUnpin))
{
svc.Unpin(tab);
ImGui.CloseCurrentPopup();
}
}
else
{
var atCap = svc.PinnedTempTabCount >= AutoTellTabsService.MaxPinnedTempTabs;
if (ImGui.MenuItem(HellionStrings.PinTab_MenuPin, enabled: !atCap))
{
if (svc.TryPin(tab))
ImGui.CloseCurrentPopup();
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
{
ImGui.SetTooltip(
atCap
? string.Format(
HellionStrings.PinTab_LimitReached,
AutoTellTabsService.MaxPinnedTempTabs
)
: HellionStrings.PinTab_PinTooltip
);
}
}
}
internal readonly List<bool> PopOutDocked = [];
internal readonly HashSet<Guid> PopOutWindows = [];
// Live enumeration of active Popout windows for KeybindManager tab-cycle forwarding.
// Filters on IsOpen to skip closed-but-registered popouts.
internal IEnumerable<Popout> ActivePopouts =>
Plugin.WindowSystem.Windows.OfType<Popout>().Where(p => p.IsOpen);
private void AddPopOutsToDraw()
{
HandlerLender.ResetCounter();
if (PopOutDocked.Count != Plugin.Config.Tabs.Count)
{
PopOutDocked.Clear();
PopOutDocked.AddRange(Enumerable.Repeat(false, Plugin.Config.Tabs.Count));
}
for (var i = 0; i < Plugin.Config.Tabs.Count; i++)
{
var tab = Plugin.Config.Tabs[i];
if (!tab.PopOut)
continue;
if (PopOutWindows.Contains(tab.Identifier))
continue;
var window = new Popout(this, tab, i);
Plugin.WindowSystem.AddWindow(window);
PopOutWindows.Add(tab.Identifier);
}
}
private unsafe void DrawAutoComplete()
{
if (AutoCompleteInfo == null)
return;
AutoCompleteList ??= AutoTranslate.Matching(
AutoCompleteInfo.ToComplete,
Plugin.Config.SortAutoTranslate
);
if (AutoCompleteOpen)
{
ImGui.OpenPopup(AutoCompleteId);
AutoCompleteOpen = false;
}
ImGui.SetNextWindowSize(new Vector2(400, 300) * ImGuiHelpers.GlobalScale);
using var popup = ImRaii.Popup(AutoCompleteId);
if (!popup.Success)
{
if (ActivatePos == -1)
ActivatePos = AutoCompleteInfo.EndPos;
AutoCompleteInfo = null;
AutoCompleteList = null;
Activate = true;
return;
}
ImGui.SetNextItemWidth(-1);
if (
ImGui.InputTextWithHint(
"##auto-complete-filter",
Language.AutoTranslate_Search_Hint,
ref AutoCompleteInfo.ToComplete,
256,
ImGuiInputTextFlags.CallbackAlways | ImGuiInputTextFlags.CallbackHistory,
AutoCompleteCallback
)
)
{
AutoCompleteList = AutoTranslate.Matching(
AutoCompleteInfo.ToComplete,
Plugin.Config.SortAutoTranslate
);
AutoCompleteSelection = 0;
AutoCompleteShouldScroll = true;
}
var selected = -1;
if (ImGui.IsItemActive() && ImGui.GetIO().KeyCtrl)
{
for (var i = 0; i < 10 && i < AutoCompleteList.Count; i++)
{
var num = (i + 1) % 10;
var key = ImGuiKey.Key0 + num;
var key2 = ImGuiKey.Keypad0 + num;
if (ImGui.IsKeyDown(key) || ImGui.IsKeyDown(key2))
selected = i;
}
}
if (ImGui.IsItemDeactivated())
{
if (ImGui.IsKeyDown(ImGuiKey.Escape))
{
ImGui.CloseCurrentPopup();
return;
}
var enter = ImGui.IsKeyDown(ImGuiKey.Enter) || ImGui.IsKeyDown(ImGuiKey.KeypadEnter);
if (AutoCompleteList.Count > 0 && enter)
selected = AutoCompleteSelection;
}
if (ImGui.IsWindowAppearing())
{
FixCursor = true;
ImGui.SetKeyboardFocusHere(-1);
}
using var child = ImRaii.Child(
"##auto-complete-list",
Vector2.Zero,
false,
ImGuiWindowFlags.HorizontalScrollbar
);
if (!child.Success)
return;
var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper());
try
{
clipper.Begin(AutoCompleteList.Count);
while (clipper.Step())
{
for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++)
{
var entry = AutoCompleteList[i];
var highlight = AutoCompleteSelection == i;
var clicked =
ImGui.Selectable($"{entry.Text}##{entry.Group}/{entry.Row}", highlight)
|| selected == i;
if (i < 10)
{
var button = (i + 1) % 10;
var text = string.Format(Language.AutoTranslate_Completion_Key, button);
var size = ImGui.CalcTextSize(text);
ImGui.SameLine(ImGui.GetContentRegionAvail().X - size.X);
using (
ImRaii.PushColor(
ImGuiCol.Text,
ImGui.GetStyle().Colors[(int)ImGuiCol.TextDisabled]
)
)
ImGui.TextUnformatted(text);
}
if (!clicked)
continue;
var before = Chat[..AutoCompleteInfo.StartPos];
var after = Chat[AutoCompleteInfo.EndPos..];
var replacement = $"<at:{entry.Group},{entry.Row}>";
Chat = $"{before}{replacement}{after}";
ImGui.CloseCurrentPopup();
Activate = true;
ActivatePos = AutoCompleteInfo.StartPos + replacement.Length;
}
}
if (!AutoCompleteShouldScroll)
return;
AutoCompleteShouldScroll = false;
var selectedPos =
clipper.StartPosY + clipper.ItemsHeight * (AutoCompleteSelection * 1f);
ImGui.SetScrollFromPosY(selectedPos - ImGui.GetWindowPos().Y);
}
finally
{
// Destroy frees the unmanaged ImGuiListClipper allocated above; without it the block leaks per render.
clipper.Destroy();
}
}
private int AutoCompleteCallback(scoped ref ImGuiInputTextCallbackData data)
{
if (FixCursor && AutoCompleteInfo != null)
{
FixCursor = false;
data.CursorPos = AutoCompleteInfo.ToComplete.Length;
data.SelectionStart = data.SelectionEnd = data.CursorPos;
}
if (AutoCompleteList == null)
return 0;
switch (data.EventKey)
{
case ImGuiKey.UpArrow:
if (AutoCompleteSelection == 0)
AutoCompleteSelection = AutoCompleteList.Count - 1;
else
AutoCompleteSelection--;
AutoCompleteShouldScroll = true;
return 1;
case ImGuiKey.DownArrow:
if (AutoCompleteSelection == AutoCompleteList.Count - 1)
AutoCompleteSelection = 0;
else
AutoCompleteSelection++;
AutoCompleteShouldScroll = true;
return 1;
default:
if (ImGui.IsKeyPressed(ImGuiKey.Tab))
{
if (AutoCompleteSelection == AutoCompleteList.Count - 1)
AutoCompleteSelection = 0;
else
AutoCompleteSelection++;
AutoCompleteShouldScroll = true;
return 1;
}
break;
}
return 0;
}
private unsafe int Callback(scoped ref ImGuiInputTextCallbackData data)
{
// We play the opening sound here only if closing sound has been played before
if (Plugin.Config.PlaySounds && PlayedClosingSound)
{
PlayedClosingSound = false;
UIGlobals.PlaySoundEffect(ChatOpenSfx);
}
// Set the cursor pos to the user selected
if (Plugin.InputPreview.SelectedCursorPos != -1)
data.CursorPos = Plugin.InputPreview.SelectedCursorPos;
Plugin.InputPreview.SelectedCursorPos = -1;
CursorPos = data.CursorPos;
if (data.EventFlag == ImGuiInputTextFlags.CallbackCompletion)
{
if (data.CursorPos == 0)
{
AutoCompleteInfo = new AutoCompleteInfo(
string.Empty,
data.CursorPos,
data.CursorPos
);
AutoCompleteOpen = true;
AutoCompleteSelection = 0;
return 0;
}
int white;
for (white = data.CursorPos - 1; white >= 0; white--)
if (data.Buf[white] == ' ')
break;
var start = data.Buf + white + 1;
var end = data.CursorPos - white - 1;
var utf8Message = Marshal.PtrToStringUTF8((nint)start, end);
var correctedCursor = data.CursorPos - (end - utf8Message.Length);
AutoCompleteInfo = new AutoCompleteInfo(utf8Message, white + 1, correctedCursor);
AutoCompleteOpen = true;
AutoCompleteSelection = 0;
return 0;
}
if (data.EventFlag == ImGuiInputTextFlags.CallbackCharFilter)
if (!Plugin.Functions.Chat.IsCharValid((char)data.EventChar))
return 1;
if (Activate)
{
Activate = false;
data.CursorPos = ActivatePos > -1 ? ActivatePos : Chat.Length;
data.SelectionStart = data.SelectionEnd = data.CursorPos;
ActivatePos = -1;
}
Plugin.CommandHelpWindow.IsOpen = false;
var text = MemoryHelper.ReadString((nint)data.Buf, data.BufTextLen);
if (text.StartsWith('/'))
{
var command = text.Split(' ')[0];
if (AllCommands.TryGetValue(command, out var textCommand))
Plugin.CommandHelpWindow.UpdateContent(textCommand.Description);
else if (
Plugin.CommandManager.Commands.TryGetValue(command, out var info) && info.ShowInHelp
)
Plugin.CommandHelpWindow.UpdateContent(info.HelpMessage);
}
if (data.EventFlag != ImGuiInputTextFlags.CallbackHistory)
return 0;
var prevPos = InputBacklogIdx;
switch (data.EventKey)
{
case ImGuiKey.UpArrow:
switch (InputBacklogIdx)
{
case -1:
var offset = 0;
if (!string.IsNullOrWhiteSpace(Chat))
{
AddBacklog(Chat);
offset = 1;
}
InputBacklogIdx = InputHistoryService.Count - 1 - offset;
break;
case > 0:
InputBacklogIdx--;
break;
}
break;
case ImGuiKey.DownArrow:
if (InputBacklogIdx != -1)
if (++InputBacklogIdx >= InputHistoryService.Count)
InputBacklogIdx = -1;
break;
}
if (prevPos == InputBacklogIdx)
return 0;
var historyStr = InputHistoryService.GetByCursor(InputBacklogIdx) ?? string.Empty;
data.DeleteChars(0, data.BufTextLen);
data.InsertChars(0, historyStr);
return 0;
}
internal void DrawChunks(
IReadOnlyList<Chunk> chunks,
bool wrap = true,
PayloadHandler? handler = null,
float lineWidth = 0f
)
{
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;
DrawChunk(chunks[i], wrap, handler, lineWidth);
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 DrawChunk(
Chunk chunk,
bool wrap = true,
PayloadHandler? handler = null,
float lineWidth = 0f
)
{
if (chunk is IconChunk icon)
{
DrawIcon(chunk, icon, handler);
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 the 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);
return;
}
}
var colour = text.Foreground;
if (colour == null && text.FallbackColour != null)
{
var type = text.FallbackColour.Value;
colour = Plugin.Config.ChatColours.TryGetValue(type, out var col)
? col
: type.DefaultColor();
}
var push = colour != null;
var uColor = push ? ColourUtil.RgbaToAbgr(colour!.Value) : 0;
using var pushedColor = ImRaii.PushColor(ImGuiCol.Text, uColor, push);
var useCustomItalicFont =
Plugin.Config.FontsEnabled && Plugin.FontManager.ItalicFont != null;
if (text.Italic)
(
useCustomItalicFont ? Plugin.FontManager.ItalicFont! : Plugin.FontManager.AxisItalic
).Push();
// Check for contains here as sometimes there are multiple
// TextChunks with the same PlayerPayload but only one has the name.
// E.g. party chat with cross world players adds extra chunks.
//
// Note: This has been null before, I'm guessing due to some issues with
// other plugins. New TextChunks will now enforce empty string in ctor,
// but old ones may still be null.
// ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
var content = text.Content ?? "";
if (ScreenshotMode)
{
if (chunk.Link is PlayerPayload playerPayload)
content = HidePlayerInString(
content,
playerPayload.PlayerName,
playerPayload.World.RowId
);
else if (Plugin.PlayerState.IsLoaded)
content = HidePlayerInString(
content,
Plugin.PlayerState.CharacterName,
Plugin.PlayerState.HomeWorld.RowId
);
}
if (wrap)
{
ImGuiUtil.WrapText(content, chunk, handler, DefaultText, lineWidth);
}
else
{
ImGui.TextUnformatted(content);
ImGuiUtil.PostPayload(chunk, handler);
}
if (text.Italic)
(
useCustomItalicFont ? Plugin.FontManager.ItalicFont! : Plugin.FontManager.AxisItalic
).Pop();
}
internal void DrawIcon(Chunk chunk, IconChunk icon, PayloadHandler? handler)
{
if (!IconUtil.GfdFileView.TryGetEntry((uint)icon.Icon, out var entry))
return;
var iconTexture = Plugin
.TextureProvider.GetFromGame("common/font/fonticon_ps5.tex")
.GetWrapOrDefault();
if (iconTexture == null)
return;
var texSize = new Vector2(iconTexture.Width, iconTexture.Height);
var sizeRatio = FontManager.GetFontSize() / entry.Height;
var size = new Vector2(entry.Width, entry.Height) * sizeRatio * ImGuiHelpers.GlobalScale;
var uv0 = new Vector2(entry.Left, entry.Top + 170) * 2 / texSize;
var uv1 =
new Vector2(entry.Left + entry.Width, entry.Top + entry.Height + 170) * 2 / texSize;
ImGui.Image(iconTexture.Handle, size, uv0, uv1);
ImGuiUtil.PostPayload(chunk, handler);
}
internal string HidePlayerInString(string str, string playerName, uint worldId)
{
var expected = Plugin.Functions.Chat.AbbreviatePlayerName(playerName);
var hash = HashPlayer(playerName, worldId);
return str.Replace(playerName, expected).Replace(expected, hash);
}
private string HashPlayer(string playerName, uint worldId)
{
var hashCode = $"{Salt}{playerName}{worldId}".GetHashCode();
return $"Player {hashCode:X8}";
}
// Snap threshold: minimum window overlap with a visible viewport before
// we consider it off-screen.
private const int OnScreenMinOverlapX = 100;
private const int OnScreenMinOverlapY = 40;
// Default snap position relative to the primary viewport (top-left with a
// safety margin from the game title bar).
private static readonly Vector2 SafeDefaultOffset = new(50, 50);
private void EnsureWindowOnScreen(string source)
{
if (LastWindowSize.X < 1 || LastWindowSize.Y < 1)
return;
var viewport = ImGui.GetMainViewport();
var visibleMin = viewport.WorkPos;
var visibleMax = viewport.WorkPos + viewport.WorkSize;
var overlapMin = Vector2.Max(LastWindowPos, visibleMin);
var overlapMax = Vector2.Min(LastWindowPos + LastWindowSize, visibleMax);
var overlap = overlapMax - overlapMin;
if (overlap.X >= OnScreenMinOverlapX && overlap.Y >= OnScreenMinOverlapY)
return;
ApplySafeDefaultPosition(source);
}
private void ApplySafeDefaultPosition(string source)
{
var viewport = ImGui.GetMainViewport();
var safePos = viewport.WorkPos + SafeDefaultOffset;
Position = safePos;
Plugin.LogProxy.Info(
$"[Window-Recovery] {source}: snapping main window from {LastWindowPos} (size {LastWindowSize}) to {safePos}."
);
// Pop-outs don't persist across sessions so they can never end up off-screen
// after a reload. Only the main window needs explicit recovery.
}
}