fix(ui): render each frame from one tab-list snapshot, key widgets by identity
Sidebar, top tabs and status bar each read Config.Tabs on their own, unlocked, while the worker added or evicted tabs. That gave three independent views of a moving list: an index built in one place could resolve to a different tab a few lines later, which showed up either as an out-of-range crash on the draw thread or -- worse, because it is silent -- as a click landing in someone else's tell. MainWindow now takes one snapshot under the lock and passes it through the whole frame. Deliberately a shallow copy: tab identity is compared by reference all over the draw path, so cloning would break every ReferenceEquals and Contains. ChangeTabDelta and ResetActiveTabIfRemoved run on the framework thread and keep their own locked reads instead; ThemeQuickPicker locks its own copy, since reaching it would mean threading a parameter through InputBar, which popouts share and which has no tab list. Widget IDs move from list position to tab.Identifier. ImGui carries popup and widget state across frames under that ID, so a position-based one re-binds an open context menu to a different tab as soon as the list shifts -- a snapshot cannot fix that, it spans frames. This also resolves top tabs visually merging into each other when the list changed. The sidebar section headers counted over the live list while the rows came from BuildRenderOrder, which skips popped-out tabs. Both sides take the same predicate now, so the count matches what is drawn.
This commit is contained in:
@@ -144,7 +144,11 @@ internal sealed class MainWindow : Window, IFocusableChatWindow
|
||||
if (!ReferenceEquals(_activeTab, removed))
|
||||
return;
|
||||
|
||||
var next = Plugin.Config.Tabs.Count > 0 ? Plugin.Config.Tabs[0] : null;
|
||||
// Framework thread, not the draw frame: needs the current truth, so it takes
|
||||
// its own lock instead of using the frame snapshot.
|
||||
Tab? next;
|
||||
lock (Plugin.Instance.TabsListLock)
|
||||
next = Plugin.Config.Tabs.Count > 0 ? Plugin.Config.Tabs[0] : null;
|
||||
_activeTab = next;
|
||||
if (next is not null)
|
||||
TabLifecycleHelpers.OnTabActivated(next, removed);
|
||||
@@ -171,7 +175,11 @@ internal sealed class MainWindow : Window, IFocusableChatWindow
|
||||
// deferred (no focus contract) — main-window tabs only.
|
||||
internal void ChangeTabDelta(int delta)
|
||||
{
|
||||
var tabs = Plugin.Config.Tabs;
|
||||
// Runs on Framework.Update via the keybind dispatch, not on the draw frame —
|
||||
// own lock, own copy. Stays a List so IndexOf below keeps working.
|
||||
List<Tab> tabs;
|
||||
lock (Plugin.Instance.TabsListLock)
|
||||
tabs = Plugin.Config.Tabs.ToList();
|
||||
if (tabs.Count == 0)
|
||||
return;
|
||||
|
||||
@@ -256,24 +264,30 @@ internal sealed class MainWindow : Window, IFocusableChatWindow
|
||||
// Primary pool-reset path; InputPreview has a defensive fallback for the MainWindow-closed edge case.
|
||||
_handlerLender.ResetCounter();
|
||||
|
||||
// One snapshot for the whole frame. Everything below reads this instead of
|
||||
// Config.Tabs, so sidebar, top tabs and status bar see the same list even if
|
||||
// the worker adds or evicts a tab mid-frame. Deliberately a SHALLOW copy:
|
||||
// tab identity is compared by reference all over the draw path, so cloning
|
||||
// would break every ReferenceEquals and Contains.
|
||||
List<Tab> tabs;
|
||||
lock (Plugin.Instance.TabsListLock)
|
||||
tabs = Plugin.Config.Tabs.ToList();
|
||||
|
||||
// First-frame seed: the active tab defaults to the first persisted
|
||||
// tab so the message list isn't empty on a clean session.
|
||||
if (_activeTab is null && Plugin.Config.Tabs.Count > 0)
|
||||
if (_activeTab is null && tabs.Count > 0)
|
||||
{
|
||||
var seeded = Plugin.Config.Tabs[0];
|
||||
var seeded = tabs[0];
|
||||
_activeTab = seeded;
|
||||
// The seeded Tabs[0] is the likeliest legacy stale-tell carrier
|
||||
// (pre-coupling the detour wrote here); strip it like any activation.
|
||||
TabLifecycleHelpers.OnTabActivated(seeded, null);
|
||||
}
|
||||
else if (_activeTab is { } active && !Plugin.Config.Tabs.Contains(active))
|
||||
else if (_activeTab is { } active && !tabs.Contains(active))
|
||||
{
|
||||
// Active tab is no longer in the list (e.g. a wholesale config import
|
||||
// the service repair paths never see). Re-seed on the Draw thread. The
|
||||
// Contains read shares the pre-existing unsynchronized-Tabs-list
|
||||
// exposure that spec §6 defers (SaveConfig also strips from the worker
|
||||
// thread); this adds one more racing read, not a new hazard class.
|
||||
var reseed = Plugin.Config.Tabs.Count > 0 ? Plugin.Config.Tabs[0] : null;
|
||||
// the service repair paths never see). Re-seed on the Draw thread.
|
||||
var reseed = tabs.Count > 0 ? tabs[0] : null;
|
||||
_activeTab = reseed;
|
||||
if (reseed is not null)
|
||||
TabLifecycleHelpers.OnTabActivated(reseed, active);
|
||||
@@ -287,7 +301,7 @@ internal sealed class MainWindow : Window, IFocusableChatWindow
|
||||
// settled, so OnTabActivated fires only on the pop frame.
|
||||
var visibleActive = TabLifecycleHelpers.PickMainActiveTab(
|
||||
_activeTab,
|
||||
Plugin.Config.Tabs,
|
||||
tabs,
|
||||
t => _pool.IsOpen(t.Identifier)
|
||||
);
|
||||
if (!ReferenceEquals(visibleActive, _activeTab))
|
||||
@@ -309,20 +323,20 @@ internal sealed class MainWindow : Window, IFocusableChatWindow
|
||||
using (var body = ImRaii.Child("##hellion-body", new Vector2(-1f, -statusHeight)))
|
||||
{
|
||||
if (body.Success)
|
||||
DrawBody();
|
||||
DrawBody(tabs);
|
||||
}
|
||||
|
||||
_status.Draw(_activeTab);
|
||||
_status.Draw(_activeTab, tabs);
|
||||
}
|
||||
|
||||
private void DrawBody()
|
||||
private void DrawBody(IReadOnlyList<Tab> tabs)
|
||||
{
|
||||
var bodyWidth = ImGui.GetContentRegionAvail().X;
|
||||
_honorific.Draw(bodyWidth);
|
||||
|
||||
if (Plugin.Config.MainWindowLayoutMode == MainWindowLayoutMode.TopTabs)
|
||||
{
|
||||
_topTabs.Draw(Plugin.Config.Tabs, ref _activeTab);
|
||||
_topTabs.Draw(tabs, ref _activeTab);
|
||||
using (ImRaii.Group())
|
||||
{
|
||||
DrawMainArea();
|
||||
@@ -333,7 +347,7 @@ internal sealed class MainWindow : Window, IFocusableChatWindow
|
||||
// Sidebar layout (default).
|
||||
using (ImRaii.Group())
|
||||
{
|
||||
_sidebar.Draw(bodyWidth, Plugin.Config.Tabs, ref _activeTab);
|
||||
_sidebar.Draw(bodyWidth, tabs, ref _activeTab);
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
Reference in New Issue
Block a user