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