Reported by Carla: a tell arriving while you are typing pulls the focus away. The interruption is the visible half. The sharp half is that the input buffer belongs to the WINDOW while the send target is read off whatever tab is active at Enter -- so a line typed at one person could leave addressed to whoever just wrote, and in this game losing the keyboard means the next sentence walks the character around. Nothing is revealed now while any chat surface is mid-sentence, in any mode. The tab still appears and still carries its unread mark. The check lives in its own file because the answer has to be identical everywhere: it started inside the reveal plan, and a second pop-out path walked straight past it -- AutoTellTabsService opened windows off its own flag, at tab creation, a tick before the router was ever asked. Those two paths are one now. AutoTellTabsOpenAsPopout and TellAutoOpenMode were two settings for one decision, and the older one won every race, which is why the other looked inert. Config schema 28 carries the old flag forward so nobody's behaviour changes. "Off" went with it: it never stopped the tab from being created -- that is the auto-tell switch -- it only stopped the jump to it, which is what the switch below it does. Also in here, all from the same corner of the code: - Closing a tab was lost in the v2.0.0 rebuild. The trash entry lived in the retired ChatLogWindow menu, and the rebuilt one restored rename, sound, pop-out and pinning but not this. For tell tabs that left no way out at all: IsEditable keeps them out of the settings editor on purpose and points at the context menu, which could not close them either. Pinned tell tabs stay disabled with a tooltip rather than absent. - Re-anchoring the active tab used an unconditional Tabs[0] in three places, and Tabs[0] can be popped out -- so it ran OnTabActivated over a tab live in its own window and stripped its tell binding. With every tab popped, the seed and the re-anchor also fought each other every frame. - PinTab_LimitReached still pointed at "Promote to permanent", removed in May. Spanish said "Desija", which is not a word; Greek left "tell tabs" untranslated; pt-PT broke its own unpin verb. - Pop Out was a hardcoded English literal despite the key existing in all 25 languages since v1.5.6, and the tell-open modes were the last English display names in the plugin. - Segmented setting rows measured 200px flat, which cut German labels in half. They size to their longest label now. - Metrics.Scale still called GlobalScaleSafe. It is an alias for GlobalScale in current Dalamud, and dropping it clears the last compiler warning in the project.
332 lines
12 KiB
C#
332 lines
12 KiB
C#
using Dalamud.Bindings.ImGui;
|
|
using Dalamud.Interface;
|
|
using Dalamud.Interface.Utility;
|
|
using Dalamud.Interface.Utility.Raii;
|
|
using FFXIVClientStructs.FFXIV.Client.UI;
|
|
using HellionChat.Resources;
|
|
using HellionChat.Util;
|
|
|
|
namespace HellionChat.Ui.Components;
|
|
|
|
// Shared right-click menu for both tab layouts (Sidebar rows + TopTabBar). One
|
|
// source of truth instead of two divergent inline blocks. Static: it has no own
|
|
// state and reaches the live Config/Plugin through Plugin.Instance/Plugin.Config.
|
|
internal static class TabContextMenu
|
|
{
|
|
// Pending rename, scoped to one tab. ImGui never re-submits the input when the
|
|
// popup is dismissed by clicking outside, so IsItemDeactivatedAfterEdit never
|
|
// fires there — without this the rename would be lost.
|
|
private static Guid _renamingTab;
|
|
private static bool _renameDirty;
|
|
|
|
// MUST be called immediately after the row-carrying ImGui item (Sidebar
|
|
// "row" InvisibleButton / TopTabBar Selectable). popupId only names the
|
|
// popup; the open trigger is a right-click on the LAST submitted item
|
|
// (g.LastItemData via IsItemHovered) — any interactive item in between
|
|
// would steal the trigger. Only DrawList ops may sit between.
|
|
public static void Draw(
|
|
Tab tab,
|
|
string popupId,
|
|
Windows.ChannelPopoutPool pool,
|
|
IReadOnlyList<Tab> tabs
|
|
)
|
|
{
|
|
if (!ImGui.BeginPopupContextItem(popupId))
|
|
{
|
|
// Popup gone: flush a pending rename. Scoped to the OWNING tab — every
|
|
// other tab's Draw lands here too and would flush foreign state.
|
|
if (_renamingTab == tab.Identifier)
|
|
{
|
|
if (_renameDirty)
|
|
Plugin.Instance.SaveConfig();
|
|
ClearPendingRename();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// The sidebar pushes ItemSpacing to zero so its rows sit flush, and style
|
|
// vars are a global stack the popup inherits. Reading GetStyle() here
|
|
// would read that zero back, so the popup sets its own spacing outright
|
|
// -- including X, which HelpMarker's SameLine depends on.
|
|
//
|
|
// Scoped block, not a `using var`: that would pop after EndPopup, and
|
|
// ImGui asserts when a popup closes with a style var still on the stack.
|
|
using (
|
|
ImRaii.PushStyle(
|
|
ImGuiStyleVar.ItemSpacing,
|
|
new System.Numerics.Vector2(8f, 4f) * Ui.StyleEngine.Metrics.Scale
|
|
)
|
|
)
|
|
{
|
|
DrawBody(tab, pool, tabs);
|
|
}
|
|
|
|
ImGui.EndPopup();
|
|
}
|
|
|
|
private static void DrawBody(Tab tab, Windows.ChannelPopoutPool pool, IReadOnlyList<Tab> tabs)
|
|
{
|
|
// Rename: focus the field the first frame the popup appears.
|
|
if (ImGui.IsWindowAppearing())
|
|
ImGui.SetKeyboardFocusHere();
|
|
ImGui.SetNextItemWidth(250f * ImGuiHelpers.GlobalScale);
|
|
var name = tab.Name;
|
|
if (ImGui.InputText("##tab-name", ref name, 512) && ApplyTabRename(tab, name))
|
|
{
|
|
_renamingTab = tab.Identifier;
|
|
_renameDirty = true;
|
|
}
|
|
|
|
// Covers leaving the field while the popup stays open; the dismissed-popup
|
|
// case is handled above.
|
|
if (ImGui.IsItemDeactivatedAfterEdit() && _renamingTab == tab.Identifier)
|
|
{
|
|
if (_renameDirty)
|
|
Plugin.Instance.SaveConfig();
|
|
ClearPendingRename();
|
|
}
|
|
|
|
// Per-tab notification sound. The checkbox gates the picker so
|
|
// tabs that never want a sound keep the popup short.
|
|
if (
|
|
ImGui.Checkbox(
|
|
HellionStrings.Tabs_NotificationSound_Enable_Name,
|
|
ref tab.EnableNotificationSound
|
|
)
|
|
)
|
|
Plugin.Instance.SaveConfig();
|
|
ImGuiUtil.HelpMarker(HellionStrings.Tabs_NotificationSound_Description);
|
|
if (tab.EnableNotificationSound)
|
|
DrawSoundPicker(tab);
|
|
|
|
if (ImGui.MenuItem(Language.ChatLog_Tabs_PopOut))
|
|
pool.TryOpen(tab);
|
|
|
|
// One separator for the whole lifecycle block below, so a normal tab
|
|
// (no pin controls) still gets the rule above its close entry.
|
|
ImGui.Separator();
|
|
DrawPinControls(tab);
|
|
DrawCloseControl(tab, tabs, pool);
|
|
}
|
|
|
|
// Pinning has been complete since v1.4.7 -- pools, cap, persistence, logout
|
|
// symmetry, the notification -- and has had no way in since the menu that
|
|
// called it was removed.
|
|
//
|
|
// That left a dead end in saved data, which is the real reason this is here:
|
|
// a tab pinned in v1.5.6 survives every save and load, permanently occupying
|
|
// one of five pool slots, with nothing anywhere to release it.
|
|
//
|
|
// Promote-to-permanent deliberately does not come back. It was removed on
|
|
// purpose after a tester kept hitting it by accident, and reconnecting every
|
|
// caller-less method without asking why it lost its caller would rebuild the
|
|
// problem.
|
|
private static void DrawPinControls(Tab tab)
|
|
{
|
|
if (!tab.IsTempTab)
|
|
return;
|
|
|
|
// Instance property today, not the static the old menu reached for.
|
|
var service = Plugin.Instance.AutoTellTabsService;
|
|
if (service is null)
|
|
return;
|
|
|
|
if (tab.IsPinned)
|
|
{
|
|
if (ImGui.MenuItem(HellionStrings.PinTab_MenuUnpin))
|
|
{
|
|
service.Unpin(tab);
|
|
ImGui.CloseCurrentPopup();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
var atCap = service.PinnedTempTabCount >= AutoTellTabsService.MaxPinnedTempTabs;
|
|
|
|
// Disabled rather than absent: the cap is a state the user can undo by
|
|
// unpinning something, and the tooltip below is what says so.
|
|
if (ImGui.MenuItem(HellionStrings.PinTab_MenuPin, enabled: !atCap) && service.TryPin(tab))
|
|
ImGui.CloseCurrentPopup();
|
|
|
|
if (!ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
|
|
return;
|
|
|
|
ImGuiUtil.Tooltip(
|
|
atCap
|
|
? string.Format(
|
|
HellionStrings.PinTab_LimitReached,
|
|
AutoTellTabsService.MaxPinnedTempTabs
|
|
)
|
|
: HellionStrings.PinTab_PinTooltip
|
|
);
|
|
}
|
|
|
|
// Closing a tab was lost in the v2.0.0 window rebuild: the trash entry lived
|
|
// in ChatLogWindow's menu, which cf4705e retired, and the rebuilt menu only
|
|
// restored rename, sound, pop-out and pinning.
|
|
//
|
|
// For tell tabs that left no way out at all. TabLifecycleHelpers.IsEditable
|
|
// keeps temp tabs out of the settings editor on purpose and says the context
|
|
// menu is where their gestures live -- so the editor was pointing at a menu
|
|
// that could not close them either.
|
|
//
|
|
// Blocked states stay visible and disabled rather than absent: both are
|
|
// states the user can undo, and the tooltip is what says how.
|
|
private static void DrawCloseControl(
|
|
Tab tab,
|
|
IReadOnlyList<Tab> tabs,
|
|
Windows.ChannelPopoutPool pool
|
|
)
|
|
{
|
|
var closeability = TabLifecycleHelpers.GetCloseability(tab, tabs);
|
|
var allowed = closeability == TabLifecycleHelpers.TabCloseability.Allowed;
|
|
|
|
// A tell tab is closed, a layout tab is deleted. Same gesture, different
|
|
// promise: the conversation goes on without its tab, the layout entry does not.
|
|
var label = tab.IsTempTab
|
|
? HellionStrings.Tabs_Close_MenuItem
|
|
: Language.ChatLog_Tabs_Delete;
|
|
|
|
if (ImGui.MenuItem(label, enabled: allowed) && allowed)
|
|
{
|
|
CloseTab(tab, pool);
|
|
ImGui.CloseCurrentPopup();
|
|
return;
|
|
}
|
|
|
|
if (allowed || !ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
|
|
return;
|
|
|
|
ImGuiUtil.Tooltip(
|
|
closeability == TabLifecycleHelpers.TabCloseability.BlockedByPin
|
|
? HellionStrings.Tabs_Close_UnpinFirst
|
|
: HellionStrings.Tabs_Close_LastTab
|
|
);
|
|
}
|
|
|
|
// Removal order mirrors TabEditor.Delete, which already does this from a draw
|
|
// frame: drop the tab, release the pool slot bound to its identifier, then
|
|
// re-anchor the main window if this was the active tab. Safe here because the
|
|
// strip iterates a frame snapshot, not the live list, and TryClose only
|
|
// releases a fixed slot instead of mutating a window collection.
|
|
private static void CloseTab(Tab tab, Windows.ChannelPopoutPool pool)
|
|
{
|
|
// Draw will never run for this tab again, so a pending rename can no
|
|
// longer flush -- and leaving the guard armed would make the NEXT tab's
|
|
// Draw see a stale owner. Drop it before the tab goes.
|
|
if (_renamingTab == tab.Identifier)
|
|
ClearPendingRename();
|
|
|
|
lock (Plugin.Instance.TabsListLock)
|
|
Plugin.Config.Tabs.RemoveAll(t => t.Identifier == tab.Identifier);
|
|
|
|
pool.TryClose(tab.Identifier);
|
|
Plugin.Instance.MainWindow?.ResetActiveTabIfRemoved(tab);
|
|
Plugin.Instance.SaveConfig();
|
|
}
|
|
|
|
// The flush depends on Draw running once more for this tab. If it never does —
|
|
// LRU eviction, logout, window closed or collapsed, plugin unload, game exit —
|
|
// the name only lives in memory until some other SaveConfig happens to run.
|
|
private static void ClearPendingRename()
|
|
{
|
|
_renamingTab = Guid.Empty;
|
|
_renameDirty = false;
|
|
}
|
|
|
|
// Sound picker: 16 numbered game sounds, a separator, then the 3 bundled
|
|
// Hellion clips stored as ids 17-19 (1.5.6 parity order). The collapsed
|
|
// preview reuses the entry label scheme so the current pick reads the same
|
|
// open or closed.
|
|
private static void DrawSoundPicker(Tab tab)
|
|
{
|
|
var preview =
|
|
tab.NotificationSoundId <= 16
|
|
? $"{HellionStrings.Tabs_NotificationSound_Option} {tab.NotificationSoundId}"
|
|
: $"{HellionStrings.Tabs_NotificationSound_CustomOption} {tab.NotificationSoundId - 16}";
|
|
using (
|
|
var combo = ImGuiUtil.BeginComboVertical(
|
|
HellionStrings.Tabs_NotificationSound_Option,
|
|
preview
|
|
)
|
|
)
|
|
{
|
|
if (combo.Success)
|
|
{
|
|
for (uint s = 1; s <= 16; s++)
|
|
{
|
|
if (
|
|
ImGui.Selectable(
|
|
$"{HellionStrings.Tabs_NotificationSound_Option} {s}",
|
|
tab.NotificationSoundId == s
|
|
)
|
|
)
|
|
{
|
|
tab.NotificationSoundId = s;
|
|
Plugin.Instance.SaveConfig();
|
|
}
|
|
}
|
|
|
|
ImGui.Separator();
|
|
|
|
for (uint n = 1; n <= 3; n++)
|
|
{
|
|
var customId = 16 + n;
|
|
if (
|
|
ImGui.Selectable(
|
|
$"{HellionStrings.Tabs_NotificationSound_CustomOption} {n}",
|
|
tab.NotificationSoundId == customId
|
|
)
|
|
)
|
|
{
|
|
tab.NotificationSoundId = customId;
|
|
Plugin.Instance.SaveConfig();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
ImGuiUtil.IconButton(
|
|
FontAwesomeIcon.Play,
|
|
"tab-sound-preview",
|
|
HellionStrings.Tabs_NotificationSound_Preview
|
|
)
|
|
)
|
|
PreviewSound(tab.NotificationSoundId);
|
|
}
|
|
|
|
// Preview: 1-16 are game UI sounds (must hit the framework thread); 17+ are
|
|
// custom NAudio clips (own playback thread). Open range >= 17 (not 17-19); the
|
|
// 3-clip ceiling is guarded inside CustomAudioPlayer.
|
|
private static void PreviewSound(uint id)
|
|
{
|
|
if (id is >= 1 and <= 16)
|
|
{
|
|
Plugin.Framework.RunOnFrameworkThread(() =>
|
|
{
|
|
unsafe
|
|
{
|
|
UIGlobals.PlaySoundEffect(id);
|
|
}
|
|
});
|
|
}
|
|
else if (id >= 17)
|
|
{
|
|
Plugin.Instance.CustomAudioPlayer.Play((int)id - 16, Plugin.Config.CustomSoundVolume);
|
|
}
|
|
}
|
|
|
|
// Factored out so the SelfTest drives the real rename path, not a field poke.
|
|
// Returns true when the name actually changed (gates the SaveConfig write).
|
|
internal static bool ApplyTabRename(Tab tab, string newName)
|
|
{
|
|
if (string.IsNullOrEmpty(newName) || newName == tab.Name)
|
|
return false;
|
|
tab.Name = newName;
|
|
return true;
|
|
}
|
|
}
|