using System.Diagnostics; using System.Numerics; using System.Runtime.InteropServices; using System.Text; using ChatTwo.Code; using ChatTwo.GameFunctions.Types; using ChatTwo.Resources; using ChatTwo.Util; using Dalamud.Game.Addon.Lifecycle; using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.ClientState.Keys; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Interface; using Dalamud.Interface.Internal; using Dalamud.Interface.Style; using Dalamud.Interface.Utility; using Dalamud.Interface.Windowing; using Dalamud.Memory; using ImGuiNET; using Lumina.Excel.GeneratedSheets; namespace ChatTwo.Ui; public sealed class ChatLogWindow : Window, IUiComponent { private const string ChatChannelPicker = "chat-channel-picker"; private const string AutoCompleteId = "##chat2-autocomplete"; internal Plugin Plugin { get; } internal bool ScreenshotMode; internal string Salt { get; } internal Vector4 DefaultText { get; set; } internal Tab? CurrentTab { get { var i = LastTab; if (i > -1 && i < Plugin.Config.Tabs.Count) { return Plugin.Config.Tabs[i]; } return null; } } internal bool Activate; private int _activatePos = -1; internal string Chat = string.Empty; private readonly IDalamudTextureWrap? _fontIcon; private readonly List _inputBacklog = new(); private int _inputBacklogIdx = -1; internal int LastTab { get; private set; } private InputChannel? _tempChannel; private TellTarget? _tellTarget; private readonly Stopwatch _lastResize = new(); private AutoCompleteInfo? _autoCompleteInfo; private bool _autoCompleteOpen; private List? _autoCompleteList; private bool _fixCursor; private int _autoCompleteSelection; private bool _autoCompleteShouldScroll; public Vector2 LastWindowPos { get; private set; } = Vector2.Zero; public Vector2 LastWindowSize { get; private set; } = Vector2.Zero; public unsafe ImGuiViewport* LastViewport; private bool _wasDocked; internal PayloadHandler PayloadHandler { get; } internal Lender HandlerLender { get; } private Dictionary TextCommandChannels { get; } = new(); private HashSet AllCommands { get; } = new(); internal ChatLogWindow(Plugin plugin) : base($"{Plugin.PluginName}###chat2") { Plugin = plugin; Salt = new Random().Next().ToString(); Size = new Vector2(500, 250); SizeCondition = ImGuiCond.FirstUseEver; RespectCloseHotkey = false; PayloadHandler = new PayloadHandler(this); HandlerLender = new Lender(() => new PayloadHandler(this)); SetUpTextCommandChannels(); SetUpAllCommands(); Plugin.Commands.Register("/clearlog2", "Clear the Chat 2 chat log").Execute += ClearLog; Plugin.Commands.Register("/chat2").Execute += ToggleChat; _fontIcon = Plugin.TextureProvider.GetTextureFromGame("common/font/fonticon_ps5.tex"); Plugin.Functions.Chat.Activated += Activated; Plugin.ClientState.Login += Login; Plugin.ClientState.Logout += Logout; Plugin.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, "ItemDetail", PayloadHandler.MoveTooltip); } public override void PreDraw() { if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null }) StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Push(); } public override void PostDraw() { if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null }) StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Pop(); } public void Dispose() { Plugin.AddonLifecycle.UnregisterListener(AddonEvent.PostRequestedUpdate, "ItemDetail", PayloadHandler.MoveTooltip); Plugin.ClientState.Logout -= Logout; Plugin.ClientState.Login -= Login; Plugin.Functions.Chat.Activated -= Activated; _fontIcon?.Dispose(); Plugin.Commands.Register("/chat2").Execute -= ToggleChat; Plugin.Commands.Register("/clearlog2").Execute -= ClearLog; } private void Logout() { foreach (var tab in Plugin.Config.Tabs) { tab.Clear(); } } private void Login() { Plugin.Store.FilterAllTabs(false); } private void Activated(ChatActivatedArgs args) { Activate = true; 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 prevTemp = _tempChannel; if (info.Permanent) { SetChannel(info.Channel.Value); } else { _tempChannel = info.Channel.Value; } if (info.Channel is InputChannel.Tell) { if (info.Rotate != RotateMode.None) { var idx = prevTemp != InputChannel.Tell ? 0 : info.Rotate == RotateMode.Reverse ? -1 : 1; var tellInfo = Plugin.Functions.Chat.GetTellHistoryInfo(idx); if (tellInfo != null && reason != null) { _tellTarget = new TellTarget(tellInfo.Name, (ushort) tellInfo.World, tellInfo.ContentId, reason.Value); } } else { _tellTarget = null; if (target != null) { _tellTarget = target; } } } else { _tellTarget = null; } var mode = prevTemp == null ? RotateMode.None : info.Rotate; if (info.Channel is InputChannel.Linkshell1 && info.Rotate != RotateMode.None) { var idx = Plugin.Functions.Chat.RotateLinkshellHistory(mode); _tempChannel = info.Channel.Value + (uint) idx; } else if (info.Channel is InputChannel.CrossLinkshell1 && info.Rotate != RotateMode.None) { var idx = Plugin.Functions.Chat.RotateCrossLinkshellHistory(mode); _tempChannel = info.Channel.Value + (uint) idx; } } if (info.Text != null && Chat.Length == 0) { Chat = info.Text; } } private bool IsValidCommand(string command) { return Plugin.CommandManager.Commands.ContainsKey(command) || AllCommands.Contains(command); } private void ClearLog(string command, string arguments) { switch (arguments) { case "all": foreach (var tab in Plugin.Config.Tabs) { tab.Clear(); } 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 (LastTab > -1 && LastTab < Plugin.Config.Tabs.Count) { Plugin.Config.Tabs[LastTab].Clear(); } break; } } private void ToggleChat(string command, string arguments) { var parts = arguments.Split(' '); if (parts.Length < 2 || parts[0] != "chat") { return; } switch (parts[1]) { case "hide": _hideState = HideState.User; break; case "show": _hideState = HideState.None; break; case "toggle": _hideState = _hideState switch { HideState.User or HideState.CutsceneOverride => HideState.None, HideState.Cutscene => HideState.CutsceneOverride, HideState.None => HideState.User, _ => _hideState, }; break; } } private void SetUpTextCommandChannels() { TextCommandChannels.Clear(); foreach (var input in Enum.GetValues()) { var commands = input.TextCommands(Plugin.DataManager); if (commands == null) { continue; } var type = input.ToChatType(); foreach (var command in commands) { AddTextCommandChannel(command, type); } } var echo = Plugin.DataManager.GetExcelSheet()?.GetRow(116); if (echo != null) { AddTextCommandChannel(echo, ChatType.Echo); } } private void AddTextCommandChannel(TextCommand command, ChatType type) { TextCommandChannels[command.Command] = type; TextCommandChannels[command.ShortCommand] = type; TextCommandChannels[command.Alias] = type; TextCommandChannels[command.ShortAlias] = type; } private void SetUpAllCommands() { if (Plugin.DataManager.GetExcelSheet() is not { } commands) { return; } var commandNames = commands.SelectMany(cmd => new[] { cmd.Command.RawString, cmd.ShortCommand.RawString, cmd.Alias.RawString, cmd.ShortAlias.RawString, }); foreach (var command in commandNames) { AllCommands.Add(command); } } private void AddBacklog(string message) { for (var i = 0; i < _inputBacklog.Count; i++) { if (_inputBacklog[i] != message) { continue; } _inputBacklog.RemoveAt(i); break; } _inputBacklog.Add(message); } private static float GetRemainingHeightForMessageLog() { var lineHeight = ImGui.CalcTextSize("A").Y; return ImGui.GetContentRegionAvail().Y - lineHeight * 2 - ImGui.GetStyle().ItemSpacing.Y - ImGui.GetStyle().FramePadding.Y * 2; } private void HandleKeybinds(bool modifiersOnly = false) { var modifierState = (ModifierFlag) 0; if (ImGui.GetIO().KeyAlt) { modifierState |= ModifierFlag.Alt; } if (ImGui.GetIO().KeyCtrl) { modifierState |= ModifierFlag.Ctrl; } if (ImGui.GetIO().KeyShift) { modifierState |= ModifierFlag.Shift; } var turnedOff = new Dictionary(); foreach (var (toIntercept, keybind) in Plugin.Functions.Chat.Keybinds) { if (toIntercept is "CMD_CHAT" or "CMD_COMMAND") { continue; } void Intercept(VirtualKey vk, ModifierFlag modifier) { if (!vk.TryToImGui(out var key)) { return; } var modifierPressed = Plugin.Config.KeybindMode switch { KeybindMode.Strict => modifier == modifierState, KeybindMode.Flexible => modifierState.HasFlag(modifier), _ => false, }; if (!ImGui.IsKeyPressed(key) || !modifierPressed || modifier == 0 && modifiersOnly) { return; } var bits = BitOperations.PopCount((uint) modifier); if (!turnedOff.TryGetValue(vk, out var previousBits) || previousBits.Item1 < bits) { turnedOff[vk] = ((uint) bits, toIntercept); } } Intercept(keybind.Key1, keybind.Modifier1); Intercept(keybind.Key2, keybind.Modifier2); } foreach (var (_, (_, keybind)) in turnedOff) { if (!GameFunctions.Chat.KeybindsToIntercept.TryGetValue(keybind, out var info)) { continue; } try { TellReason? reason = info.Channel == InputChannel.Tell ? TellReason.Reply : null; Activated(new ChatActivatedArgs(info) { TellReason = reason, }); } catch (Exception ex) { Plugin.Log.Error(ex, "Error in chat Activated event"); } } } private bool CutsceneActive { get { return Plugin.Condition[ConditionFlag.OccupiedInCutSceneEvent] || Plugin.Condition[ConditionFlag.WatchingCutscene78]; } } private bool GposeActive { get { return Plugin.Condition[ConditionFlag.WatchingCutscene]; } } private enum HideState { None, Cutscene, CutsceneOverride, User, } private HideState _hideState = HideState.None; public override unsafe void PreOpenCheck() { // if the chat has no hide state and in a cutscene, set the hide state to cutscene if (Plugin.Config.HideDuringCutscenes && _hideState == HideState.None && (CutsceneActive || GposeActive)) _hideState = HideState.Cutscene; // if the chat is hidden because of a cutscene and no longer in a cutscene, set the hide state to none if (_hideState is HideState.Cutscene or HideState.CutsceneOverride && !CutsceneActive && !GposeActive) _hideState = HideState.None; // if the chat is hidden because of a cutscene and the chat has been activated, show chat if (_hideState == HideState.Cutscene && Activate) _hideState = HideState.CutsceneOverride; // if the user hid the chat and is now activating chat, reset the hide state if (_hideState == HideState.User && Activate) _hideState = HideState.None; if (_hideState is HideState.Cutscene or HideState.User) { IsOpen = false; return; } if (Plugin.Config.HideWhenNotLoggedIn && !Plugin.ClientState.IsLoggedIn) { IsOpen = false; return; } IsOpen = true; Flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse; if (!Plugin.Config.CanMove) Flags |= ImGuiWindowFlags.NoMove; if (!Plugin.Config.CanResize) Flags |= ImGuiWindowFlags.NoResize; if (!Plugin.Config.ShowTitleBar) Flags |= ImGuiWindowFlags.NoTitleBar; if (LastViewport == ImGuiHelpers.MainViewport.NativePtr && !_wasDocked) BgAlpha = Plugin.Config.WindowAlpha / 100f; LastViewport = ImGui.GetWindowViewport().NativePtr; _wasDocked = ImGui.IsWindowDocked(); } public override void Draw() { DrawChatLog(); DrawPopOuts(); DrawAutoComplete(); } private unsafe void DrawChatLog() { var resized = LastWindowSize != ImGui.GetWindowSize(); LastWindowSize = ImGui.GetWindowSize(); LastWindowPos = ImGui.GetWindowPos(); if (resized) _lastResize.Restart(); LastViewport = ImGui.GetWindowViewport().NativePtr; _wasDocked = ImGui.IsWindowDocked(); var currentTab = Plugin.Config.SidebarTabView ? DrawTabSidebar() : DrawTabBar(); Tab? activeTab = null; if (currentTab > -1 && currentTab < Plugin.Config.Tabs.Count) { activeTab = Plugin.Config.Tabs[currentTab]; } ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, Vector2.Zero); try { if (_tellTarget != null) { var playerName = _tellTarget.Name; if (ScreenshotMode) playerName = HashPlayer(_tellTarget.Name, _tellTarget.World); var world = Plugin.DataManager.GetExcelSheet() ?.GetRow(_tellTarget.World) ?.Name ?.RawString ?? "???"; DrawChunks(new Chunk[] { 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 if (_tempChannel != null) { if (_tempChannel.Value.IsLinkshell()) { var idx = (uint) _tempChannel.Value - (uint) InputChannel.Linkshell1; var lsName = Plugin.Functions.Chat.GetLinkshellName(idx); ImGui.TextUnformatted($"LS #{idx + 1}: {lsName}"); } else if (_tempChannel.Value.IsCrossLinkshell()) { var idx = (uint) _tempChannel.Value - (uint) InputChannel.CrossLinkshell1; var cwlsName = Plugin.Functions.Chat.GetCrossLinkshellName(idx); ImGui.TextUnformatted($"CWLS [{idx + 1}]: {cwlsName}"); } else { ImGui.TextUnformatted(_tempChannel.Value.ToChatType().Name()); } } else if (activeTab is { Channel: { } channel }) { if (channel.IsExtraChatLinkshell()) { // We cannot lookup ExtraChat channel names from index over // IPC so we just don't show the name if it's the tabs // channel. // // We don't call channel.ToChatType().Name() as it has the // long name as used in the settings window. ImGui.TextUnformatted($"ECLS [{channel.LinkshellIndex() + 1}]"); } else { ImGui.TextUnformatted(channel.ToChatType().Name()); } } else if (Plugin.ExtraChat.ChannelOverride is var (overrideName, _)) { ImGui.TextUnformatted(overrideName); } else if (ScreenshotMode && Plugin.Functions.Chat.Channel is (InputChannel.Tell, _, var tellPlayerName, var tellWorldId)) { if (!string.IsNullOrWhiteSpace(tellPlayerName) && tellWorldId != 0) { var playerName = HashPlayer(tellPlayerName, tellWorldId); var world = Plugin.DataManager.GetExcelSheet() ?.GetRow(tellWorldId) ?.Name ?.RawString ?? "???"; DrawChunks(new Chunk[] { 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. ImGui.TextUnformatted("Tell"); } } else { DrawChunks(Plugin.Functions.Chat.Channel.name); } } finally { ImGui.PopStyleVar(); } var beforeIcon = ImGui.GetCursorPos(); if (ImGuiUtil.IconButton(FontAwesomeIcon.Comment) && activeTab is not { Channel: { } }) { ImGui.OpenPopup(ChatChannelPicker); } if (activeTab is { Channel: { } } && ImGui.IsItemHovered()) { ImGui.BeginTooltip(); ImGui.TextUnformatted(Language.ChatLog_SwitcherDisabled); ImGui.EndTooltip(); } if (ImGui.BeginPopup(ChatChannelPicker)) { foreach (var channel in Enum.GetValues()) { var name = Plugin.DataManager.GetExcelSheet()! .FirstOrDefault(row => row.LogKind == (byte) channel.ToChatType()) ?.Name ?.RawString ?? 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}"; } if (channel.IsExtraChatLinkshell()) { // 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 (!Plugin.CommandManager.Commands.ContainsKey(channel.Prefix())) { continue; } } if (ImGui.Selectable(name)) { SetChannel(channel); } } ImGui.EndPopup(); } ImGui.SameLine(); var afterIcon = ImGui.GetCursorPos(); var buttonWidth = afterIcon.X - beforeIcon.X; var showNovice = Plugin.Config.ShowNoviceNetwork && Plugin.Functions.IsMentor(); var inputWidth = ImGui.GetContentRegionAvail().X - buttonWidth * (showNovice ? 2 : 1); var inputType = _tempChannel?.ToChatType() ?? activeTab?.Channel?.ToChatType() ?? Plugin.Functions.Chat.Channel.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 normalColour = *ImGui.GetStyleColorVec4(ImGuiCol.Text); var inputColour = Plugin.Config.ChatColours.TryGetValue(inputType, out var inputCol) ? inputCol : inputType.DefaultColour(); if (!isCommand && Plugin.ExtraChat.ChannelOverride is var (_, overrideColour)) { inputColour = overrideColour; } if (isCommand && Plugin.ExtraChat.ChannelCommandColours.TryGetValue(Chat.Split(' ')[0], out var ecColour)) { inputColour = ecColour; } if (inputColour != null) { ImGui.PushStyleColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(inputColour.Value)); } if (Activate) { ImGui.SetKeyboardFocusHere(); } var chatCopy = Chat; ImGui.SetNextItemWidth(inputWidth); const ImGuiInputTextFlags inputFlags = ImGuiInputTextFlags.CallbackAlways | ImGuiInputTextFlags.CallbackCharFilter | ImGuiInputTextFlags.CallbackCompletion | ImGuiInputTextFlags.CallbackHistory; ImGui.InputText("##chat2-input", ref Chat, 500, inputFlags, Callback); if (ImGui.IsItemDeactivated()) { if (ImGui.IsKeyDown(ImGuiKey.Escape)) { Chat = chatCopy; if (Plugin.Functions.Chat.UsesTellTempChannel) { Plugin.Functions.Chat.UsesTellTempChannel = false; SetChannel(Plugin.Functions.Chat.PreviousChannel); } } var enter = ImGui.IsKeyDown(ImGuiKey.Enter) || ImGui.IsKeyDown(ImGuiKey.KeypadEnter); if (enter) { Plugin.CommandHelpWindow.IsOpen = false; SendChatBox(activeTab); if (Plugin.Functions.Chat.UsesTellTempChannel) { Plugin.Functions.Chat.UsesTellTempChannel = false; SetChannel(Plugin.Functions.Chat.PreviousChannel); } } } if (ImGui.IsItemActive()) { HandleKeybinds(true); } // Only trigger unfocused if we are currently not calling the auto complete if (!Activate && !ImGui.IsItemActive() && _autoCompleteInfo == null) { if (_tempChannel is InputChannel.Tell) _tellTarget = null; _tempChannel = null; if (Plugin.Functions.Chat.UsesTellTempChannel) { Plugin.Functions.Chat.UsesTellTempChannel = false; SetChannel(Plugin.Functions.Chat.PreviousChannel); } } if (ImGui.BeginPopupContextItem()) { ImGui.PushStyleColor(ImGuiCol.Text, normalColour); try { if (ImGui.Selectable(Language.ChatLog_HideChat)) UserHide(); } finally { ImGui.PopStyleColor(); } ImGui.EndPopup(); } if (inputColour != null) ImGui.PopStyleColor(); ImGui.SameLine(); if (ImGuiUtil.IconButton(FontAwesomeIcon.Cog)) Plugin.SettingsWindow.Toggle(); if (showNovice) { ImGui.SameLine(); if (ImGuiUtil.IconButton(FontAwesomeIcon.Leaf)) Plugin.Functions.ClickNoviceNetworkButton(); } } internal void SetChannel(InputChannel? channel) { channel ??= InputChannel.Say; _tellTarget = null; // Instead of calling SetChannel(), we ask the ExtraChat plugin to set a // channel override by just calling the command directly. 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()); Plugin.Common.Functions.Chat.SendMessageUnsafe(bytes); return; } Plugin.Functions.Chat.SetChannel(channel.Value); } private void SendChatBox(Tab? activeTab) { if (!string.IsNullOrWhiteSpace(Chat)) { var trimmed = Chat.Trim(); AddBacklog(trimmed); _inputBacklogIdx = -1; if (!trimmed.StartsWith('/')) { if (_tellTarget != null) { var target = _tellTarget; var reason = target.Reason; var world = Plugin.DataManager.GetExcelSheet()?.GetRow(target.World); if (world is { IsPublic: true }) { if (reason == TellReason.Reply && Plugin.Common.Functions.FriendList.List.Any(friend => friend.ContentId == target.ContentId)) { reason = TellReason.Friend; } var tellBytes = Encoding.UTF8.GetBytes(trimmed); AutoTranslate.ReplaceWithPayload(Plugin.DataManager, ref tellBytes); Plugin.Functions.Chat.SendTell(reason, target.ContentId, target.Name, (ushort) world.RowId, tellBytes); } if (_tempChannel is InputChannel.Tell) { _tellTarget = null; } goto Skip; } if (_tempChannel != null) trimmed = $"{_tempChannel.Value.Prefix()} {trimmed}"; else if (activeTab is { Channel: { } channel }) trimmed = $"{channel.Prefix()} {trimmed}"; } var bytes = Encoding.UTF8.GetBytes(trimmed); AutoTranslate.ReplaceWithPayload(Plugin.DataManager, ref bytes); Plugin.Common.Functions.Chat.SendMessageUnsafe(bytes); } Skip: Chat = string.Empty; } internal void UserHide() { _hideState = HideState.User; } internal void DrawMessageLog(Tab tab, PayloadHandler handler, float childHeight, bool switchedTab) { if (ImGui.BeginChild("##chat2-messages", new Vector2(-1, childHeight))) { ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, Vector2.Zero); var table = tab.DisplayTimestamp && Plugin.Config.PrettierTimestamps; var oldCellPaddingY = ImGui.GetStyle().CellPadding.Y; if (Plugin.Config.PrettierTimestamps && Plugin.Config.MoreCompactPretty) { var padding = ImGui.GetStyle().CellPadding; padding.Y = 0; ImGui.PushStyleVar(ImGuiStyleVar.CellPadding, padding); } if (table) { if (!ImGui.BeginTable("timestamp-table", 2, ImGuiTableFlags.PreciseWidths)) { goto EndChild; } ImGui.TableSetupColumn("timestamps", ImGuiTableColumnFlags.WidthFixed); ImGui.TableSetupColumn("messages", ImGuiTableColumnFlags.WidthStretch); } try { tab.MessagesMutex.Wait(); var reset = false; if (_lastResize.IsRunning && _lastResize.Elapsed.TotalSeconds > 0.25) { _lastResize.Stop(); _lastResize.Reset(); reset = true; } var lastPos = ImGui.GetCursorPosY(); var lastTimestamp = string.Empty; int? lastMessageHash = null; var sameCount = 0; for (var i = 0; i < tab.Messages.Count; i++) { var message = tab.Messages[i]; if (reset) { message.Height = null; message.IsVisible = false; } if (Plugin.Config.CollapseDuplicateMessages) { var messageHash = message.Hash; var same = lastMessageHash == messageHash; if (same) { sameCount += 1; if (i != tab.Messages.Count - 1) { continue; } } if (sameCount > 0) { ImGui.SameLine(); DrawChunks( new[] { 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 == tab.Messages.Count - 1) { continue; } } // go to next row if (table) { ImGui.TableNextColumn(); } // message has rendered once if (message.Height.HasValue) { // message isn't visible, so render dummy if (!message.IsVisible) { var beforeDummy = ImGui.GetCursorPos(); if (table) { // skip to the message column for vis test ImGui.TableNextColumn(); } ImGui.Dummy(new Vector2(10f, message.Height.Value)); message.IsVisible = ImGui.IsItemVisible(); if (message.IsVisible) { if (table) { ImGui.TableSetColumnIndex(0); } ImGui.SetCursorPos(beforeDummy); } else { goto UpdateMessage; } } } if (tab.DisplayTimestamp) { var timestamp = message.Date.ToLocalTime().ToString("t"); if (table) { if (!Plugin.Config.HideSameTimestamps || timestamp != lastTimestamp) { ImGui.TextUnformatted(timestamp); lastTimestamp = timestamp; } } else { DrawChunk(new TextChunk(ChunkSource.None, null, $"[{timestamp}]") { Foreground = 0xFFFFFFFF, }); ImGui.SameLine(); } } if (table) { ImGui.TableNextColumn(); } var lineWidth = ImGui.GetContentRegionAvail().X; var beforeDraw = ImGui.GetCursorScreenPos(); if (message.Sender.Count > 0) { DrawChunks(message.Sender, true, handler, lineWidth); ImGui.SameLine(); } if (message.Content.Count == 0) { DrawChunks(new[] { new TextChunk(ChunkSource.Content, null, " ") }, true, handler, lineWidth); } else { DrawChunks(message.Content, true, handler, lineWidth); } var afterDraw = ImGui.GetCursorScreenPos(); message.Height = ImGui.GetCursorPosY() - lastPos; if (Plugin.Config.PrettierTimestamps && !Plugin.Config.MoreCompactPretty) { message.Height -= oldCellPaddingY * 2; beforeDraw.Y += oldCellPaddingY; afterDraw.Y -= oldCellPaddingY; } message.IsVisible = ImGui.IsRectVisible(beforeDraw, afterDraw); UpdateMessage: lastPos = ImGui.GetCursorPosY(); } } finally { tab.MessagesMutex.Release(); ImGui.PopStyleVar(Plugin.Config.PrettierTimestamps && Plugin.Config.MoreCompactPretty ? 2 : 1); } if (switchedTab || ImGui.GetScrollY() >= ImGui.GetScrollMaxY()) { ImGui.SetScrollHereY(1f); } handler.Draw(); if (table) { ImGui.EndTable(); } } EndChild: ImGui.EndChild(); } private int DrawTabBar() { var currentTab = -1; if (!ImGui.BeginTabBar("##chat2-tabs")) { return currentTab; } for (var tabI = 0; tabI < Plugin.Config.Tabs.Count; tabI++) { var tab = Plugin.Config.Tabs[tabI]; if (tab.PopOut) { continue; } var unread = tabI == LastTab || tab.UnreadMode == UnreadMode.None || tab.Unread == 0 ? "" : $" ({tab.Unread})"; var draw = ImGui.BeginTabItem($"{tab.Name}{unread}###log-tab-{tabI}"); DrawTabContextMenu(tab, tabI); if (!draw) { continue; } currentTab = tabI; var switchedTab = LastTab != tabI; LastTab = tabI; tab.Unread = 0; DrawMessageLog(tab, PayloadHandler, GetRemainingHeightForMessageLog(), switchedTab); ImGui.EndTabItem(); } ImGui.EndTabBar(); return currentTab; } private int DrawTabSidebar() { var currentTab = -1; if (!ImGui.BeginTable("tabs-table", 2, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.Resizable)) { return -1; } ImGui.TableSetupColumn("tabs", ImGuiTableColumnFlags.None, 1); ImGui.TableSetupColumn("chat", ImGuiTableColumnFlags.None, 4); ImGui.TableNextColumn(); var switchedTab = false; var childHeight = GetRemainingHeightForMessageLog(); if (ImGui.BeginChild("##chat2-tab-sidebar", new Vector2(-1, childHeight))) { for (var tabI = 0; tabI < Plugin.Config.Tabs.Count; tabI++) { var tab = Plugin.Config.Tabs[tabI]; if (tab.PopOut) { continue; } var unread = tabI == LastTab || tab.UnreadMode == UnreadMode.None || tab.Unread == 0 ? "" : $" ({tab.Unread})"; var clicked = ImGui.Selectable($"{tab.Name}{unread}###log-tab-{tabI}", LastTab == tabI); DrawTabContextMenu(tab, tabI); if (!clicked) { continue; } currentTab = tabI; switchedTab = LastTab != tabI; LastTab = tabI; } } ImGui.EndChild(); ImGui.TableNextColumn(); if (currentTab == -1 && LastTab < Plugin.Config.Tabs.Count) { currentTab = LastTab; Plugin.Config.Tabs[currentTab].Unread = 0; } if (currentTab > -1) { DrawMessageLog(Plugin.Config.Tabs[currentTab], PayloadHandler, childHeight, switchedTab); } ImGui.EndTable(); return currentTab; } private void DrawTabContextMenu(Tab tab, int i) { if (!ImGui.BeginPopupContextItem()) { return; } var tabs = Plugin.Config.Tabs; var anyChanged = false; ImGui.PushID($"tab-context-menu-{i}"); 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); 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 (anyChanged) { Plugin.SaveConfig(); } ImGui.PopID(); ImGui.EndPopup(); } internal readonly List PopOutDocked = new(); internal Dictionary PopOutWindows = new(); private void DrawPopOuts() { 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.ContainsKey($"{tab.Name}{i}")) continue; var window = new Popout(this, tab, i) { IsOpen = true }; Plugin.WindowSystem.AddWindow(window); PopOutWindows.Add($"{tab.Name}{i}", window); } } private unsafe void DrawAutoComplete() { if (_autoCompleteInfo == null) { return; } _autoCompleteList ??= AutoTranslate.Matching(Plugin.DataManager, _autoCompleteInfo.ToComplete, Plugin.Config.SortAutoTranslate); if (_autoCompleteOpen) { ImGui.OpenPopup(AutoCompleteId); _autoCompleteOpen = false; } ImGui.SetNextWindowSize(new Vector2(400, 300) * ImGuiHelpers.GlobalScale); if (!ImGui.BeginPopup(AutoCompleteId)) { 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(Plugin.DataManager, _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._0 + 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(); goto End; } 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); } if (ImGui.BeginChild("##auto-complete-list", Vector2.Zero, false, ImGuiWindowFlags.HorizontalScrollbar)) { var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper_ImGuiListClipper()); 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.String}##{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); ImGui.PushStyleColor(ImGuiCol.Text, *ImGui.GetStyleColorVec4(ImGuiCol.TextDisabled)); ImGui.TextUnformatted(text); ImGui.PopStyleColor(); } if (!clicked) { continue; } var before = Chat[.._autoCompleteInfo.StartPos]; var after = Chat[_autoCompleteInfo.EndPos..]; var replacement = $""; Chat = $"{before}{replacement}{after}"; ImGui.CloseCurrentPopup(); Activate = true; _activatePos = _autoCompleteInfo.StartPos + replacement.Length; } } if (_autoCompleteShouldScroll) { _autoCompleteShouldScroll = false; var selectedPos = clipper.StartPosY + clipper.ItemsHeight * (_autoCompleteSelection * 1f); ImGui.SetScrollFromPosY(selectedPos - ImGui.GetWindowPos().Y); } ImGui.EndChild(); } End: ImGui.EndPopup(); } private unsafe int AutoCompleteCallback(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; } } return 0; } private unsafe int Callback(ImGuiInputTextCallbackData* data) { var ptr = new ImGuiInputTextCallbackDataPtr(data); if (data->EventFlag == ImGuiInputTextFlags.CallbackCompletion) { if (ptr.CursorPos == 0) { _autoCompleteInfo = new AutoCompleteInfo( string.Empty, ptr.CursorPos, ptr.CursorPos ); _autoCompleteOpen = true; _autoCompleteSelection = 0; return 0; } int white; for (white = ptr.CursorPos - 1; white >= 0; white--) { if (data->Buf[white] == ' ') { break; } } var start = ptr.Buf + white + 1; var end = ptr.CursorPos - white - 1; var utf8Message = Marshal.PtrToStringUTF8(start, end); var correctedCursor = ptr.CursorPos - (end - utf8Message.Length); _autoCompleteInfo = new AutoCompleteInfo( utf8Message, white + 1, correctedCursor ); _autoCompleteOpen = true; _autoCompleteSelection = 0; return 0; } if (data->EventFlag == ImGuiInputTextFlags.CallbackCharFilter) { var valid = Plugin.Functions.Chat.IsCharValid((char) ptr.EventChar); if (!valid) { 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((IntPtr) data->Buf, data->BufTextLen); if (text.StartsWith('/')) { var command = text.Split(' ')[0]; var cmd = Plugin.DataManager.GetExcelSheet()?.FirstOrDefault(cmd => cmd.Command.RawString == command || cmd.Alias.RawString == command || cmd.ShortCommand.RawString == command || cmd.ShortAlias.RawString == command); if (cmd != null) Plugin.CommandHelpWindow.UpdateContent(cmd); } 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 = _inputBacklog.Count - 1 - offset; break; case > 0: _inputBacklogIdx--; break; } break; case ImGuiKey.DownArrow: { if (_inputBacklogIdx != -1) { if (++_inputBacklogIdx >= _inputBacklog.Count) { _inputBacklogIdx = -1; } } break; } } if (prevPos == _inputBacklogIdx) { return 0; } var historyStr = _inputBacklogIdx >= 0 ? _inputBacklog[_inputBacklogIdx] : string.Empty; ptr.DeleteChars(0, ptr.BufTextLen); ptr.InsertChars(0, historyStr); return 0; } internal void DrawChunks(IReadOnlyList chunks, bool wrap = true, PayloadHandler? handler = null, float lineWidth = 0f) { ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, Vector2.Zero); try { 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(); } } } finally { ImGui.PopStyleVar(); } } private void DrawChunk(Chunk chunk, bool wrap = true, PayloadHandler? handler = null, float lineWidth = 0f) { if (chunk is IconChunk icon && _fontIcon != null) { var bounds = IconUtil.GfdFileView.TryGetEntry((uint) icon.Icon, out var entry); if (!bounds) return; var texSize = new Vector2(_fontIcon.Width, _fontIcon.Height); var sizeRatio = Plugin.Config.FontSize / 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(_fontIcon.ImGuiHandle, size, uv0, uv1); ImGuiUtil.PostPayload(chunk, handler); return; } if (chunk is not TextChunk text) { 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.DefaultColour(); } if (colour != null) { colour = ColourUtil.RgbaToAbgr(colour.Value); ImGui.PushStyleColor(ImGuiCol.Text, colour.Value); } var pushed = false; if (text.Italic) { pushed = true; (Plugin.Config.FontsEnabled && Plugin.FontManager.ItalicFont != null ? Plugin.FontManager.ItalicFont : Plugin.FontManager.AxisItalic).Push(); } var content = text.Content; if (ScreenshotMode) { // 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. if (chunk.Link is PlayerPayload playerPayload && content.Contains(playerPayload.PlayerName)) { content = content.Replace(playerPayload.PlayerName, HashPlayer(playerPayload.PlayerName, playerPayload.World.RowId)); } else if (Plugin.ClientState.LocalPlayer is { } player && content.Contains(player.Name.TextValue)) { content = content.Replace(player.Name.TextValue, HashPlayer(player.Name.TextValue, player.HomeWorld.Id)); } } if (wrap) { ImGuiUtil.WrapText(content, chunk, handler, DefaultText, lineWidth); } else { ImGui.TextUnformatted(content); ImGuiUtil.PostPayload(chunk, handler); } if (pushed) { (Plugin.Config.FontsEnabled && Plugin.FontManager.ItalicFont != null ? Plugin.FontManager.ItalicFont : Plugin.FontManager.AxisItalic).Pop(); } if (colour != null) { ImGui.PopStyleColor(); } } private string HashPlayer(string playerName, uint worldId) { var hashCode = $"{Salt}{playerName}{worldId}".GetHashCode(); return $"Player {hashCode:X8}"; } }