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.
362 lines
15 KiB
C#
362 lines
15 KiB
C#
using HellionChat.Code;
|
|
using HellionChat.GameFunctions.Types;
|
|
|
|
namespace HellionChat.Util;
|
|
|
|
// Pure predicates for the TempTab pin lifecycle. Extracted from the strip
|
|
// sites in Plugin.cs and Configuration.cs so they stay in lockstep — a
|
|
// load-time strip that disagrees with the save-time strip is exactly how
|
|
// pinned tabs would silently fall out of the JSON.
|
|
internal static class TabLifecycleHelpers
|
|
{
|
|
public static bool IsInUnpinnedPool(Tab t) => t.IsTempTab && !t.IsPinned;
|
|
|
|
public static bool IsInPinnedPool(Tab t) => t.IsTempTab && t.IsPinned;
|
|
|
|
// A temp tab belongs to its conversation, not to the user's layout. It is
|
|
// created and dropped by the auto-tell service, its name is the partner, and
|
|
// deleting it in an editor would be deleting a conversation. Pinning is the
|
|
// only editing gesture it accepts, and that lives in the context menu.
|
|
public static bool IsEditable(Tab t) => !t.IsTempTab;
|
|
|
|
// Where a new tab lands: after the last editable one, so it never appears
|
|
// among the temp tabs at the bottom of the list.
|
|
public static int InsertIndexForNewTab(IReadOnlyList<Tab> tabs)
|
|
{
|
|
for (var i = tabs.Count - 1; i >= 0; i--)
|
|
if (IsEditable(tabs[i]))
|
|
return i + 1;
|
|
return 0;
|
|
}
|
|
|
|
// Where an editable tab ends up when the user moves it by one step.
|
|
//
|
|
// Steps over temp tabs rather than swapping with them: a swap would push a
|
|
// conversation into the middle of the layout. Reversible in the editable
|
|
// order, which is the order the user sees -- the temp tab keeps its slot in
|
|
// the collection and the sidebar draws it under its own section header
|
|
// anyway. Returns the original index when there is no editable neighbour in
|
|
// that direction, which the caller reads as "no move".
|
|
public static int MoveIndex(IReadOnlyList<Tab> tabs, int index, int delta)
|
|
{
|
|
if (index < 0 || index >= tabs.Count || delta == 0)
|
|
return index;
|
|
if (!IsEditable(tabs[index]))
|
|
return index;
|
|
|
|
var step = Math.Sign(delta);
|
|
for (var i = index + step; i >= 0 && i < tabs.Count; i += step)
|
|
if (IsEditable(tabs[i]))
|
|
return i;
|
|
|
|
return index;
|
|
}
|
|
|
|
// Deleting the last editable tab leaves a window with nothing to draw, and
|
|
// the message list has no empty state. The editor offers templates instead
|
|
// of a delete in that situation.
|
|
public static bool CanDelete(IReadOnlyList<Tab> tabs, int index)
|
|
{
|
|
if (index < 0 || index >= tabs.Count || !IsEditable(tabs[index]))
|
|
return false;
|
|
|
|
var editable = 0;
|
|
foreach (var tab in tabs)
|
|
if (IsEditable(tab))
|
|
editable++;
|
|
|
|
return editable > 1;
|
|
}
|
|
|
|
// Why a tab may or may not be closed from the tab strip's context menu.
|
|
// The menu needs the REASON, not just a bool: a blocked close stays visible
|
|
// and disabled with the matching tooltip, because both blocks are states the
|
|
// user can undo (unpin the tab / create a second one).
|
|
internal enum TabCloseability
|
|
{
|
|
Allowed,
|
|
|
|
// Temp tab is pinned. Pinning is the "keep this" gesture, so closing
|
|
// takes the deliberate two-step (Flo, 2026-08-22) rather than undoing
|
|
// it silently on one misclick.
|
|
BlockedByPin,
|
|
|
|
// Last editable tab. Same reason CanDelete guards the editor: the window
|
|
// would be left with nothing to draw and the message list has no empty
|
|
// state.
|
|
BlockedLastTab,
|
|
}
|
|
|
|
// Closing a tab from the context menu, for both tab kinds.
|
|
//
|
|
// Temp tabs are deliberately NOT routed through CanDelete: IsEditable
|
|
// excludes them, so CanDelete would answer "no" for every tell tab and the
|
|
// conversation would have no way out at all. That was the actual v2.0.0
|
|
// regression -- the editor sends temp tabs to the context menu, and the
|
|
// context menu had no close.
|
|
//
|
|
// Pure + Dalamud-free.
|
|
// TEST-MIRROR: ../../../Hellion Build test/_Helpers/TabCloseabilityTests.cs
|
|
internal static TabCloseability GetCloseability(Tab tab, IReadOnlyList<Tab> tabs)
|
|
{
|
|
if (tab.IsTempTab)
|
|
return tab.IsPinned ? TabCloseability.BlockedByPin : TabCloseability.Allowed;
|
|
|
|
// Counted over the whole list rather than via an index: the caller draws
|
|
// from a frame snapshot in render order, so a positional index would not
|
|
// survive the sectioning.
|
|
var editable = 0;
|
|
foreach (var t in tabs)
|
|
if (IsEditable(t))
|
|
editable++;
|
|
|
|
return editable > 1 ? TabCloseability.Allowed : TabCloseability.BlockedLastTab;
|
|
}
|
|
|
|
public static bool ShouldStripOnLoad(Tab t) => IsInUnpinnedPool(t);
|
|
|
|
public static bool ShouldStripOnSave(Tab t) => IsInUnpinnedPool(t);
|
|
|
|
// Clear every Tab.PopOut at load time. The pool binds later, so at
|
|
// load NO tab can own a slot — a persisted PopOut=true is always a stale flag
|
|
// with no window. Unconditional (pinned included) because pinned TempTabs
|
|
// survive the load and are the main stale-flag source; a !IsPinned filter
|
|
// would leave exactly those leaking. Lockstep with the two in-memory resets
|
|
// (AutoTellTabsService pool-full + the settings round trip backToOriginal).
|
|
// TEST-MIRROR: ../../../Hellion Build test/_Helpers/PopOutResetOnLoadTests.cs
|
|
internal static void ResetPopOutOnLoad(IEnumerable<Tab> tabs)
|
|
{
|
|
foreach (var tab in tabs)
|
|
tab.PopOut = false;
|
|
}
|
|
|
|
// Stale-tell strip + channel derive, run at every tab activation. When a
|
|
// DIFFERENT tab becomes the input surface, drop any runtime tell state the
|
|
// game-side detour left on it (the CurrentChannel tell target plus the
|
|
// partner-name label) so a normal typed line cannot route as a silent /tell
|
|
// to the old partner — the same privacy guard StripTellBindingOnPromote
|
|
// applies on promote. Re-activating the already-active tab must NOT strip
|
|
// (a live game-tell would lose its context); a tab carrying its own
|
|
// Tab.TellTarget is a real tell binding (leg1) and is left intact.
|
|
internal static void OnTabActivated(Tab tab, Tab? previous)
|
|
{
|
|
if (
|
|
!ReferenceEquals(tab, previous)
|
|
&& tab.CurrentChannel.Channel == InputChannel.Tell
|
|
&& tab.TellTarget?.IsSet() != true
|
|
)
|
|
{
|
|
tab.CurrentChannel.SetChannel(InputChannel.Invalid);
|
|
tab.CurrentChannel.TellTarget = null;
|
|
tab.CurrentChannel.ResetTempChannel();
|
|
// Label chunks carry the partner name after a game-side tell.
|
|
tab.CurrentChannel.Name = [];
|
|
}
|
|
|
|
EnsureCurrentChannel(tab);
|
|
}
|
|
|
|
// Pure derive-helper: resolves a tab's input channel from its
|
|
// SelectedChannels when none is set yet. Reached only via OnTabActivated
|
|
// now, so the strip and the derive stay in lockstep at every entry.
|
|
internal static void EnsureCurrentChannel(Tab tab)
|
|
{
|
|
if (tab.CurrentChannel.Channel != InputChannel.Invalid)
|
|
return;
|
|
|
|
foreach (var chatType in tab.SelectedChannels.Keys)
|
|
{
|
|
if (chatType.ToInputChannel() is { } input)
|
|
{
|
|
tab.CurrentChannel.SetChannel(input);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drops a temp/pinned tell tab's binding when it is promoted to a permanent
|
|
// tab. Beyond the obvious IsTempTab/IsPinned/Tab.TellTarget reset, this also
|
|
// clears the RUNTIME channel's tell state — that part is the CORR-1 guard:
|
|
// a spawned tell tab carries CurrentChannel.Channel == Tell plus a resolvable
|
|
// CurrentChannel.TellTarget, and neither is touched by clearing Tab.TellTarget
|
|
// alone. Without this clear the input bar would route a normal typed line on
|
|
// the promoted tab silently as /tell to the OLD partner (a privacy misfire the
|
|
// current==Tell routing gate cannot catch, because current here really IS
|
|
// Tell). Channel -> Invalid so the next sidebar/top-bar click re-derives the
|
|
// channel from SelectedChannels via EnsureCurrentChannel like any normal tab;
|
|
// the worst residual is a "/t" with no target, which the game rejects without
|
|
// sending (same safe class as the COMP-1 fall-through, no silent send).
|
|
internal static void StripTellBindingOnPromote(Tab tab)
|
|
{
|
|
tab.IsTempTab = false;
|
|
tab.IsPinned = false;
|
|
tab.TellTarget = TellTarget.Empty();
|
|
tab.Channel = null;
|
|
tab.CurrentChannel.SetChannel(InputChannel.Invalid);
|
|
tab.CurrentChannel.TellTarget = null;
|
|
tab.CurrentChannel.ResetTempChannel();
|
|
}
|
|
|
|
// Wrap-around tab index for keybind cycling. Pure so the Build-Suite can test the
|
|
// wrap math without a live window. count == 0 returns 0 (the caller dead-zones
|
|
// before activating); negative deltas wrap correctly via the double-mod.
|
|
// TEST-MIRROR: ../../../Hellion Build test/_Helpers/TabLifecycleHelpersTests.cs
|
|
internal static int WrapTabIndex(int current, int delta, int count)
|
|
{
|
|
if (count <= 0)
|
|
return 0;
|
|
return ((current + delta) % count + count) % count;
|
|
}
|
|
|
|
// Sectioned sidebar render order (1.5.6 parity): persistent → pinned TempTabs →
|
|
// unpinned TempTabs. Returns indices into the live tab list so the list order is
|
|
// never mutated and DrawRow keeps each tab's ORIGINAL index for PushID.
|
|
// isPoppedOut excludes tabs bound to a pop-out window — an excluded tab draws no
|
|
// row and its pool's section header gates on the first tab actually reached. Pure
|
|
// + Dalamud-free so the Build-Suite can pin it.
|
|
// TEST-MIRROR: ../../../Hellion Build test/_Helpers/SidebarRenderOrderTests.cs
|
|
// Section-header counts for the sidebar. They take the same isPoppedOut
|
|
// predicate as BuildRenderOrder, which skips popped-out tabs — without it the
|
|
// header would claim "(3)" above two rendered rows. AutoTellTabsService keeps
|
|
// its own live properties: those gate the pool limits and must not see a
|
|
// snapshot.
|
|
internal static int CountUnpinnedPool(IReadOnlyList<Tab> tabs, Func<Tab, bool> isPoppedOut)
|
|
{
|
|
var n = 0;
|
|
for (var i = 0; i < tabs.Count; i++)
|
|
if (IsInUnpinnedPool(tabs[i]) && !isPoppedOut(tabs[i]))
|
|
n++;
|
|
return n;
|
|
}
|
|
|
|
internal static int CountPinnedPool(IReadOnlyList<Tab> tabs, Func<Tab, bool> isPoppedOut)
|
|
{
|
|
var n = 0;
|
|
for (var i = 0; i < tabs.Count; i++)
|
|
if (IsInPinnedPool(tabs[i]) && !isPoppedOut(tabs[i]))
|
|
n++;
|
|
return n;
|
|
}
|
|
|
|
internal static List<int> BuildRenderOrder(IReadOnlyList<Tab> tabs, Func<Tab, bool> isPoppedOut)
|
|
{
|
|
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 (isPoppedOut(tabs[i]))
|
|
continue;
|
|
if (IsInPinnedPool(tabs[i]))
|
|
pinned.Add(i);
|
|
else if (IsInUnpinnedPool(tabs[i]))
|
|
unpinned.Add(i);
|
|
else
|
|
persistent.Add(i);
|
|
}
|
|
|
|
persistent.AddRange(pinned);
|
|
persistent.AddRange(unpinned);
|
|
return persistent;
|
|
}
|
|
|
|
// Returns the tab the main window should display — the current tab if it
|
|
// is not popped out, else the FIRST non-popped tab in list order, else null when
|
|
// every tab is popped. Skipping popped tabs is the whole point: an unconditional
|
|
// Tabs[0] would re-trigger the re-anchor every frame when Tabs[0] is itself
|
|
// popped. ResetActiveTabIfRemoved passes null to reach the same rule from its
|
|
// own entry point. Pure + Dalamud-free.
|
|
// TEST-MIRROR: ../../../Hellion Build test/_Helpers/PickMainActiveTabTests.cs
|
|
internal static Tab? PickMainActiveTab(
|
|
Tab? current,
|
|
IReadOnlyList<Tab> tabs,
|
|
Func<Tab, bool> isPoppedOut
|
|
)
|
|
{
|
|
if (current is not null && !isPoppedOut(current))
|
|
return current;
|
|
foreach (var tab in tabs)
|
|
if (!isPoppedOut(tab))
|
|
return tab;
|
|
return null;
|
|
}
|
|
|
|
// What an incoming tell should do about the tab it belongs to.
|
|
internal enum TellReveal
|
|
{
|
|
None,
|
|
MainWindow,
|
|
Popout,
|
|
}
|
|
|
|
// Two reasons this is a function, and inputBusy is the second one.
|
|
//
|
|
// A tell arriving while the user is mid-sentence used to yank the active tab
|
|
// away (tester report, Carla, 23.08.2026). The interruption is the visible
|
|
// half; the sharp half is that InputBar's 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 the one who just wrote. When
|
|
// an input is busy, nothing is revealed at all: the tab still appears and
|
|
// still carries its unread mark, the user just walks over on their own.
|
|
//
|
|
// The alreadyPopped case is the older reason.
|
|
//
|
|
// Revealing a popped-out tab in the main window looks like it does nothing,
|
|
// and then does something worse: PickMainActiveTab re-anchors on the next
|
|
// frame, and it anchors to the FIRST non-popped tab, not to the one the user
|
|
// was reading. So a tell from a partner whose tab is popped out threw the
|
|
// main window back to the first tab every single time.
|
|
//
|
|
// The tab is already on screen in its own window. There is nothing to
|
|
// reveal.
|
|
//
|
|
// Pure + Dalamud-free.
|
|
// TEST-MIRROR: ../../../Hellion Build test/_Helpers/PlanTellRevealTests.cs
|
|
internal static TellReveal PlanTellReveal(
|
|
TellAutoOpenMode mode,
|
|
bool switchAlways,
|
|
bool alreadyPopped,
|
|
bool inputBusy
|
|
) =>
|
|
inputBusy
|
|
? TellReveal.None
|
|
: mode switch
|
|
{
|
|
TellAutoOpenMode.Off => TellReveal.None,
|
|
TellAutoOpenMode.Popout => alreadyPopped ? TellReveal.None : TellReveal.Popout,
|
|
// Sidebar and TopTab both land here: they used to pick the window
|
|
// LAYOUT as a side effect, which overwrote a setting the user made
|
|
// somewhere else entirely. Reveal is reveal; layout is the user's.
|
|
_ => switchAlways && !alreadyPopped ? TellReveal.MainWindow : TellReveal.None,
|
|
};
|
|
|
|
// Popout-aware sibling of WrapTabIndex for the ChatTabForward/Backward
|
|
// keybind. Steps from current by delta's sign (±1), wrapping, and returns the
|
|
// first index whose tab is NOT popped out within tabs.Count steps; returns current
|
|
// when every other tab is popped (no-op), the list is empty, or a single tab.
|
|
// Skipping popped tabs here is what stops the PickMainActiveTab re-anchor
|
|
// from making the cycle stick. Pure + Dalamud-free.
|
|
// TEST-MIRROR: ../../../Hellion Build test/_Helpers/NextMainTabIndexTests.cs
|
|
internal static int NextMainTabIndex(
|
|
int current,
|
|
int delta,
|
|
IList<Tab> tabs,
|
|
Func<Tab, bool> isPoppedOut
|
|
)
|
|
{
|
|
if (tabs.Count == 0)
|
|
return current;
|
|
var step = delta >= 0 ? 1 : -1;
|
|
var idx = current;
|
|
for (var n = 0; n < tabs.Count; n++)
|
|
{
|
|
idx = ((idx + step) % tabs.Count + tabs.Count) % tabs.Count;
|
|
if (idx == current)
|
|
break;
|
|
if (!isPoppedOut(tabs[idx]))
|
|
return idx;
|
|
}
|
|
return current;
|
|
}
|
|
}
|