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; using Microsoft.Extensions.Logging; 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; private readonly SymbolPicker _symbolPicker; 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? 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 HandlerLender { get; } private Dictionary TextCommandChannels { get; } = new(); private Dictionary 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; private readonly ILogger _logger; private readonly ILoggerFactory _loggerFactory; internal ChatLogWindow( Plugin plugin, ILogger logger, ILoggerFactory loggerFactory ) : base($"{Plugin.PluginName}###chat2") { Plugin = plugin; _logger = logger; _loggerFactory = loggerFactory; 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, _loggerFactory.CreateLogger()); HandlerLender = new Lender(() => new PayloadHandler(this, _loggerFactory.CreateLogger()) ); 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; _symbolPicker = new SymbolPicker(); 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; } // --------------------------------------------------------------- // Cherry-picked from ChatTwo upstream ee7768ac (Infiziert90, 2026-05-16) // - Replace the chat input when args.AddIfNotPresent / args.Input starts // with a slash. Vanilla actions like the Friend List "/tell" entry and // other plugins push slash commands through these args; appending them // to existing text would produce inputs like "test/tell user@world". // --------------------------------------------------------------- if (args.AddIfNotPresent != null && !Chat.Contains(args.AddIfNotPresent)) { if (args.AddIfNotPresent.StartsWith('/')) Chat = args.AddIfNotPresent; else Chat += args.AddIfNotPresent; } if (args.Input != null) { if (args.Input.StartsWith('/')) Chat = args.Input; else 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) ) { _logger.LogWarning( $"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; _logger.LogTrace("HideState: → User (chat hide command)"); break; case "show": CurrentHideState = HideState.None; _logger.LogTrace("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, }; _logger.LogTrace($"HideState: → {CurrentHideState} (chat toggle command)"); break; } } private void SetUpTextCommandChannels() { TextCommandChannels.Clear(); foreach (var input in Enum.GetValues()) { 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(); // StatusBar.Height now bakes in its own DPI-aware 2px spacer, so the // window reservation is just Height -- no extra +2 (v1.4.8 B1). height -= StatusBar.Height; 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); } // PM-2b v1.5.4 header quick-picker. Two scrollable sections -- every // built-in plus custom theme, and every tab. Clicking a theme arms // the PM-1 crossfade via ThemeRegistry.Switch; clicking a tab routes // through ChangeTab so LastActivityTime stays consistent with the // sidebar and top-bar click paths. DontClosePopups keeps the popup // open so the user can hop between entries without re-opening it. private void DrawQuickPickerPopup() { using var popup = ImRaii.Popup("##hellion-quick-picker"); if (!popup.Success) return; ImGui.TextUnformatted(HellionStrings.Settings_QuickPicker_Themes_Header); ImGui.Separator(); var activeSlug = Plugin.ThemeRegistry.Active.Slug; var allThemes = Plugin .ThemeRegistry.AllBuiltIns() .Concat(Plugin.ThemeRegistry.AllCustom()) .ToList(); using ( var scroll = ImRaii.Child( "##hellion-quick-picker-themes", new Vector2(220f, Math.Min(allThemes.Count * 22f, 200f)) ) ) { if (scroll.Success) { foreach (var theme in allThemes) { var isActive = string.Equals( theme.Slug, activeSlug, StringComparison.OrdinalIgnoreCase ); DrawQuickPickerGlyph(isActive); if ( ImGui.Selectable( $"{theme.Name}##quick-theme-{theme.Slug}", isActive, ImGuiSelectableFlags.DontClosePopups ) && !isActive ) Plugin.ThemeRegistry.Switch(theme.Slug); } } } ImGui.Spacing(); ImGui.TextUnformatted(HellionStrings.Settings_QuickPicker_Tabs_Header); ImGui.Separator(); var tabs = Plugin.Config.Tabs; var activeTabIndex = Plugin.LastTab; using ( var scroll = ImRaii.Child( "##hellion-quick-picker-tabs", new Vector2(220f, Math.Min(tabs.Count * 22f, 200f)) ) ) { if (scroll.Success) { for (var i = 0; i < tabs.Count; i++) { var isActive = i == activeTabIndex; DrawQuickPickerGlyph(isActive); if ( ImGui.Selectable( $"{tabs[i].Name}##quick-tab-{i}", isActive, ImGuiSelectableFlags.DontClosePopups ) && !isActive ) ChangeTab(i); } } } } // Leading check-glyph slot for a quick-picker row. Active rows get a // FontAwesome check; inactive rows get a same-width blank so the // labels stay aligned. The glyph font push stays on its own line so // it never bleeds into the body-font Selectable label. private void DrawQuickPickerGlyph(bool isActive) { using (Plugin.FontManager.FontAwesome.Push()) { var check = FontAwesomeIcon.Check.ToIconString(); if (isActive) ImGui.TextUnformatted(check); else ImGui.Dummy(new Vector2(ImGui.CalcTextSize(check).X, ImGui.GetTextLineHeight())); } ImGui.SameLine(); } private void TabSwitched(Tab newTab, Tab previousTab) { // Use the fixed channel if set by the user. Otherwise, if the new tab // has no channel state yet (fresh from JSON, never selected this // session), seed from the previous tab — but deep-clone so we don't // share TellTarget with the previous tab. Without the clone, a later // /tell on the new tab would mutate the pinned tab's TellTarget and // the Party/Linkshell channel would pop back to the pinned tell-mark. if (newTab.Channel is not null) { newTab.CurrentChannel.Channel = newTab.Channel.Value; } else if (newTab.CurrentChannel.Channel is InputChannel.Invalid) { newTab.CurrentChannel = previousTab.CurrentChannel.Clone(); _logger.LogDebug( $"[Tab] '{newTab.Name}' seeded channel from '{previousTab.Name}' " + $"(Channel={newTab.CurrentChannel.Channel}, TellTarget={newTab.CurrentChannel.TellTarget?.ToTargetString() ?? "null"})" ); } 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; _logger.LogTrace("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; _logger.LogTrace("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; _logger.LogTrace("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 ) { _logger.LogTrace($"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; _logger.LogTrace("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; _logger.LogTrace("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; } // v1.4.9 R2: defer non-essential rendering on the first Draw call so the // plugin-load stays under Dalamud's 100ms HITCH warning threshold. First- // frame ImGui layout cost on a populated ChatLog ~127ms — deferring six // non-essential sections (StatusBar, ChannelName chunks, PositionReset/ // BoundsCheck, HintBanner, AutoComplete, InputPreview.CalculatePreview) // shaves ~33ms down to ~94ms. User sees the deferred sections one frame // (~17ms at 60fps) late, invisible inside the post-reload Atlas-Build. private bool _firstFrameDone; // UI-5: set when the user clicks the scroll-to-bottom button; the next // frame's scroll-snap check forces a jump to the live end. private bool _scrollToBottomRequested; // UI-5: cached each frame inside the ##chat2-messages child. True when the // user has scrolled up enough that the toolbar button should be shown. private bool _childScrolledUp; public override void Draw() { DrewThisFrame = true; try { DrawChatLog(); AddPopOutsToDraw(); // v1.4.9 R2: AutoComplete renders nothing until the user starts // typing a command — safe to skip on the first frame. ~6ms. if (_firstFrameDone) DrawAutoComplete(); } catch (Exception ex) { _logger.LogError(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; } finally { // Flag flips after the first Draw completes (success or caught // exception). Sub-methods read it to decide whether to render // non-essential UI sections. _firstFrameDone = true; } } 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(); // v1.4.9 R2: skip the bounds-check chain on the first frame. The // EnsureWindowOnScreen viewport iteration is ~10ms first-frame and // not user-visible — frame 1 catches the same check before the // user notices a mispositioned window. if (_firstFrameDone) { // 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(); // v1.4.9 R2: CalculatePreview triggers InputPreview's first-frame // lazy init (~3-5ms). User-typing-driven, safe to defer one frame. if (_firstFrameDone && 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. // v1.4.9 R2: skip on first frame (~3-5ms layout cost). The banner // is a v0.6.1 migration notice that returns the same result frame 1. if (_firstFrameDone) 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; // Symbol-picker trigger sits left of the channel indicator. ImRaii.Popup // inside DrawAndConsume pins to the last rendered item, so the call MUST // run immediately after this IconButton — placing it after the channel // picker below would pin the popup under the wrong widget. if (Plugin.Config.SymbolPickerEnabled) { if ( ImGuiUtil.IconButton( FontAwesomeIcon.Smile, "symbol-picker-trigger", "Insert symbol or FFXIV icon" ) ) { _symbolPicker.OpenPopup(); } } // DrawAndConsume runs unconditionally; with the button hidden the popup // can't open, so the call is a no-op. Splice path stays outside the // guard for the same reason. var insertedSymbol = _symbolPicker.DrawAndConsume(); if (insertedSymbol is not null) { // Same cursor-aware splice idiom as the AutoComplete commit path at // ChatLogWindow.cs:2487-2493. Clamp because CursorPos can drift if // the user mutates Chat while the popup is open. var pos = Math.Clamp(CursorPos, 0, Chat.Length); Chat = Chat[..pos] + insertedSymbol + Chat[pos..]; Activate = true; ActivatePos = pos + insertedSymbol.Length; } if (Plugin.Config.SymbolPickerEnabled) ImGui.SameLine(); 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); // Right-side buttons: quick-picker palette + cog (always present) // plus the optional hide / novice buttons. Each slot costs the // measured button width AND one ItemSpacing for the SameLine gap // in front of it -- leaving the spacing term out overflows the // header row by one gap per button (v1.5.4 quick-picker fix). var rightButtonCount = 2 + buttonsRight; var inputWidth = ImGui.GetContentRegionAvail().X - rightButtonCount * (buttonWidth + ImGui.GetStyle().ItemSpacing.X); 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.Palette, tooltip: HellionStrings.Settings_QuickPicker_Tooltip, width: (int)buttonWidth ) ) ImGui.OpenPopup("##hellion-quick-picker"); DrawQuickPickerPopup(); 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. // v1.4.9 R2: skip on the first frame; ~12ms of first-frame layout // cost. User sees the StatusBar 1 frame (~17ms at 60fps) later // which is hidden inside the post-reload Atlas-Build window. if (_firstFrameDone) Plugin.StatusBar.Draw(Plugin); } internal Dictionary GetValidChannels() { var channels = new Dictionary(); foreach (var channel in Enum.GetValues()) { 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) { // v1.4.9 R2: plain-text fallback on the first frame. ReadChannelName // builds SeString chunks and DrawChunks runs SeString-Renderer layout // — together ~18ms first-frame. Frame 1 renders the real chunks; the // user sees the tab name for ~17ms during the post-reload window. if (!_firstFrameDone) { ImGui.TextUnformatted(activeTab.Name); return; } 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, bool updateScrollState = true ) { using (var child = ImRaii.Child("##chat2-messages", new Vector2(-1, childHeight))) { if (child.Success) { if (tab.DisplayTimestamp && Plugin.Config.PrettierTimestamps) DrawLogTableStyle(tab, handler, switchedTab); else DrawLogNormalStyle(tab, handler, switchedTab); // Cached for the header toolbar's scroll-to-bottom button, which is // drawn one frame later. GetScrollMaxY / GetScrollY here refer to // the child's scroll context. Pop-out windows pass updateScrollState: // false so they do not overwrite the main window's cached state. if (updateScrollState) _childScrolledUp = ImGui.GetScrollMaxY() - ImGui.GetScrollY() > 1f; } else { if (updateScrollState) _childScrolledUp = false; } } } private void DrawLogNormalStyle(Tab tab, PayloadHandler handler, bool switchedTab) { using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero)) DrawMessages(tab, handler, false); if (switchedTab || _scrollToBottomRequested || ImGui.GetScrollY() >= ImGui.GetScrollMaxY()) ImGui.SetScrollHereY(1f); _scrollToBottomRequested = false; 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 || _scrollToBottomRequested || ImGui.GetScrollY() + cellPaddingOffset >= ImGui.GetScrollMaxY() ) ImGui.SetScrollHereY(1f); _scrollToBottomRequested = false; 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 are // invariant per DrawMessages call. borderColorAbgr used to be // hoisted here too, but PM-3d (v1.5.4) modulates it by // tab._cardHoverAlpha per row, so it moves into the AddLine // call below. anyCardHovered aggregates the row-hover state // across all card-rows; the lerp runs once at the loop end so // the next frame paints with the updated alpha. var theme = Plugin.ThemeRegistry.Active; var drawList = ImGui.GetWindowDrawList(); var winLeft = ImGui.GetWindowPos().X; var winRight = winLeft + ImGui.GetWindowSize().X; var baseBorderRgba = (theme.Colors.Border & 0xFFFFFF00u) | 0x33u; var anyCardHovered = false; 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) { var rowStartY = ImGui.GetCursorScreenPos().Y; 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. Base alpha 0x33; // PM-3d lifts it by up to ~+0x70 while any row in this // tab is hovered. _cardHoverAlpha lerps at the loop // end, so the one-frame lag is invisible at 10f speed. { var rowEndY = ImGui.GetCursorScreenPos().Y; var hoverBoost = 0.45f * tab._cardHoverAlpha; var alphaByte = (uint) Math.Clamp((int)(0x33u + hoverBoost * 255f), 0x33, 0xCC); var borderColorAbgr = ColourUtil.RgbaToAbgr( (baseBorderRgba & 0xFFFFFF00u) | alphaByte ); drawList.AddLine( new Vector2(winLeft + 4, rowEndY - 1), new Vector2(winRight - 4, rowEndY - 1), borderColorAbgr, 1f ); ImGui.Dummy(new Vector2(0, 2)); // Whole-row hover test. IsItemHovered would only see // the 2px Dummy above, so hit-test the row rect from // its start Y down to the separator line instead. if ( ImGui.IsMouseHoveringRect( new Vector2(winLeft, rowStartY), new Vector2(winRight, rowEndY) ) ) anyCardHovered = true; } } 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(); } // PM-3d: update the per-tab card-hover lerp once per // DrawMessages call. ReduceMotion snaps to the target; // otherwise the border alpha eases toward it over a few // frames the next time the rows paint. var cardTarget = anyCardHovered ? 1f : 0f; tab._cardHoverAlpha = Plugin.Config.ReduceMotion ? cardTarget : FrameLerp.Smooth( tab._cardHoverAlpha, cardTarget, speed: 10f, deltaTime: ImGui.GetIO().DeltaTime ); } 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) { _logger.LogWarning(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; } // Sidebar render order: persistent tabs in their original Plugin.Config.Tabs // position, then pinned TempTabs, then unpinned TempTabs. Returns indices // into Plugin.Config.Tabs so tabI in the loop body still mirrors the real // list position (LastTab / WantedTab stay consistent). private static List BuildSidebarRenderOrder() { var tabs = Plugin.Config.Tabs; var persistent = new List(tabs.Count); var pinned = new List(); var unpinned = new List(); for (var i = 0; i < tabs.Count; i++) { if (TabLifecycleHelpers.IsInPinnedPool(tabs[i])) pinned.Add(i); else if (TabLifecycleHelpers.IsInUnpinnedPool(tabs[i])) unpinned.Add(i); else persistent.Add(i); } persistent.AddRange(pinned); persistent.AddRange(unpinned); return persistent; } 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; var sidebarWidth = Math.Clamp(Plugin.Config.SidebarWidth, 44, 160); ImGui.TableSetupColumn("tabs", ImGuiTableColumnFlags.WidthFixed, sidebarWidth); 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; // Render order: persistent → pinned TempTabs → unpinned TempTabs. // Underlying Plugin.Config.Tabs order is untouched (tabI mirrors // the real list index), only the display sequence groups by // section so each section can carry its own divider header. var renderOrder = BuildSidebarRenderOrder(); var pinnedHeaderRendered = false; var tempTabHeaderRendered = false; var pinnedCount = Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool); var unpinnedTempCount = Plugin.Config.Tabs.Count( TabLifecycleHelpers.IsInUnpinnedPool ); foreach (var tabI in renderOrder) { var tab = Plugin.Config.Tabs[tabI]; if (tab.PopOut) continue; if (TabLifecycleHelpers.IsInPinnedPool(tab) && !pinnedHeaderRendered) { ImGui.Separator(); if (!Plugin.Config.AutoTellTabsCompactDisplay) { ImGui.TextDisabled( $"{HellionStrings.PinTab_SectionHeader} ({pinnedCount})" ); } pinnedHeaderRendered = true; } else if (TabLifecycleHelpers.IsInUnpinnedPool(tab) && !tempTabHeaderRendered) { ImGui.Separator(); if (!Plugin.Config.AutoTellTabsCompactDisplay) { ImGui.TextDisabled( $"{HellionStrings.AutoTellTabs_SectionHeader} ({unpinnedTempCount})" ); } 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) ) ) // PM-3c: icon alpha eases from 40% (dim) to 100% on // hover. _hoverAlpha lerps at the end of this block, // so the colour for frame N uses frame N-1's value -- // a sub-frame lag that is invisible at 10f speed. using ( ImRaii.PushColor( ImGuiCol.Text, ColourUtil.ApplyAlpha( ColourUtil.RgbaToAbgr(iconColor), 0.4f + 0.6f * tab._hoverAlpha ) ) ) using (Plugin.FontManager.FontAwesome.Push()) { // Button stretches with the configured sidebar width so a // user-widened sidebar feels intentional, not a 36px icon // floating in empty space. clicked = ImGui.Button( $"{icon.ToIconString()}##sidebar-tab-{tabI}", new Vector2(sidebarWidth - 8f, ImGui.GetFrameHeight()) ); } // PM-3c hover-lerp: ramp _hoverAlpha toward 1 while the // icon button is hovered, back to 0 otherwise. // ReduceMotion snaps so the dim/full states stay binary. var hoverTarget = ImGui.IsItemHovered() ? 1f : 0f; tab._hoverAlpha = Plugin.Config.ReduceMotion ? hoverTarget : FrameLerp.Smooth( tab._hoverAlpha, hoverTarget, speed: 10f, deltaTime: ImGui.GetIO().DeltaTime ); 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: subtle thumbtack glyph top-left of the icon. // Muted colour because the "Pinned" section header already // groups these tabs visually — this is just a per-tab // confirmation glyph, not the primary discoverability cue. if (tab.IsPinned) { var min = ImGui.GetItemRectMin(); const float pinPadding = 1f; var pinPos = new Vector2(min.X + pinPadding, min.Y + pinPadding); var pinColor = theme.Colors.TextMuted; // Dim further so the glyph reads as a hint, not a badge. var pinAbgr = ColourUtil.RgbaToAbgr(pinColor) & 0x77FFFFFFu; using (Plugin.FontManager.FontAwesome.Push()) { ImGui .GetWindowDrawList() .AddText(pinPos, pinAbgr, 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 honorific title slot, the optional // scroll-to-bottom button, and the pop-out button for the active tab. // v1.3.0 added the title slot; v1.5.5 added the scroll-to-bottom button. private void DrawChatHeaderToolbar(Tab tab) { DrawHonorificTitleSlot(); DrawScrollToBottomToolbarButton(); DrawPopOutButton(tab); } // Draws an arrow-down button in the toolbar when the user has scrolled up // from the live end of the chat log. Clicking it requests a snap to bottom. // // _childScrolledUp is set at the end of DrawMessageLog, which runs AFTER // DrawChatHeaderToolbar in the same frame. So this button always reflects the // previous frame's scroll state, a one-frame lag that is imperceptible in use. // // Both this button and DrawPopOutButton use SetCursorPosX with absolute // positioning (cursorX + GetContentRegionAvail().X - N * iconWidth). Because // each call computes its own target X from the right edge, they are independent // of each other and of what the cursor position happens to be at call time. // The pop-out button lands at rightEdge - iconWidth regardless of call order. private void DrawScrollToBottomToolbarButton() { if (!_childScrolledUp) return; var avail = ImGui.GetContentRegionAvail().X; var iconWidth = ImGui.GetFrameHeight(); var spacing = ImGui.GetStyle().ItemSpacing.X; ImGui.SetCursorPosX(ImGui.GetCursorPosX() + avail - 2 * iconWidth - spacing); if ( ImGuiUtil.IconButton( FontAwesomeIcon.ArrowDown, tooltip: HellionStrings.ChatLog_ScrollToBottom_Tooltip ) ) _scrollToBottomRequested = true; // Keep the pop-out button on the same toolbar row. Without this the // button item ends the line and the pop-out drops to the next row. ImGui.SameLine(); } 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; } // When the scroll button is also present it occupies iconWidth + ItemSpacing.X // to the left of the pop-out button, so shrink the title budget accordingly. var scrollButtonReserve = _childScrolledUp ? iconWidth + ImGui.GetStyle().ItemSpacing.X : 0f; var maxTitleWidth = avail - iconWidth - scrollButtonReserve - 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(); _logger.LogDebug("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; // UI-4: focus the rename field on the frame the context menu opens so // the user can type immediately. Buffer raised 128 -> 512 to match the // settings-tab rename (Ui/SettingsTabs/Tabs.cs). One name limit, not two. if (ImGui.IsWindowAppearing()) ImGui.SetKeyboardFocusHere(); ImGui.SetNextItemWidth(250f * ImGuiHelpers.GlobalScale); if (ImGui.InputText("##tab-name", ref tab.Name, 512)) 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 PopOutDocked = []; internal readonly HashSet PopOutWindows = []; // Live enumeration of active Popout windows for KeybindManager tab-cycle forwarding. // Filters on IsOpen to skip closed-but-registered popouts. internal IEnumerable ActivePopouts => Plugin.WindowSystem.Windows.OfType().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, _loggerFactory.CreateLogger()); 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 = $""; 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 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; _logger.LogInformation( $"[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. } }