From a6a93ed24141614f9c29e1ed6bc521c94e58335f Mon Sep 17 00:00:00 2001 From: Keda Date: Sat, 15 Nov 2025 00:17:39 +0100 Subject: [PATCH] Add typing state IPC integration for enhanced Chat2 input handling --- ChatTwo/Ipc/TypingIpc.cs | 65 +++++++++++++++++++++++++++++++++++++ ChatTwo/Plugin.cs | 12 +++++++ ChatTwo/Ui/ChatLogWindow.cs | 20 ++++++++++-- ipc.md | 57 ++++++++++++++++++++++++++++++-- 4 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 ChatTwo/Ipc/TypingIpc.cs diff --git a/ChatTwo/Ipc/TypingIpc.cs b/ChatTwo/Ipc/TypingIpc.cs new file mode 100644 index 0000000..73799c0 --- /dev/null +++ b/ChatTwo/Ipc/TypingIpc.cs @@ -0,0 +1,65 @@ +using ChatTwo.Code; +using Dalamud.Plugin.Ipc; + +namespace ChatTwo.Ipc; + +using ChatInputState = (bool InputVisible, bool InputFocused, bool HasText, bool IsTyping, int TextLength, ChatType ChannelType); + +internal sealed class TypingIpc : IDisposable +{ + private Plugin Plugin { get; } + + private ICallGateProvider StateQueryGate { get; } + private ICallGateProvider StateChangedGate { get; } + + private ChatInputState LastState; + private bool HasState; + + internal TypingIpc(Plugin plugin) + { + Plugin = plugin; + + StateQueryGate = Plugin.Interface.GetIpcProvider("ChatTwo.GetChatInputState"); + StateChangedGate = Plugin.Interface.GetIpcProvider("ChatTwo.ChatInputStateChanged"); + + StateQueryGate.RegisterFunc(GetState); + } + + private ChatInputState BuildState() + { + var log = Plugin.ChatLogWindow; + var chat = log.Chat ?? string.Empty; + var hasText = !string.IsNullOrWhiteSpace(chat); + var usedChannel = Plugin.CurrentTab?.CurrentChannel; + var inputChannel = usedChannel is not null + ? (usedChannel.UseTempChannel ? usedChannel.TempChannel : usedChannel.Channel) + : InputChannel.Invalid; + var channelType = inputChannel.ToChatType(); + + return (InputVisible: !log.IsHidden, + InputFocused: log.InputFocused, + HasText: hasText, + IsTyping: log.InputFocused && hasText, + TextLength: chat.Length, + ChannelType: channelType); + } + + private ChatInputState GetState() + => BuildState(); + + internal void Update() + { + var state = BuildState(); + if (HasState && state.Equals(LastState)) + return; + + HasState = true; + LastState = state; + StateChangedGate.SendMessage(state); + } + + public void Dispose() + { + StateQueryGate.UnregisterFunc(); + } +} diff --git a/ChatTwo/Plugin.cs b/ChatTwo/Plugin.cs index 14843c8..7a6aaf5 100755 --- a/ChatTwo/Plugin.cs +++ b/ChatTwo/Plugin.cs @@ -56,6 +56,7 @@ public sealed class Plugin : IDalamudPlugin internal MessageManager MessageManager { get; } internal IpcManager Ipc { get; } internal ExtraChat ExtraChat { get; } + internal TypingIpc TypingIpc { get; } internal FontManager FontManager { get; } internal ServerCore ServerCore { get; } @@ -97,6 +98,7 @@ public sealed class Plugin : IDalamudPlugin Commands = new Commands(this); Functions = new GameFunctions.GameFunctions(this); Ipc = new IpcManager(); + TypingIpc = new TypingIpc(this); ExtraChat = new ExtraChat(this); FontManager = new FontManager(); @@ -181,6 +183,7 @@ public sealed class Plugin : IDalamudPlugin DebuggerWindow?.Dispose(); SeStringDebugger?.Dispose(); + TypingIpc?.Dispose(); ExtraChat?.Dispose(); Ipc?.Dispose(); MessageManager?.DisposeAsync().AsTask().Wait(); @@ -193,8 +196,14 @@ public sealed class Plugin : IDalamudPlugin private void Draw() { + ChatLogWindow.BeginFrame(); + if (Config.HideInLoadingScreens && Condition[ConditionFlag.BetweenAreas]) + { + ChatLogWindow.FinalizeFrame(); + TypingIpc?.Update(); return; + } ChatLogWindow.HideStateCheck(); @@ -205,6 +214,9 @@ public sealed class Plugin : IDalamudPlugin { WindowSystem.Draw(); } + + ChatLogWindow.FinalizeFrame(); + TypingIpc?.Update(); } internal void SaveConfig() diff --git a/ChatTwo/Ui/ChatLogWindow.cs b/ChatTwo/Ui/ChatLogWindow.cs index 1494949..d16c245 100644 --- a/ChatTwo/Ui/ChatLogWindow.cs +++ b/ChatTwo/Ui/ChatLogWindow.cs @@ -40,6 +40,7 @@ public sealed class ChatLogWindow : Window internal bool FocusedPreview; internal bool Activate; + internal bool InputFocused { get; private set; } private int ActivatePos = -1; internal string Chat = string.Empty; private readonly List InputBacklog = []; @@ -72,6 +73,7 @@ public sealed class ChatLogWindow : Window private const uint ChatOpenSfx = 35u; private const uint ChatCloseSfx = 3u; private bool PlayedClosingSound = true; + private bool DrewThisFrame; private long FrameTime; // set every frame internal long LastActivityTime = Environment.TickCount64; @@ -430,6 +432,17 @@ public sealed class ChatLogWindow : Window 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; @@ -500,6 +513,7 @@ public sealed class ChatLogWindow : Window public override void Draw() { + DrewThisFrame = true; try { DrawChatLog(); @@ -618,6 +632,8 @@ public sealed class ChatLogWindow : Window 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()) @@ -655,14 +671,14 @@ public sealed class ChatLogWindow : Window } // Process keybinds that have modifiers while the chat is focused. - if (ImGui.IsItemActive()) + 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 && !ImGui.IsItemActive() && AutoCompleteInfo == null) + if (!Activate && !inputActive && AutoCompleteInfo == null) { if (Plugin.Config.PlaySounds && !PlayedClosingSound) { diff --git a/ipc.md b/ipc.md index 33c5521..94aeb51 100755 --- a/ipc.md +++ b/ipc.md @@ -35,11 +35,11 @@ public class ContextMenuIntegration { this.Available.Subscribe(() => this.Register()); // Register if Chat 2 is already loaded. this.Register(); - + // Listen for context menu events. this.Invoke.Subscribe(this.Integration); } - + private void Register() { // Register and save the registration ID. this._id = this.Register.InvokeFunc(); @@ -73,4 +73,57 @@ public class ContextMenuIntegration { } ``` +# Typing State IPC + +If you need to know whether the player is currently interacting with Chat 2's +input box, subscribe to the typing IPC. +- `ChatTwo.GetChatInputState`: call this function to retrieve the current state. +- `ChatTwo.ChatInputStateChanged`: subscribe to this event to receive updates + whenever the state changes (and once immediately after subscribing). +Both IPC endpoints use the same tuple payload: +``` +(bool InputVisible, bool InputFocused, bool HasText, bool IsTyping, int TextLength, ChatType ChannelType) +``` +- `InputVisible`: `true` when Chat 2 is not hidden by user/cutscene/battle + settings. +- `InputFocused`: `true` while the Chat 2 input box currently has keyboard focus. +- `HasText`: `true` when the input buffer contains more than whitespace. +- `IsTyping`: convenience flag (`InputFocused && HasText`). +- `TextLength`: length of the raw input buffer. +- `ChannelType`: the `ChatTwo.Code.ChatType` representing the channel/mode that + will be used if the buffer is submitted. This value comes from the current + tab's `UsedChannel` (`ChatTwo/Configuration.cs`) which the plugin keeps in + sync by hooking the in-game shell (`ChatTwo/GameFunctions/Chat.cs`) and by + resolving temporary overrides inside the chat UI + (`ChatTwo/Ui/ChatLogWindow.cs:597`). `InputChannel` values are converted into + the exported `ChatType` via `ChatTwo/Code/InputChannelExt.ToChatType`. +Example usage: +```cs +public sealed class TypingIntegration { + private ICallGateSubscriber<(bool InputVisible, bool InputFocused, bool HasText, bool IsTyping, int TextLength, ChatType ChannelType)> GetChatInputState { get; } + private ICallGateSubscriber<(bool InputVisible, bool InputFocused, bool HasText, bool IsTyping, int TextLength, ChatType ChannelType)> ChatInputStateChanged { get; } + public TypingIntegration(DalamudPluginInterface @interface) { + this.GetChatInputState = @interface.GetIpcSubscriber<(bool, bool, bool, bool, int, ChatType)>("ChatTwo.GetChatInputState"); + this.ChatInputStateChanged = @interface.GetIpcSubscriber<(bool, bool, bool, bool, int, ChatType)>("ChatTwo.ChatInputStateChanged"); + } + public void Enable() { + this.ChatInputStateChanged.Subscribe(OnChatInputStateChanged); + // Optionally poll the current state on enable. + var state = this.GetChatInputState.InvokeFunc(); + PluginLog.Information($"Initial typing state: {state}"); + } + public void Disable() { + this.ChatInputStateChanged.Unsubscribe(OnChatInputStateChanged); + } + + private void OnChatInputStateChanged((bool InputVisible, bool InputFocused, bool HasText, bool IsTyping, int TextLength, ChatType ChannelType) state) { + if (state.IsTyping) { + // Show typing indicator. + } else { + // Hide typing indicator. + } + } +} +``` + All integrations are called inside of an ImGui `BeginMenu`.