3296 lines
123 KiB
C#
3296 lines
123 KiB
C#
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.Numerics;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text;
|
|
using Dalamud.Bindings.ImGui;
|
|
using Dalamud.Game.Addon.Lifecycle;
|
|
using Dalamud.Game.Text.SeStringHandling;
|
|
using Dalamud.Game.Text.SeStringHandling.Payloads;
|
|
using Dalamud.Interface;
|
|
using Dalamud.Interface.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 CommandWrapper _clearHellionCommand;
|
|
private readonly CommandWrapper _hellionCommand;
|
|
private readonly SymbolPicker _symbolPicker;
|
|
|
|
internal bool ScreenshotMode;
|
|
private string Salt { get; }
|
|
|
|
internal Vector4 DefaultText { get; set; }
|
|
|
|
internal bool FocusedPreview;
|
|
internal bool Activate;
|
|
internal bool InputFocused { get; private set; }
|
|
private int ActivatePos = -1;
|
|
internal string Chat = string.Empty;
|
|
|
|
// 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<AutoTranslateEntry>? AutoCompleteList;
|
|
private bool FixCursor;
|
|
private int AutoCompleteSelection;
|
|
private bool AutoCompleteShouldScroll;
|
|
|
|
// Used to detect channel changes for the webinterface
|
|
public Chunk[] PreviousChannel = [];
|
|
|
|
public int CursorPos;
|
|
|
|
public Vector2 LastWindowPos { get; private set; } = Vector2.Zero;
|
|
public Vector2 LastWindowSize { get; private set; } = Vector2.Zero;
|
|
|
|
// Guards against off-screen positions after a display layout change.
|
|
// One-shot bounds check on first draw; manual reset button bypasses it.
|
|
private bool DidOnLoadBoundsCheck;
|
|
internal bool RequestPositionReset { get; set; }
|
|
|
|
public unsafe ImGuiViewport* LastViewport;
|
|
private bool WasDocked;
|
|
|
|
public PayloadHandler PayloadHandler { get; }
|
|
internal Lender<PayloadHandler> HandlerLender { get; }
|
|
private Dictionary<string, ChatType> TextCommandChannels { get; } = new();
|
|
private Dictionary<string, TextCommand> AllCommands { get; } = [];
|
|
|
|
private const uint ChatOpenSfx = 35u;
|
|
private const uint ChatCloseSfx = 3u;
|
|
private bool PlayedClosingSound = true;
|
|
private bool DrewThisFrame;
|
|
|
|
// One-shot guard so a recurring draw failure doesn't spam the
|
|
// notification stack frame-by-frame. Resets only on next plugin reload.
|
|
private bool NotifiedDrawFailure;
|
|
|
|
private long FrameTime; // set every frame
|
|
internal long LastActivityTime = Environment.TickCount64;
|
|
|
|
private readonly ILogger<ChatLogWindow> _logger;
|
|
private readonly ILoggerFactory _loggerFactory;
|
|
|
|
internal ChatLogWindow(
|
|
Plugin plugin,
|
|
ILogger<ChatLogWindow> 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<PayloadHandler>());
|
|
HandlerLender = new Lender<PayloadHandler>(() =>
|
|
new PayloadHandler(this, _loggerFactory.CreateLogger<PayloadHandler>())
|
|
);
|
|
|
|
SetUpTextCommandChannels();
|
|
SetUpAllCommands();
|
|
|
|
// Cache wrapper instances so Dispose can detach the same event objects
|
|
// without going through Register() again.
|
|
_clearHellionCommand = Plugin.Commands.Register(
|
|
"/clearhellion",
|
|
"Clear the Hellion Chat log"
|
|
);
|
|
_hellionCommand = Plugin.Commands.Register("/hellion");
|
|
_clearHellionCommand.Execute += ClearLog;
|
|
_hellionCommand.Execute += ToggleChat;
|
|
|
|
_symbolPicker = new SymbolPicker();
|
|
|
|
Plugin.ClientState.Login += Login;
|
|
Plugin.ClientState.Logout += Logout;
|
|
|
|
Plugin.AddonLifecycle.RegisterListener(
|
|
AddonEvent.PostUpdate,
|
|
"ItemDetail",
|
|
PayloadHandler.MoveTooltip
|
|
);
|
|
Plugin.AddonLifecycle.RegisterListener(
|
|
AddonEvent.PostUpdate,
|
|
"ActionDetail",
|
|
PayloadHandler.MoveTooltip
|
|
);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Plugin.AddonLifecycle.UnregisterListener(
|
|
AddonEvent.PostUpdate,
|
|
"ItemDetail",
|
|
PayloadHandler.MoveTooltip
|
|
);
|
|
Plugin.AddonLifecycle.UnregisterListener(
|
|
AddonEvent.PostUpdate,
|
|
"ActionDetail",
|
|
PayloadHandler.MoveTooltip
|
|
);
|
|
Plugin.ClientState.Logout -= Logout;
|
|
Plugin.ClientState.Login -= Login;
|
|
_hellionCommand.Execute -= ToggleChat;
|
|
_clearHellionCommand.Execute -= ClearLog;
|
|
}
|
|
|
|
private void Logout(int _, int __)
|
|
{
|
|
Plugin.MessageManager.ClearAllTabs();
|
|
}
|
|
|
|
private void Login()
|
|
{
|
|
Plugin.MessageManager.FilterAllTabsAsync();
|
|
}
|
|
|
|
internal unsafe void Activated(ChatActivatedArgs args)
|
|
{
|
|
TellSpecial = args.TellSpecial;
|
|
|
|
Activate = true;
|
|
PlayedClosingSound = false;
|
|
if (Plugin.Config.PlaySounds)
|
|
UIGlobals.PlaySoundEffect(ChatOpenSfx);
|
|
|
|
// Don't set the channel or text content when activating a disabled tab.
|
|
if (Plugin.CurrentTab.InputDisabled)
|
|
{
|
|
// The closing sound would've been immediately played in this case.
|
|
PlayedClosingSound = true;
|
|
return;
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Cherry-picked from ChatTwo upstream ee7768ac (Infiziert90, 2026-05-16)
|
|
// - Replace the chat input when args.AddIfNotPresent / args.Input starts
|
|
// with a slash. Vanilla actions like the Friend List "/tell" entry and
|
|
// other plugins push slash commands through these args; appending them
|
|
// to existing text would produce inputs like "test/tell user@world".
|
|
// ---------------------------------------------------------------
|
|
if (args.AddIfNotPresent != null && !Chat.Contains(args.AddIfNotPresent))
|
|
{
|
|
if (args.AddIfNotPresent.StartsWith('/'))
|
|
Chat = args.AddIfNotPresent;
|
|
else
|
|
Chat += args.AddIfNotPresent;
|
|
}
|
|
|
|
if (args.Input != null)
|
|
{
|
|
if (args.Input.StartsWith('/'))
|
|
Chat = args.Input;
|
|
else
|
|
Chat += args.Input;
|
|
}
|
|
|
|
var (info, reason, target) = (args.ChannelSwitchInfo, args.TellReason, args.TellTarget);
|
|
|
|
if (info.Channel != null)
|
|
{
|
|
var targetChannel = info.Channel;
|
|
if (info.Channel is InputChannel.Tell)
|
|
{
|
|
if (info.Rotate != RotateMode.None)
|
|
{
|
|
var idx =
|
|
Plugin.CurrentTab.CurrentChannel.TempChannel != InputChannel.Tell ? 0
|
|
: info.Rotate == RotateMode.Reverse ? -1
|
|
: 1;
|
|
|
|
var tellInfo = Plugin.Functions.Chat.GetTellHistoryInfo(idx);
|
|
if (tellInfo != null && reason != null)
|
|
Plugin.CurrentTab.CurrentChannel.TempTellTarget = new TellTarget(
|
|
tellInfo.Name,
|
|
(ushort)tellInfo.World,
|
|
tellInfo.ContentId,
|
|
reason.Value
|
|
);
|
|
}
|
|
else
|
|
{
|
|
Plugin.CurrentTab.CurrentChannel.TellTarget = null;
|
|
if (target != null)
|
|
{
|
|
if (info.Permanent)
|
|
{
|
|
Plugin.CurrentTab.CurrentChannel.TellTarget = target;
|
|
}
|
|
else
|
|
{
|
|
Plugin.CurrentTab.CurrentChannel.UseTempChannel = true;
|
|
Plugin.CurrentTab.CurrentChannel.TempTellTarget = target;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Plugin.CurrentTab.CurrentChannel.TellTarget = null;
|
|
}
|
|
|
|
if (
|
|
info.Channel is InputChannel.Linkshell1 or InputChannel.CrossLinkshell1
|
|
&& info.Rotate != RotateMode.None
|
|
)
|
|
{
|
|
var module = UIModule.Instance();
|
|
|
|
// If any of these operations fail, do nothing.
|
|
if (info.Permanent)
|
|
{
|
|
// Rotate using the game's code.
|
|
if (info.Channel == InputChannel.Linkshell1)
|
|
{
|
|
GameFunctions.Chat.RotateLinkshellHistory(info.Rotate);
|
|
targetChannel = info.Channel + (uint)module->LinkshellCycle;
|
|
}
|
|
else
|
|
{
|
|
GameFunctions.Chat.RotateCrossLinkshellHistory(info.Rotate);
|
|
targetChannel = info.Channel + (uint)module->CrossWorldLinkshellCycle;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
targetChannel = GameFunctions.Chat.ResolveTempInputChannel(
|
|
Plugin.CurrentTab.CurrentChannel.TempChannel,
|
|
info.Channel.Value,
|
|
info.Rotate
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
targetChannel == null
|
|
|| !GameFunctions.Chat.IsChannelOrExistingLinkshell(targetChannel.Value)
|
|
)
|
|
{
|
|
_logger.LogWarning(
|
|
$"Channel was set to an invalid value '{targetChannel}', ignoring"
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (info.Permanent)
|
|
{
|
|
SetChannel(targetChannel);
|
|
}
|
|
else
|
|
{
|
|
Plugin.CurrentTab.CurrentChannel.UseTempChannel = true;
|
|
Plugin.CurrentTab.CurrentChannel.TempChannel = targetChannel.Value;
|
|
}
|
|
}
|
|
|
|
if (info.Text != null && Chat.Length == 0)
|
|
Chat = info.Text;
|
|
}
|
|
|
|
private bool IsValidCommand(string command)
|
|
{
|
|
return Plugin.CommandManager.Commands.ContainsKey(command)
|
|
|| AllCommands.ContainsKey(command);
|
|
}
|
|
|
|
private void ClearLog(string command, string arguments)
|
|
{
|
|
switch (arguments)
|
|
{
|
|
case "all":
|
|
Plugin.MessageManager.ClearAllTabs();
|
|
break;
|
|
case "help":
|
|
Plugin.ChatGui.Print("- /clearlog2: clears the active tab's log");
|
|
Plugin.ChatGui.Print(
|
|
"- /clearlog2 all: clears all tabs' logs and the global history"
|
|
);
|
|
Plugin.ChatGui.Print("- /clearlog2 help: shows this help");
|
|
break;
|
|
default:
|
|
if (Plugin.LastTab > -1 && Plugin.LastTab < Plugin.Config.Tabs.Count)
|
|
Plugin.Config.Tabs[Plugin.LastTab].Clear();
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void ToggleChat(string _, string arguments)
|
|
{
|
|
switch (arguments)
|
|
{
|
|
case "hide":
|
|
CurrentHideState = HideState.User;
|
|
_logger.LogTrace("HideState: → User (chat hide command)");
|
|
break;
|
|
case "show":
|
|
CurrentHideState = HideState.None;
|
|
_logger.LogTrace("HideState: → None (chat show command)");
|
|
break;
|
|
case "toggle":
|
|
CurrentHideState = CurrentHideState switch
|
|
{
|
|
HideState.User or HideState.CutsceneOverride => HideState.None,
|
|
HideState.Cutscene => HideState.CutsceneOverride,
|
|
HideState.None => HideState.User,
|
|
_ => CurrentHideState,
|
|
};
|
|
_logger.LogTrace($"HideState: → {CurrentHideState} (chat toggle command)");
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void SetUpTextCommandChannels()
|
|
{
|
|
TextCommandChannels.Clear();
|
|
|
|
foreach (var input in Enum.GetValues<InputChannel>())
|
|
{
|
|
var commands = input.TextCommands();
|
|
if (commands == null)
|
|
continue;
|
|
|
|
var type = input.ToChatType();
|
|
foreach (var command in commands)
|
|
AddTextCommandChannel(command, type);
|
|
}
|
|
|
|
if (Sheets.TextCommandSheet.TryGetRow(116, out var row))
|
|
AddTextCommandChannel(row, ChatType.Echo);
|
|
}
|
|
|
|
private void AddTextCommandChannel(TextCommand command, ChatType type)
|
|
{
|
|
TextCommandChannels[command.Command.ExtractText()] = type;
|
|
TextCommandChannels[command.ShortCommand.ExtractText()] = type;
|
|
TextCommandChannels[command.Alias.ExtractText()] = type;
|
|
TextCommandChannels[command.ShortAlias.ExtractText()] = type;
|
|
}
|
|
|
|
private void SetUpAllCommands()
|
|
{
|
|
foreach (var command in Sheets.TextCommandSheet)
|
|
{
|
|
if (!command.Command.IsEmpty)
|
|
AllCommands.TryAdd(command.Command.ToString(), command);
|
|
|
|
if (!command.ShortCommand.IsEmpty)
|
|
AllCommands.TryAdd(command.ShortCommand.ToString(), command);
|
|
|
|
if (!command.Alias.IsEmpty)
|
|
AllCommands.TryAdd(command.Alias.ToString(), command);
|
|
|
|
if (!command.ShortAlias.IsEmpty)
|
|
AllCommands.TryAdd(command.ShortAlias.ToString(), command);
|
|
}
|
|
}
|
|
|
|
// Delegates to InputHistoryService so pop-out ChatInputBar instances share
|
|
// history. Deduplication lives inside the service.
|
|
private void AddBacklog(string message)
|
|
{
|
|
InputHistoryService.Push(message);
|
|
}
|
|
|
|
private float GetRemainingHeightForMessageLog()
|
|
{
|
|
var lineHeight = ImGui.CalcTextSize("A").Y;
|
|
var height =
|
|
ImGui.GetContentRegionAvail().Y
|
|
- lineHeight * 2
|
|
- ImGui.GetStyle().ItemSpacing.Y
|
|
- ImGui.GetStyle().FramePadding.Y * 2;
|
|
|
|
if (Plugin.Config.PreviewPosition is PreviewPosition.Inside)
|
|
height -= Plugin.InputPreview.PreviewHeight;
|
|
|
|
// Header toolbar height is not subtracted by GetContentRegionAvail automatically
|
|
// (it renders outside the normal layout path), so we subtract it explicitly.
|
|
// The hint banner renders before this block so ImGui already accounts for it.
|
|
height -= ImGui.GetFrameHeightWithSpacing();
|
|
|
|
// 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 <flag>/<item> 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 += "<flag>";
|
|
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 += "<item>";
|
|
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<string, InputChannel> GetValidChannels()
|
|
{
|
|
var channels = new Dictionary<string, InputChannel>();
|
|
foreach (var channel in Enum.GetValues<InputChannel>())
|
|
{
|
|
if (!channel.IsValid())
|
|
continue;
|
|
|
|
var name =
|
|
Sheets
|
|
.LogFilterSheet.FirstOrNull(row => row.LogKind == (byte)channel.ToChatType())
|
|
?.Name.ToString()
|
|
?? channel.ToChatType().Name();
|
|
if (channel.IsLinkshell())
|
|
{
|
|
var lsName = Plugin.Functions.Chat.GetLinkshellName(channel.LinkshellIndex());
|
|
if (string.IsNullOrWhiteSpace(lsName))
|
|
continue;
|
|
|
|
name += $": {lsName}";
|
|
}
|
|
|
|
if (channel.IsCrossLinkshell())
|
|
{
|
|
var lsName = Plugin.Functions.Chat.GetCrossLinkshellName(channel.LinkshellIndex());
|
|
if (string.IsNullOrWhiteSpace(lsName))
|
|
continue;
|
|
|
|
name += $": {lsName}";
|
|
}
|
|
|
|
// Check if the linkshell with this index is registered in
|
|
// the ExtraChat plugin by seeing if the command is
|
|
// registered. The command gets registered only if a
|
|
// linkshell is assigned (and even gets unassigned if the
|
|
// index changes!).
|
|
if (channel.IsExtraChatLinkshell())
|
|
if (!Plugin.CommandManager.Commands.ContainsKey(channel.Prefix()))
|
|
continue;
|
|
|
|
channels.Add(name, channel);
|
|
}
|
|
|
|
return channels;
|
|
}
|
|
|
|
private void DrawChannelName(Tab activeTab)
|
|
{
|
|
// 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<int> BuildSidebarRenderOrder()
|
|
{
|
|
var tabs = Plugin.Config.Tabs;
|
|
var persistent = new List<int>(tabs.Count);
|
|
var pinned = new List<int>();
|
|
var unpinned = new List<int>();
|
|
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<bool> PopOutDocked = [];
|
|
internal readonly HashSet<Guid> PopOutWindows = [];
|
|
|
|
// Live enumeration of active Popout windows for KeybindManager tab-cycle forwarding.
|
|
// Filters on IsOpen to skip closed-but-registered popouts.
|
|
internal IEnumerable<Popout> ActivePopouts =>
|
|
Plugin.WindowSystem.Windows.OfType<Popout>().Where(p => p.IsOpen);
|
|
|
|
private void AddPopOutsToDraw()
|
|
{
|
|
HandlerLender.ResetCounter();
|
|
|
|
if (PopOutDocked.Count != Plugin.Config.Tabs.Count)
|
|
{
|
|
PopOutDocked.Clear();
|
|
PopOutDocked.AddRange(Enumerable.Repeat(false, Plugin.Config.Tabs.Count));
|
|
}
|
|
|
|
for (var i = 0; i < Plugin.Config.Tabs.Count; i++)
|
|
{
|
|
var tab = Plugin.Config.Tabs[i];
|
|
if (!tab.PopOut)
|
|
continue;
|
|
|
|
if (PopOutWindows.Contains(tab.Identifier))
|
|
continue;
|
|
|
|
var window = new Popout(this, tab, i, _loggerFactory.CreateLogger<Popout>());
|
|
|
|
Plugin.WindowSystem.AddWindow(window);
|
|
PopOutWindows.Add(tab.Identifier);
|
|
}
|
|
}
|
|
|
|
private unsafe void DrawAutoComplete()
|
|
{
|
|
if (AutoCompleteInfo == null)
|
|
return;
|
|
|
|
AutoCompleteList ??= AutoTranslate.Matching(
|
|
AutoCompleteInfo.ToComplete,
|
|
Plugin.Config.SortAutoTranslate
|
|
);
|
|
if (AutoCompleteOpen)
|
|
{
|
|
ImGui.OpenPopup(AutoCompleteId);
|
|
AutoCompleteOpen = false;
|
|
}
|
|
|
|
ImGui.SetNextWindowSize(new Vector2(400, 300) * ImGuiHelpers.GlobalScale);
|
|
using var popup = ImRaii.Popup(AutoCompleteId);
|
|
if (!popup.Success)
|
|
{
|
|
if (ActivatePos == -1)
|
|
ActivatePos = AutoCompleteInfo.EndPos;
|
|
|
|
AutoCompleteInfo = null;
|
|
AutoCompleteList = null;
|
|
Activate = true;
|
|
return;
|
|
}
|
|
|
|
ImGui.SetNextItemWidth(-1);
|
|
if (
|
|
ImGui.InputTextWithHint(
|
|
"##auto-complete-filter",
|
|
Language.AutoTranslate_Search_Hint,
|
|
ref AutoCompleteInfo.ToComplete,
|
|
256,
|
|
ImGuiInputTextFlags.CallbackAlways | ImGuiInputTextFlags.CallbackHistory,
|
|
AutoCompleteCallback
|
|
)
|
|
)
|
|
{
|
|
AutoCompleteList = AutoTranslate.Matching(
|
|
AutoCompleteInfo.ToComplete,
|
|
Plugin.Config.SortAutoTranslate
|
|
);
|
|
AutoCompleteSelection = 0;
|
|
AutoCompleteShouldScroll = true;
|
|
}
|
|
|
|
var selected = -1;
|
|
if (ImGui.IsItemActive() && ImGui.GetIO().KeyCtrl)
|
|
{
|
|
for (var i = 0; i < 10 && i < AutoCompleteList.Count; i++)
|
|
{
|
|
var num = (i + 1) % 10;
|
|
var key = ImGuiKey.Key0 + num;
|
|
var key2 = ImGuiKey.Keypad0 + num;
|
|
if (ImGui.IsKeyDown(key) || ImGui.IsKeyDown(key2))
|
|
selected = i;
|
|
}
|
|
}
|
|
|
|
if (ImGui.IsItemDeactivated())
|
|
{
|
|
if (ImGui.IsKeyDown(ImGuiKey.Escape))
|
|
{
|
|
ImGui.CloseCurrentPopup();
|
|
return;
|
|
}
|
|
|
|
var enter = ImGui.IsKeyDown(ImGuiKey.Enter) || ImGui.IsKeyDown(ImGuiKey.KeypadEnter);
|
|
if (AutoCompleteList.Count > 0 && enter)
|
|
selected = AutoCompleteSelection;
|
|
}
|
|
|
|
if (ImGui.IsWindowAppearing())
|
|
{
|
|
FixCursor = true;
|
|
ImGui.SetKeyboardFocusHere(-1);
|
|
}
|
|
|
|
using var child = ImRaii.Child(
|
|
"##auto-complete-list",
|
|
Vector2.Zero,
|
|
false,
|
|
ImGuiWindowFlags.HorizontalScrollbar
|
|
);
|
|
if (!child.Success)
|
|
return;
|
|
|
|
var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper());
|
|
try
|
|
{
|
|
clipper.Begin(AutoCompleteList.Count);
|
|
while (clipper.Step())
|
|
{
|
|
for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++)
|
|
{
|
|
var entry = AutoCompleteList[i];
|
|
|
|
var highlight = AutoCompleteSelection == i;
|
|
var clicked =
|
|
ImGui.Selectable($"{entry.Text}##{entry.Group}/{entry.Row}", highlight)
|
|
|| selected == i;
|
|
if (i < 10)
|
|
{
|
|
var button = (i + 1) % 10;
|
|
var text = string.Format(Language.AutoTranslate_Completion_Key, button);
|
|
var size = ImGui.CalcTextSize(text);
|
|
|
|
ImGui.SameLine(ImGui.GetContentRegionAvail().X - size.X);
|
|
|
|
using (
|
|
ImRaii.PushColor(
|
|
ImGuiCol.Text,
|
|
ImGui.GetStyle().Colors[(int)ImGuiCol.TextDisabled]
|
|
)
|
|
)
|
|
ImGui.TextUnformatted(text);
|
|
}
|
|
|
|
if (!clicked)
|
|
continue;
|
|
|
|
var before = Chat[..AutoCompleteInfo.StartPos];
|
|
var after = Chat[AutoCompleteInfo.EndPos..];
|
|
var replacement = $"<at:{entry.Group},{entry.Row}>";
|
|
Chat = $"{before}{replacement}{after}";
|
|
ImGui.CloseCurrentPopup();
|
|
Activate = true;
|
|
ActivatePos = AutoCompleteInfo.StartPos + replacement.Length;
|
|
}
|
|
}
|
|
|
|
if (!AutoCompleteShouldScroll)
|
|
return;
|
|
|
|
AutoCompleteShouldScroll = false;
|
|
var selectedPos =
|
|
clipper.StartPosY + clipper.ItemsHeight * (AutoCompleteSelection * 1f);
|
|
ImGui.SetScrollFromPosY(selectedPos - ImGui.GetWindowPos().Y);
|
|
}
|
|
finally
|
|
{
|
|
// Destroy frees the unmanaged ImGuiListClipper allocated above; without it the block leaks per render.
|
|
clipper.Destroy();
|
|
}
|
|
}
|
|
|
|
private int AutoCompleteCallback(scoped ref ImGuiInputTextCallbackData data)
|
|
{
|
|
if (FixCursor && AutoCompleteInfo != null)
|
|
{
|
|
FixCursor = false;
|
|
data.CursorPos = AutoCompleteInfo.ToComplete.Length;
|
|
data.SelectionStart = data.SelectionEnd = data.CursorPos;
|
|
}
|
|
|
|
if (AutoCompleteList == null)
|
|
return 0;
|
|
|
|
switch (data.EventKey)
|
|
{
|
|
case ImGuiKey.UpArrow:
|
|
if (AutoCompleteSelection == 0)
|
|
AutoCompleteSelection = AutoCompleteList.Count - 1;
|
|
else
|
|
AutoCompleteSelection--;
|
|
|
|
AutoCompleteShouldScroll = true;
|
|
return 1;
|
|
case ImGuiKey.DownArrow:
|
|
if (AutoCompleteSelection == AutoCompleteList.Count - 1)
|
|
AutoCompleteSelection = 0;
|
|
else
|
|
AutoCompleteSelection++;
|
|
|
|
AutoCompleteShouldScroll = true;
|
|
return 1;
|
|
default:
|
|
if (ImGui.IsKeyPressed(ImGuiKey.Tab))
|
|
{
|
|
if (AutoCompleteSelection == AutoCompleteList.Count - 1)
|
|
AutoCompleteSelection = 0;
|
|
else
|
|
AutoCompleteSelection++;
|
|
|
|
AutoCompleteShouldScroll = true;
|
|
return 1;
|
|
}
|
|
break;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
private unsafe int Callback(scoped ref ImGuiInputTextCallbackData data)
|
|
{
|
|
// We play the opening sound here only if closing sound has been played before
|
|
if (Plugin.Config.PlaySounds && PlayedClosingSound)
|
|
{
|
|
PlayedClosingSound = false;
|
|
UIGlobals.PlaySoundEffect(ChatOpenSfx);
|
|
}
|
|
|
|
// Set the cursor pos to the user selected
|
|
if (Plugin.InputPreview.SelectedCursorPos != -1)
|
|
data.CursorPos = Plugin.InputPreview.SelectedCursorPos;
|
|
Plugin.InputPreview.SelectedCursorPos = -1;
|
|
|
|
CursorPos = data.CursorPos;
|
|
if (data.EventFlag == ImGuiInputTextFlags.CallbackCompletion)
|
|
{
|
|
if (data.CursorPos == 0)
|
|
{
|
|
AutoCompleteInfo = new AutoCompleteInfo(
|
|
string.Empty,
|
|
data.CursorPos,
|
|
data.CursorPos
|
|
);
|
|
AutoCompleteOpen = true;
|
|
AutoCompleteSelection = 0;
|
|
|
|
return 0;
|
|
}
|
|
|
|
int white;
|
|
for (white = data.CursorPos - 1; white >= 0; white--)
|
|
if (data.Buf[white] == ' ')
|
|
break;
|
|
|
|
var start = data.Buf + white + 1;
|
|
var end = data.CursorPos - white - 1;
|
|
var utf8Message = Marshal.PtrToStringUTF8((nint)start, end);
|
|
var correctedCursor = data.CursorPos - (end - utf8Message.Length);
|
|
AutoCompleteInfo = new AutoCompleteInfo(utf8Message, white + 1, correctedCursor);
|
|
AutoCompleteOpen = true;
|
|
AutoCompleteSelection = 0;
|
|
return 0;
|
|
}
|
|
|
|
if (data.EventFlag == ImGuiInputTextFlags.CallbackCharFilter)
|
|
if (!Plugin.Functions.Chat.IsCharValid((char)data.EventChar))
|
|
return 1;
|
|
|
|
if (Activate)
|
|
{
|
|
Activate = false;
|
|
data.CursorPos = ActivatePos > -1 ? ActivatePos : Chat.Length;
|
|
data.SelectionStart = data.SelectionEnd = data.CursorPos;
|
|
ActivatePos = -1;
|
|
}
|
|
|
|
Plugin.CommandHelpWindow.IsOpen = false;
|
|
var text = MemoryHelper.ReadString((nint)data.Buf, data.BufTextLen);
|
|
if (text.StartsWith('/'))
|
|
{
|
|
var command = text.Split(' ')[0];
|
|
if (AllCommands.TryGetValue(command, out var textCommand))
|
|
Plugin.CommandHelpWindow.UpdateContent(textCommand.Description);
|
|
else if (
|
|
Plugin.CommandManager.Commands.TryGetValue(command, out var info) && info.ShowInHelp
|
|
)
|
|
Plugin.CommandHelpWindow.UpdateContent(info.HelpMessage);
|
|
}
|
|
|
|
if (data.EventFlag != ImGuiInputTextFlags.CallbackHistory)
|
|
return 0;
|
|
|
|
var prevPos = InputBacklogIdx;
|
|
switch (data.EventKey)
|
|
{
|
|
case ImGuiKey.UpArrow:
|
|
switch (InputBacklogIdx)
|
|
{
|
|
case -1:
|
|
var offset = 0;
|
|
|
|
if (!string.IsNullOrWhiteSpace(Chat))
|
|
{
|
|
AddBacklog(Chat);
|
|
offset = 1;
|
|
}
|
|
|
|
InputBacklogIdx = InputHistoryService.Count - 1 - offset;
|
|
break;
|
|
case > 0:
|
|
InputBacklogIdx--;
|
|
break;
|
|
}
|
|
break;
|
|
case ImGuiKey.DownArrow:
|
|
if (InputBacklogIdx != -1)
|
|
if (++InputBacklogIdx >= InputHistoryService.Count)
|
|
InputBacklogIdx = -1;
|
|
break;
|
|
}
|
|
|
|
if (prevPos == InputBacklogIdx)
|
|
return 0;
|
|
|
|
var historyStr = InputHistoryService.GetByCursor(InputBacklogIdx) ?? string.Empty;
|
|
data.DeleteChars(0, data.BufTextLen);
|
|
data.InsertChars(0, historyStr);
|
|
|
|
return 0;
|
|
}
|
|
|
|
internal void DrawChunks(
|
|
IReadOnlyList<Chunk> chunks,
|
|
bool wrap = true,
|
|
PayloadHandler? handler = null,
|
|
float lineWidth = 0f
|
|
)
|
|
{
|
|
// 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.
|
|
}
|
|
}
|