perf(settings): write config on release instead of every slider frame

ImGui sliders report a change in every frame the value moves, so dragging one
rewrote the full 31 KB config to disk per frame -- serialize, fsync and rename,
synchronously on the draw thread. Measured on Linux/Wine that showed up as a
114 ms frame while the plugin itself only drew for 2.9 ms; the rest was waiting
on the write.

The five shared slider helpers now defer SaveConfig to IsItemDeactivatedAfterEdit,
matching what ChatColourPicker already did for the colour wheel.

Renaming a tab needed its own path: the input lives inside a popup, and ImGui
never re-submits it when the popup is dismissed by clicking outside, so
IsItemDeactivatedAfterEdit would not fire and the new name would be lost. A
pending-rename marker scoped to the owning tab flushes it when the popup is
gone -- scoped, because every other tab's Draw reaches that branch too.

DeferredSaveFrames is removed: the debounce was fully wired but never armed,
and this approach makes it redundant.
This commit is contained in:
2026-08-17 06:49:53 +02:00
parent 99dca8cb31
commit d2da51a4f7
6 changed files with 54 additions and 31 deletions
-20
View File
@@ -175,8 +175,6 @@ public sealed class Plugin : IAsyncDalamudPlugin
// PerformanceBaselineStep so the hot path stays allocation-free.
internal double LastDrawMs;
internal int DeferredSaveFrames = -1;
// Cancels the v1.4.8 FTS5 bulk-insert worker on plugin teardown. The
// worker runs off the framework thread on its own SqliteConnection, so a
// Dispose mid-rebuild must signal cancellation before MessageManager
@@ -277,8 +275,6 @@ public sealed class Plugin : IAsyncDalamudPlugin
ImGuiUtil.Initialize(this);
DeferredSaveFrames = -1;
// Custom themes dir + seed run before the container builds so the
// ThemeRegistry factory lambda finds the directory ready.
var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes");
@@ -641,19 +637,6 @@ public sealed class Plugin : IAsyncDalamudPlugin
}
);
// Flush a pending DeferredSave — FrameworkUpdate won't fire it anymore.
failure = CaptureFailure(
failure,
() =>
{
if (DeferredSaveFrames >= 0)
{
SaveConfig();
DeferredSaveFrames = -1;
}
}
);
// Framework-thread cleanup the container does not reach.
try
{
@@ -1134,9 +1117,6 @@ public sealed class Plugin : IAsyncDalamudPlugin
private void FrameworkUpdate(IFramework framework)
{
if (DeferredSaveFrames >= 0 && DeferredSaveFrames-- == 0)
SaveConfig();
if (!Config.HideChat)
return;
@@ -116,10 +116,11 @@ internal sealed class ChannelsTab
{
var current = get();
ImGui.SetNextItemWidth(200);
// Sliders report a change every frame while dragging; deferring the write
// to release turns ~30 full-config disk writes per second into one.
if (ImGui.SliderInt(label, ref current, min, max, "%d"))
{
set(current);
if (ImGui.IsItemDeactivatedAfterEdit())
_plugin.SaveConfig();
}
}
}
@@ -108,10 +108,11 @@ internal sealed class DataPrivacyTab
{
var current = get();
ImGui.SetNextItemWidth(200);
// Sliders report a change every frame while dragging; deferring the write
// to release turns ~30 full-config disk writes per second into one.
if (ImGui.SliderInt(label, ref current, min, max, "%d"))
{
set(current);
if (ImGui.IsItemDeactivatedAfterEdit())
_plugin.SaveConfig();
}
}
}
@@ -80,11 +80,12 @@ internal sealed class GeneralTab
{
var current = get();
ImGui.SetNextItemWidth(200);
// Sliders report a change every frame while dragging; deferring the write
// to release turns ~30 full-config disk writes per second into one.
if (ImGui.SliderFloat(label, ref current, min, max, "%.2f"))
{
set(current);
if (ImGui.IsItemDeactivatedAfterEdit())
_plugin.SaveConfig();
}
}
// Wires the already-present ImGuiUtil.KeybindInput capture widget (dead/unwired
@@ -134,21 +134,23 @@ internal sealed class WindowTab
{
var current = get();
ImGui.SetNextItemWidth(200);
// Sliders report a change every frame while dragging; deferring the write
// to release turns ~30 full-config disk writes per second into one.
if (ImGui.SliderFloat(label, ref current, min, max, "%.2f"))
{
set(current);
if (ImGui.IsItemDeactivatedAfterEdit())
_plugin.SaveConfig();
}
}
private void DrawSliderInt(string label, Func<int> get, Action<int> set, int min, int max)
{
var current = get();
ImGui.SetNextItemWidth(200);
// Sliders report a change every frame while dragging; deferring the write
// to release turns ~30 full-config disk writes per second into one.
if (ImGui.SliderInt(label, ref current, min, max, "%d"))
{
set(current);
if (ImGui.IsItemDeactivatedAfterEdit())
_plugin.SaveConfig();
}
}
}
+39 -1
View File
@@ -12,6 +12,12 @@ namespace HellionChat.Ui.Components;
// 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
@@ -20,7 +26,18 @@ internal static class TabContextMenu
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;
}
// Rename: focus the field the first frame the popup appears.
if (ImGui.IsWindowAppearing())
@@ -28,7 +45,19 @@ internal static class TabContextMenu
ImGui.SetNextItemWidth(250f * ImGuiHelpers.GlobalScale);
var name = tab.Name;
if (ImGui.InputText("##tab-name", ref name, 512) && ApplyTabRename(tab, name))
Plugin.Instance.SaveConfig();
{
_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 (B3-3). The checkbox gates the picker so
// tabs that never want a sound keep the popup short.
@@ -49,6 +78,15 @@ internal static class TabContextMenu
ImGui.EndPopup();
}
// 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