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. // PerformanceBaselineStep so the hot path stays allocation-free.
internal double LastDrawMs; internal double LastDrawMs;
internal int DeferredSaveFrames = -1;
// Cancels the v1.4.8 FTS5 bulk-insert worker on plugin teardown. The // 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 // worker runs off the framework thread on its own SqliteConnection, so a
// Dispose mid-rebuild must signal cancellation before MessageManager // Dispose mid-rebuild must signal cancellation before MessageManager
@@ -277,8 +275,6 @@ public sealed class Plugin : IAsyncDalamudPlugin
ImGuiUtil.Initialize(this); ImGuiUtil.Initialize(this);
DeferredSaveFrames = -1;
// Custom themes dir + seed run before the container builds so the // Custom themes dir + seed run before the container builds so the
// ThemeRegistry factory lambda finds the directory ready. // ThemeRegistry factory lambda finds the directory ready.
var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes"); 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. // Framework-thread cleanup the container does not reach.
try try
{ {
@@ -1134,9 +1117,6 @@ public sealed class Plugin : IAsyncDalamudPlugin
private void FrameworkUpdate(IFramework framework) private void FrameworkUpdate(IFramework framework)
{ {
if (DeferredSaveFrames >= 0 && DeferredSaveFrames-- == 0)
SaveConfig();
if (!Config.HideChat) if (!Config.HideChat)
return; return;
@@ -116,10 +116,11 @@ internal sealed class ChannelsTab
{ {
var current = get(); var current = get();
ImGui.SetNextItemWidth(200); 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")) if (ImGui.SliderInt(label, ref current, min, max, "%d"))
{
set(current); set(current);
if (ImGui.IsItemDeactivatedAfterEdit())
_plugin.SaveConfig(); _plugin.SaveConfig();
}
} }
} }
@@ -108,10 +108,11 @@ internal sealed class DataPrivacyTab
{ {
var current = get(); var current = get();
ImGui.SetNextItemWidth(200); 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")) if (ImGui.SliderInt(label, ref current, min, max, "%d"))
{
set(current); set(current);
if (ImGui.IsItemDeactivatedAfterEdit())
_plugin.SaveConfig(); _plugin.SaveConfig();
}
} }
} }
@@ -80,11 +80,12 @@ internal sealed class GeneralTab
{ {
var current = get(); var current = get();
ImGui.SetNextItemWidth(200); 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")) if (ImGui.SliderFloat(label, ref current, min, max, "%.2f"))
{
set(current); set(current);
if (ImGui.IsItemDeactivatedAfterEdit())
_plugin.SaveConfig(); _plugin.SaveConfig();
}
} }
// Wires the already-present ImGuiUtil.KeybindInput capture widget (dead/unwired // Wires the already-present ImGuiUtil.KeybindInput capture widget (dead/unwired
@@ -134,21 +134,23 @@ internal sealed class WindowTab
{ {
var current = get(); var current = get();
ImGui.SetNextItemWidth(200); 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")) if (ImGui.SliderFloat(label, ref current, min, max, "%.2f"))
{
set(current); set(current);
if (ImGui.IsItemDeactivatedAfterEdit())
_plugin.SaveConfig(); _plugin.SaveConfig();
}
} }
private void DrawSliderInt(string label, Func<int> get, Action<int> set, int min, int max) private void DrawSliderInt(string label, Func<int> get, Action<int> set, int min, int max)
{ {
var current = get(); var current = get();
ImGui.SetNextItemWidth(200); 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")) if (ImGui.SliderInt(label, ref current, min, max, "%d"))
{
set(current); set(current);
if (ImGui.IsItemDeactivatedAfterEdit())
_plugin.SaveConfig(); _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. // state and reaches the live Config/Plugin through Plugin.Instance/Plugin.Config.
internal static class TabContextMenu 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 // MUST be called immediately after the row-carrying ImGui item (Sidebar
// "row" InvisibleButton / TopTabBar Selectable). popupId only names the // "row" InvisibleButton / TopTabBar Selectable). popupId only names the
// popup; the open trigger is a right-click on the LAST submitted item // 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) public static void Draw(Tab tab, string popupId, Windows.ChannelPopoutPool pool)
{ {
if (!ImGui.BeginPopupContextItem(popupId)) 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; return;
}
// Rename: focus the field the first frame the popup appears. // Rename: focus the field the first frame the popup appears.
if (ImGui.IsWindowAppearing()) if (ImGui.IsWindowAppearing())
@@ -28,7 +45,19 @@ internal static class TabContextMenu
ImGui.SetNextItemWidth(250f * ImGuiHelpers.GlobalScale); ImGui.SetNextItemWidth(250f * ImGuiHelpers.GlobalScale);
var name = tab.Name; var name = tab.Name;
if (ImGui.InputText("##tab-name", ref name, 512) && ApplyTabRename(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 // Per-tab notification sound (B3-3). The checkbox gates the picker so
// tabs that never want a sound keep the popup short. // tabs that never want a sound keep the popup short.
@@ -49,6 +78,15 @@ internal static class TabContextMenu
ImGui.EndPopup(); 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 // 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 // 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 // preview reuses the entry label scheme so the current pick reads the same