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.
261 lines
9.4 KiB
C#
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;
|
|
}
|
|
}
|