Files
HellionChat/HellionChat/Ui/Components/TabContextMenu.cs
T
JonKazama-Hellion 16557213cd chore: comments, second pass -- the task codes the first pass missed
The first sweep matched a character class that swallowed the digit, so a bare
B1 slipped through while B1-2 was caught. Searching the whole A-Z space instead
of guessing prefixes turned up 130-odd more: B0 through B6, C2, C3, D1, H2, M6,
P7, P8, T2, W2, plus GP-04, KB-01, OD-1, PM-1, PM-3, SEC-01, TR-4, TR-7, UI-11,
UI-12, XC-8 and API-3.

Kept deliberately: 41 B4 01 is a byte signature, "N0" a format string,
#L119-L128 a source anchor, LS4/LS6 are linkshells, and A=FF B=0C G=41 R=C2
explains a colour-channel order. Those look like codes and are not.

Also translated the eight German comments left in the theme files and
ImGuiUtil. Seven of them described what a palette does to which channel, which
is worth reading -- just not in a second language in an otherwise English
codebase.
2026-08-19 22:03:12 +02:00

261 lines
9.4 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)
{
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);
}
ImGui.EndPopup();
}
private static void DrawBody(Tab tab, Windows.ChannelPopoutPool pool)
{
// 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("Pop Out"))
pool.TryOpen(tab);
DrawPinControls(tab);
}
// 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;
ImGui.Separator();
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
);
}
// 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;
}
}