The exporter has worked since v1.4.8. The form that drives it went out with the old settings window in May, which left PRIVACY.md promising an access request the plugin had no way to answer. New section in the data and privacy tab: time range, sender substring, channel groups, format, and a save dialog. Form state lives in the tab, not the config -- a filter describes one action, and a stale "last 7 days, sender Mira" reappearing weeks later is a worse start than an empty form. StreamForExport now takes a caller-owned connection. The reader stays open for as long as the file is written, seconds to minutes on a large history, and chat keeps arriving throughout -- so the primary connection would be read here and written by UpsertMessage at once, and SqliteConnection is not thread-safe. Holding the read lock instead would trade that for freezing the game. ChannelGroups lifts the eight groups out of the deleted tab and finishes them: 37 of 89 channels belonged to no group and were therefore unreachable in the UI. Game Master channels follow ChatTypeExt.Parent(), so GmTell sits with the other tells rather than under system traffic -- an access request that quietly drops part of what it promises is the dangerous kind of gap. Also here: - OpenSecondaryConnection disposes on a failing pragma. Open can succeed and journal_mode=WAL still time out, and with Pooling=false the connection then survives until a finalizer reaches it. Affects the full-text rebuild worker too. - StreamForExport builds its logger before the reader, so a throwing CreateLogger cannot leave a reader nobody owns. - The export thread takes the gate itself instead of the caller taking it first. Acquiring before Start would strand the gate for the session if thread creation failed, and the gate also holds back the sweep. - Notifications are skipped once teardown has started. The thread has no cancellation path and finishing the file is right, but reporting it to a plugin that is gone is not. - Transient widget rows that return their value instead of saving it. Writing the config file on every keystroke of a sender filter would be both pointless and slow. - Five translated keys for "another database operation is running", in all 25 languages. Two of the four operation names have no trigger yet; they arrive with the cleanup and maintenance sections.
448 lines
13 KiB
C#
448 lines
13 KiB
C#
using System.Numerics;
|
|
using Dalamud.Bindings.ImGui;
|
|
using Dalamud.Interface.Utility.Raii;
|
|
using HellionChat.Themes;
|
|
using HellionChat.Ui.StyleEngine;
|
|
using HellionChat.Ui.StyleEngine.Widgets;
|
|
|
|
namespace HellionChat.Ui.Components.Settings;
|
|
|
|
// Enum.GetValues allocates a fresh array on every call, and the settings tabs
|
|
// were calling it inside Draw -- once per combo, every frame the window is open.
|
|
// The set cannot change at runtime, so it is read once per closed generic.
|
|
internal static class EnumValues<T>
|
|
where T : struct, Enum
|
|
{
|
|
internal static readonly T[] All = Enum.GetValues<T>();
|
|
}
|
|
|
|
// The four controls every settings tab draws. Six tabs carried a byte-identical
|
|
// DrawToggle, four a byte-identical slider, and five hand-rolled the same combo
|
|
// loop with different widths.
|
|
//
|
|
// Deliberately constructed by the tabs rather than injected: the tabs are DI
|
|
// singletons, and a new constructor parameter on all seven of them buys nothing
|
|
// here beyond a wider blast radius.
|
|
internal sealed class SettingsWidgets
|
|
{
|
|
private readonly Plugin _plugin;
|
|
|
|
// Shared across every combo. ImGui.Combo copies the strings it needs before
|
|
// returning, so the buffer is free again by the time the next call runs.
|
|
// Always sliced to the value count when passed on -- see EnumCombo.
|
|
private string[] _labelScratch = new string[8];
|
|
|
|
private readonly SettingsPalette? _colors;
|
|
|
|
// Cached per frame: twenty rows would otherwise re-resolve the same five
|
|
// theme tokens twenty times, and the active theme cannot change mid-frame.
|
|
private int _frame = -1;
|
|
private SettingRowColors _row;
|
|
private ToggleSwitchColors _toggle;
|
|
private SectionHeaderColors _section;
|
|
private SegmentedControlColors _segmented;
|
|
|
|
internal SettingsWidgets(Plugin plugin, SettingsPalette? colors = null)
|
|
{
|
|
_plugin = plugin;
|
|
_colors = colors;
|
|
}
|
|
|
|
// Tabs that have not been converted yet pass no palette and keep using the
|
|
// plain ImGui helpers below.
|
|
private void EnsureFrame()
|
|
{
|
|
if (_colors is null || _frame == ImGui.GetFrameCount())
|
|
return;
|
|
|
|
_frame = ImGui.GetFrameCount();
|
|
var c = _plugin.ThemeRegistry.Active.Colors;
|
|
_row = _colors.Row(c);
|
|
_toggle = _colors.Toggle(c);
|
|
_section = _colors.Section(c);
|
|
_segmented = _colors.Segmented(c);
|
|
}
|
|
|
|
internal bool Section(uint key, string title, string? description = null, bool open = true)
|
|
{
|
|
EnsureFrame();
|
|
return SectionHeader.Draw(key, title, description, _section, defaultOpen: open);
|
|
}
|
|
|
|
// The whole row toggles, label included. The switch itself gets its own
|
|
// invisible button because SettingRow's hit area stops at the label column,
|
|
// and clicking the control is what a user tries first.
|
|
// Escape hatch for controls the helpers do not cover -- a keybind capture,
|
|
// a picker with side effects. The caller draws whatever it likes into the
|
|
// control column and keeps its own save logic.
|
|
internal void Row(
|
|
uint id,
|
|
string label,
|
|
string? description,
|
|
Action<SettingRowContext> drawControl
|
|
)
|
|
{
|
|
EnsureFrame();
|
|
SettingRow.Draw(id, label, description, _row, drawControl);
|
|
}
|
|
|
|
internal void ToggleRow(
|
|
uint id,
|
|
string label,
|
|
string? description,
|
|
Func<bool> get,
|
|
Action<bool> set
|
|
)
|
|
{
|
|
EnsureFrame();
|
|
var value = get();
|
|
var hit = false;
|
|
|
|
var rowClicked = SettingRow.Draw(
|
|
id,
|
|
label,
|
|
description,
|
|
_row,
|
|
ctx =>
|
|
{
|
|
var size = ToggleSwitch.CalcSize();
|
|
var pos = ctx.AlignRight(size);
|
|
ImGui.SetCursorScreenPos(pos);
|
|
if (ImGui.InvisibleButton($"##hc-sw-{id}", size))
|
|
hit = true;
|
|
ToggleSwitch.Draw(id, pos, value, _toggle);
|
|
}
|
|
);
|
|
|
|
if (!rowClicked && !hit)
|
|
return;
|
|
|
|
set(!value);
|
|
_plugin.SaveConfig();
|
|
}
|
|
|
|
internal void SliderFloatRow(
|
|
uint id,
|
|
string label,
|
|
string? description,
|
|
Func<float> get,
|
|
Action<float> set,
|
|
float min,
|
|
float max
|
|
)
|
|
{
|
|
EnsureFrame();
|
|
var current = get();
|
|
SettingRow.Draw(
|
|
id,
|
|
label,
|
|
description,
|
|
_row,
|
|
ctx =>
|
|
{
|
|
ImGui.SetNextItemWidth(ctx.ControlWidth);
|
|
if (ImGui.SliderFloat($"##hc-sf-{id}", ref current, min, max, "%.2f"))
|
|
set(current);
|
|
if (ImGui.IsItemDeactivatedAfterEdit())
|
|
_plugin.SaveConfig();
|
|
}
|
|
);
|
|
}
|
|
|
|
internal void SliderIntRow(
|
|
uint id,
|
|
string label,
|
|
string? description,
|
|
Func<int> get,
|
|
Action<int> set,
|
|
int min,
|
|
int max
|
|
)
|
|
{
|
|
EnsureFrame();
|
|
var current = get();
|
|
SettingRow.Draw(
|
|
id,
|
|
label,
|
|
description,
|
|
_row,
|
|
ctx =>
|
|
{
|
|
ImGui.SetNextItemWidth(ctx.ControlWidth);
|
|
if (ImGui.SliderInt($"##hc-si-{id}", ref current, min, max, "%d"))
|
|
set(current);
|
|
if (ImGui.IsItemDeactivatedAfterEdit())
|
|
_plugin.SaveConfig();
|
|
}
|
|
);
|
|
}
|
|
|
|
internal void EnumComboRow<T>(
|
|
uint id,
|
|
string label,
|
|
string? description,
|
|
Func<T> get,
|
|
Action<T> set,
|
|
Func<T, string> labelFor
|
|
)
|
|
where T : struct, Enum
|
|
{
|
|
EnsureFrame();
|
|
SettingRow.Draw(
|
|
id,
|
|
label,
|
|
description,
|
|
_row,
|
|
ctx =>
|
|
{
|
|
ImGui.SetNextItemWidth(ctx.ControlWidth);
|
|
EnumCombo($"##hc-ec-{id}", get, set, labelFor, ctx.ControlWidth);
|
|
}
|
|
);
|
|
}
|
|
|
|
// One setting, n choices. The control fills the whole control column rather
|
|
// than right-aligning, because segments need the room to stay readable.
|
|
internal void SegmentRow<T>(
|
|
uint id,
|
|
string label,
|
|
string? description,
|
|
T[] values,
|
|
string[] labels,
|
|
Func<T> get,
|
|
Action<T> set
|
|
)
|
|
where T : struct, Enum
|
|
{
|
|
EnsureFrame();
|
|
var current = get();
|
|
var selected = 0;
|
|
for (var i = 0; i < values.Length; i++)
|
|
if (EqualityComparer<T>.Default.Equals(values[i], current))
|
|
selected = i;
|
|
|
|
var picked = selected;
|
|
var colors = _segmented;
|
|
|
|
SettingRow.Draw(
|
|
id,
|
|
label,
|
|
description,
|
|
_row,
|
|
ctx =>
|
|
{
|
|
ImGui.SetCursorScreenPos(new Vector2(ctx.ControlOrigin.X, ctx.ControlOrigin.Y));
|
|
picked = SegmentedControl.Draw(id, ctx.ControlWidth, labels, selected, colors);
|
|
}
|
|
);
|
|
|
|
if (picked == selected)
|
|
return;
|
|
|
|
set(values[picked]);
|
|
_plugin.SaveConfig();
|
|
}
|
|
|
|
// Transient rows for form state that never reaches the config: an export
|
|
// filter, a cleanup preview. They hand the value back instead of taking a
|
|
// setter, and they do not call SaveConfig -- there is nothing to save, and
|
|
// writing the config file on every keystroke of a sender filter would be
|
|
// both pointless and slow.
|
|
internal bool ToggleRow(uint id, string label, string? description, bool value)
|
|
{
|
|
EnsureFrame();
|
|
var hit = false;
|
|
|
|
var rowClicked = SettingRow.Draw(
|
|
id,
|
|
label,
|
|
description,
|
|
_row,
|
|
ctx =>
|
|
{
|
|
var size = ToggleSwitch.CalcSize();
|
|
var pos = ctx.AlignRight(size);
|
|
ImGui.SetCursorScreenPos(pos);
|
|
if (ImGui.InvisibleButton($"##hc-sw-{id}", size))
|
|
hit = true;
|
|
ToggleSwitch.Draw(id, pos, value, _toggle);
|
|
}
|
|
);
|
|
|
|
return rowClicked || hit ? !value : value;
|
|
}
|
|
|
|
internal string TextRow(uint id, string label, string? description, string value)
|
|
{
|
|
EnsureFrame();
|
|
var current = value;
|
|
SettingRow.Draw(
|
|
id,
|
|
label,
|
|
description,
|
|
_row,
|
|
ctx =>
|
|
{
|
|
// PushId rather than an interpolated label: the binding only
|
|
// offers a ref-string InputText for a literal label, and the ID
|
|
// stack separates the rows just as well. RAII because a throw
|
|
// inside InputText would otherwise leave the stack unbalanced
|
|
// and trip the assert in End().
|
|
using var scope = ImRaii.PushId((int)id);
|
|
ImGui.SetNextItemWidth(ctx.ControlWidth);
|
|
// 511, not 512: the binding reserves maxLength + 1 and rents from
|
|
// the array pool once that reaches 512.
|
|
ImGui.InputText("##hc-tr", ref current, 511);
|
|
}
|
|
);
|
|
return current;
|
|
}
|
|
|
|
internal int SliderIntRow(
|
|
uint id,
|
|
string label,
|
|
string? description,
|
|
int value,
|
|
int min,
|
|
int max
|
|
)
|
|
{
|
|
EnsureFrame();
|
|
var current = value;
|
|
SettingRow.Draw(
|
|
id,
|
|
label,
|
|
description,
|
|
_row,
|
|
ctx =>
|
|
{
|
|
ImGui.SetNextItemWidth(ctx.ControlWidth);
|
|
ImGui.SliderInt($"##hc-si-{id}", ref current, min, max, "%d");
|
|
}
|
|
);
|
|
return current;
|
|
}
|
|
|
|
internal int SegmentRow(
|
|
uint id,
|
|
string label,
|
|
string? description,
|
|
string[] labels,
|
|
int selected
|
|
)
|
|
{
|
|
EnsureFrame();
|
|
|
|
// Clamped rather than trusted: the generic overload derives the index
|
|
// from the value and falls back to 0, this one takes whatever the caller
|
|
// passes. Array.IndexOf returns -1 on a miss, and the caller then indexes
|
|
// its value array with the result.
|
|
var current = Math.Clamp(selected, 0, Math.Max(0, labels.Length - 1));
|
|
var picked = current;
|
|
var colors = _segmented;
|
|
|
|
SettingRow.Draw(
|
|
id,
|
|
label,
|
|
description,
|
|
_row,
|
|
ctx =>
|
|
{
|
|
ImGui.SetCursorScreenPos(new Vector2(ctx.ControlOrigin.X, ctx.ControlOrigin.Y));
|
|
picked = SegmentedControl.Draw(id, ctx.ControlWidth, labels, current, colors);
|
|
}
|
|
);
|
|
|
|
return picked;
|
|
}
|
|
|
|
internal void Toggle(string label, Func<bool> get, Action<bool> set)
|
|
{
|
|
var current = get();
|
|
if (!ImGui.Checkbox(label, ref current))
|
|
return;
|
|
|
|
set(current);
|
|
_plugin.SaveConfig();
|
|
}
|
|
|
|
internal void SliderFloat(
|
|
string label,
|
|
Func<float> get,
|
|
Action<float> set,
|
|
float min,
|
|
float max,
|
|
float width = 200f
|
|
)
|
|
{
|
|
var current = get();
|
|
ImGui.SetNextItemWidth(width);
|
|
// 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();
|
|
}
|
|
|
|
internal void SliderInt(
|
|
string label,
|
|
Func<int> get,
|
|
Action<int> set,
|
|
int min,
|
|
int max,
|
|
float width = 200f
|
|
)
|
|
{
|
|
var current = get();
|
|
ImGui.SetNextItemWidth(width);
|
|
if (ImGui.SliderInt(label, ref current, min, max, "%d"))
|
|
set(current);
|
|
if (ImGui.IsItemDeactivatedAfterEdit())
|
|
_plugin.SaveConfig();
|
|
}
|
|
|
|
// labelFor is a parameter rather than a constraint because the display names
|
|
// live in extension methods, which bind statically and cannot be reached
|
|
// through a generic type parameter.
|
|
internal void EnumCombo<T>(
|
|
string label,
|
|
Func<T> get,
|
|
Action<T> set,
|
|
Func<T, string> labelFor,
|
|
float width = 200f
|
|
)
|
|
where T : struct, Enum
|
|
{
|
|
var values = EnumValues<T>.All;
|
|
if (values.Length == 0)
|
|
return;
|
|
|
|
if (_labelScratch.Length < values.Length)
|
|
_labelScratch = new string[values.Length];
|
|
|
|
var current = get();
|
|
var selected = 0;
|
|
for (var i = 0; i < values.Length; i++)
|
|
{
|
|
_labelScratch[i] = labelFor(values[i]);
|
|
if (EqualityComparer<T>.Default.Equals(values[i], current))
|
|
selected = i;
|
|
}
|
|
|
|
// Sliced, not passed whole with a count. The binding's fourth parameter
|
|
// is popupMaxHeightInItems, not the item count -- that comes from the
|
|
// span's own length. Handing over the full buffer would list all eight
|
|
// slots, so a three-value enum would show five blank rows.
|
|
ImGui.SetNextItemWidth(width);
|
|
if (!ImGui.Combo(label, ref selected, _labelScratch.AsSpan(0, values.Length)))
|
|
return;
|
|
|
|
if (selected < 0 || selected >= values.Length)
|
|
return;
|
|
|
|
set(values[selected]);
|
|
_plugin.SaveConfig();
|
|
}
|
|
}
|