feat(tabs): the tab editor is back

There has been no way to create a tab, delete one, reorder them, or
choose what any of them collects since the settings window was rebuilt
in May. The five tabs a config happened to have were all a user could
ever have. Every label for this was sitting in the resources, translated
into 25 languages, with no caller.

An accordion, not the list-and-detail pane the plan sketched. The
settings column is narrow, every other tab in this window is a stack of
collapsible sections, and a split pane inside one of them would be the
only thing here that reads differently without buying anything.

Channels go through the matrix that already existed, unchanged: it knows
the groups, the sub-matrices and the ExtraChat channels, and it is
localised. What it does not know is copy-on-write -- it mutates the
dictionary it is handed -- so it never gets the tab's own. Edits land in
a working copy and are published as one reference swap when the user
leaves the tab, and only if something actually changed.

Saving is deferred behind a dirty flag with a short idle, not
IsItemDeactivatedAfterEdit. That idiom defers for sliders and text
fields, which stay active across frames; a checkbox activates and
deactivates inside one click, so it would fire exactly as often as the
return value and write the config file once per box.

Deleting closes the pop-out first, or the pool keeps a slot bound to a
tab that no longer exists. The last editable tab cannot be deleted at
all: the message list has no empty state. Temp tabs are not editable
here -- their name is a conversation partner and the auto-tell service
owns their lifetime -- so they are skipped entirely.

Also E2: Tab.AddMessage stamps LastActivity for every message now. The
condition that used to gate it filtered on InactivityHideChannels, a
setting belonging to hide-when-inactive, and that feature lost its
reader in cf4705e. Which tell tab the pool drops first -- the only thing
that reads the stamp -- was hanging on a setting for something that does
not happen. The three channel fields behind it are gone.
This commit is contained in:
2026-08-19 06:50:23 +02:00
parent 3a1b863def
commit bbfb9fc630
3 changed files with 434 additions and 14 deletions
+8 -14
View File
@@ -185,12 +185,6 @@ public class Configuration : IPluginConfiguration
public bool HideInNewGamePlusMenu = true;
public bool HideWhenInactive;
[Obsolete("Use InactivityHideChannelsV2 instead")]
public Dictionary<ChatType, ChatSource> InactivityHideChannels = [];
public Dictionary<ChatType, (ChatSource, ChatSource)> InactivityHideChannelsV2 = [];
public bool InactivityHideExtraChatAll = true;
public HashSet<Guid> InactivityHideExtraChatChannels = [];
public bool ShowHideButton = true;
public bool NativeItemTooltips = true;
public bool ScreenshotMode;
@@ -520,14 +514,14 @@ public class Tab
return;
Unread += 1;
if (
message.Matches(
Plugin.Config.InactivityHideChannelsV2,
Plugin.Config.InactivityHideExtraChatAll,
Plugin.Config.InactivityHideExtraChatChannels
)
)
LastActivity = Environment.TickCount64;
// Stamped for every message now. The condition that used to sit here
// filtered on InactivityHideChannels, a setting for the hide-when-
// inactive feature -- and that feature lost its reader in cf4705e. So
// which tell tab the auto-tell pool drops first, which is the only
// thing that reads this stamp, hung on a setting for something that
// does not happen.
LastActivity = Environment.TickCount64;
}
public void Clear() => Messages.Clear();
@@ -0,0 +1,419 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using HellionChat.Code;
using HellionChat.Resources;
using HellionChat.Util;
namespace HellionChat.Ui.Components.Settings;
// The only way to create a tab, delete one, reorder them, or choose what a tab
// collects. All of it went out with the settings window in May; the labels
// stayed, translated, in all 25 languages.
//
// An accordion rather than the list-and-detail pane the plan sketched. The
// settings content column is narrow, every other tab in this window is a stack
// of collapsible sections, and a split pane inside one of them would be the only
// thing here that reads differently for no gain.
internal sealed class TabEditor
{
private readonly Plugin _plugin;
// The channel matrix mutates the dictionary it is handed, so it never gets
// the tab's own. Edits land here and are published as one reference swap
// when the user leaves the tab -- see Tab.ReplaceChannelFilter for why a
// half-mutated dictionary is worse than a stale one.
private Guid _editing;
private Dictionary<ChatType, (ChatSource, ChatSource)>? _workingChannels;
private HashSet<Guid>? _workingExtraChat;
private bool _workingExtraChatAll;
// Saving on every checkbox would write the config file sixty-odd times per
// matrix. IsItemDeactivatedAfterEdit does not help here: it defers for
// sliders and text fields, which stay active across frames, but a checkbox
// activates and deactivates inside one click, so the event fires exactly as
// often as the return value.
private bool _dirty;
private long _dirtyAt;
private const long SaveIdleMs = 600;
public TabEditor(Plugin plugin) => _plugin = plugin;
public void Draw()
{
List<Tab> tabs;
lock (Plugin.Instance.TabsListLock)
tabs = Plugin.Config.Tabs.ToList();
DrawToolbar(tabs);
ImGui.Spacing();
for (var i = 0; i < tabs.Count; i++)
DrawTabNode(tabs, i);
FlushIfIdle();
}
private void DrawToolbar(List<Tab> tabs)
{
if (ImGuiUtil.IconButton(FontAwesomeIcon.Plus, tooltip: Language.Options_Tabs_Add))
ImGui.OpenPopup("##hc-add-tab");
using var popup = ImRaii.Popup("##hc-add-tab");
if (!popup.Success)
return;
if (ImGui.Selectable(Language.Options_Tabs_NewTab))
Insert(new Tab());
ImGui.Separator();
// Templates that have sat in TabsUtil without a caller. A new tab with
// no channels selected collects nothing, so an empty one is the worst
// possible starting point for anybody who has not read the matrix yet.
foreach (var (label, factory) in Presets)
{
if (ImGui.Selectable(string.Format(Language.Options_Tabs_Preset, label())))
Insert(factory());
}
}
private static readonly (Func<string> Label, Func<Tab> Factory)[] Presets =
[
(() => HellionStrings.Tabs_Presets_Party, () => TabsUtil.HellionParty),
(() => HellionStrings.Tabs_Presets_FreeCompany, () => TabsUtil.HellionFreeCompany),
(() => HellionStrings.Tabs_Presets_Linkshell, () => TabsUtil.HellionLinkshell),
(() => HellionStrings.Tabs_Presets_System, () => TabsUtil.HellionSystem),
(() => HellionStrings.Tabs_Presets_Beginner, () => TabsUtil.HellionBeginner),
];
private void Insert(Tab tab)
{
lock (Plugin.Instance.TabsListLock)
Plugin.Config.Tabs.Insert(
TabLifecycleHelpers.InsertIndexForNewTab(Plugin.Config.Tabs),
tab
);
_plugin.SaveConfig();
RequestRefilter();
}
private void DrawTabNode(List<Tab> tabs, int index)
{
var tab = tabs[index];
// Temp tabs share the list but not the editor: their name is a
// conversation partner, the auto-tell service owns their lifetime, and
// deleting one here would mean deleting a conversation. Pinning is the
// gesture they accept, and that lives in the context menu.
if (!TabLifecycleHelpers.IsEditable(tab))
return;
using var id = ImRaii.PushId(tab.Identifier.ToString());
// ### keeps the node's identity while its label follows the name field.
using var node = ImRaii.TreeNode($"{tab.Name}###hc-tab-node");
if (!node.Success)
{
// Collapsing is a leave: publish whatever was edited in here.
if (_editing == tab.Identifier)
CommitChannels(tab);
return;
}
DrawRowButtons(tabs, index, tab);
ImGui.Spacing();
var name = tab.Name;
ImGui.SetNextItemWidth(240f * ImGuiHelpers.GlobalScale);
if (ImGui.InputText(Language.Options_Tabs_Name, ref name, 512))
{
tab.Name = name;
MarkDirty();
}
DrawIconPicker(tab);
DrawDisplay(tab);
DrawChannels(tab);
}
private void DrawRowButtons(List<Tab> tabs, int index, Tab tab)
{
var canDelete = TabLifecycleHelpers.CanDelete(tabs, index);
using (ImRaii.Disabled(!canDelete))
{
if (
ImGuiUtil.IconButton(
FontAwesomeIcon.TrashAlt,
tooltip: Language.Options_Tabs_Delete
) && canDelete
)
{
Delete(tab, tabs, index);
return;
}
}
ImGui.SameLine();
if (ImGuiUtil.IconButton(FontAwesomeIcon.ArrowUp, tooltip: Language.Options_Tabs_MoveUp))
Move(index, -1);
ImGui.SameLine();
if (
ImGuiUtil.IconButton(FontAwesomeIcon.ArrowDown, tooltip: Language.Options_Tabs_MoveDown)
)
Move(index, +1);
// Duplicating is the cheapest way to build a variant of a tab that
// already has its sixty channels picked, and Tab.Clone has been ready
// for it since v1.8.0.
ImGui.SameLine();
if (ImGuiUtil.IconButton(FontAwesomeIcon.Copy, tooltip: Language.Options_Tabs_Add))
{
var copy = tab.Clone();
copy.Identifier = Guid.NewGuid();
Insert(copy);
}
}
private void Delete(Tab tab, List<Tab> tabs, int index)
{
// The pool binds windows by identifier; without this the slot stays
// taken by a tab that no longer exists.
_plugin.ChannelPopoutPool.TryClose(tab.Identifier);
lock (Plugin.Instance.TabsListLock)
Plugin.Config.Tabs.RemoveAll(t => t.Identifier == tab.Identifier);
if (_editing == tab.Identifier)
ClearWorking();
_ = TabLifecycleHelpers.SelectionAfterDelete(tabs, index);
_plugin.SaveConfig();
RequestRefilter();
}
private void Move(int index, int delta)
{
lock (Plugin.Instance.TabsListLock)
{
var list = Plugin.Config.Tabs;
var target = TabLifecycleHelpers.MoveIndex(list, index, delta);
if (target == index)
return;
var tab = list[index];
list.RemoveAt(index);
list.Insert(target, tab);
}
_plugin.SaveConfig();
}
private void DrawIconPicker(Tab tab)
{
var current = tab.Icon ?? HellionStrings.Tabs_Icon_DefaultOption;
using (var combo = ImGuiUtil.BeginComboVertical(HellionStrings.Tabs_Icon_Label, current))
{
if (combo.Success)
{
if (ImGui.Selectable(HellionStrings.Tabs_Icon_DefaultOption, tab.Icon is null))
{
tab.Icon = null;
MarkDirty();
}
foreach (var glyph in IconNames)
{
if (!ImGui.Selectable(glyph, tab.Icon == glyph))
continue;
tab.Icon = glyph;
MarkDirty();
}
}
}
ImGuiUtil.HelpMarker(HellionStrings.Tabs_Icon_HelpMarker);
}
// Same set the sidebar resolves; a name outside it falls back to the
// channel-derived glyph rather than showing nothing.
private static readonly string[] IconNames =
[
"comment",
"comments",
"cog",
"users",
"user-friends",
"link",
"envelope",
"clock",
"hashtag",
"star",
"heart",
"bell",
"bookmark",
"flag",
"fire",
];
private void DrawDisplay(Tab tab)
{
using var node = ImRaii.TreeNode(
$"{HellionStrings.Settings_Section_Tab_Display}###hc-tab-display"
);
if (!node.Success)
return;
using var indent = ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false);
if (ImGui.Checkbox(Language.Options_Tabs_ShowTimestamps, ref tab.DisplayTimestamp))
MarkDirty();
using (
var combo = ImGuiUtil.BeginComboVertical(
Language.Options_Tabs_UnreadMode,
tab.UnreadMode.Name()
)
)
{
if (combo.Success)
{
foreach (var mode in EnumValues<UnreadMode>.All)
{
if (ImGui.Selectable(mode.Name(), tab.UnreadMode == mode))
{
tab.UnreadMode = mode;
MarkDirty();
}
if (mode.Tooltip() is { } tooltip && ImGui.IsItemHovered())
ImGuiUtil.Tooltip(tooltip);
}
}
}
if (ImGui.Checkbox(Language.Options_Tabs_SenderMessages, ref tab.AllSenderMessages))
MarkDirty();
}
private void DrawChannels(Tab tab)
{
using var node = ImRaii.TreeNode(
$"{HellionStrings.Settings_Section_Tab_Channels}###hc-tab-channels"
);
if (!node.Success)
{
if (_editing == tab.Identifier)
CommitChannels(tab);
return;
}
// Switching tabs without collapsing the previous one still has to
// publish it, or the edits sit in a working copy nobody reads again.
if (_editing != tab.Identifier)
{
CommitPending();
SeedWorking(tab);
}
using var indent = ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false);
ImGuiUtil.ChannelSelector(Language.Options_Tabs_Channels, _workingChannels!);
ImGuiUtil.ExtraChatSelector(
Language.Options_Tabs_ExtraChatChannels,
ref _workingExtraChatAll,
_workingExtraChat!
);
}
private void SeedWorking(Tab tab)
{
_editing = tab.Identifier;
_workingChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>(tab.SelectedChannels);
_workingExtraChat = new HashSet<Guid>(tab.ExtraChatChannels);
_workingExtraChatAll = tab.ExtraChatAll;
}
private void CommitPending()
{
if (_editing == Guid.Empty)
return;
Tab? tab;
lock (Plugin.Instance.TabsListLock)
tab = Plugin.Config.Tabs.FirstOrDefault(t => t.Identifier == _editing);
if (tab is not null)
CommitChannels(tab);
else
ClearWorking();
}
private void CommitChannels(Tab tab)
{
if (_workingChannels is null || _workingExtraChat is null)
{
ClearWorking();
return;
}
var changed =
_workingExtraChatAll != tab.ExtraChatAll
|| !_workingExtraChat.SetEquals(tab.ExtraChatChannels)
|| _workingChannels.Count != tab.SelectedChannels.Count
|| _workingChannels.Any(p =>
!tab.SelectedChannels.TryGetValue(p.Key, out var v) || v != p.Value
);
if (changed)
{
tab.ReplaceChannelFilter(_workingChannels, _workingExtraChatAll, _workingExtraChat);
_plugin.SaveConfig();
// Deselecting a channel needs a rebuild, not a filter pass:
// AddSortPrune deduplicates and never removes, so the messages that
// no longer match are already in the list.
RequestRefilter();
}
ClearWorking();
}
private void ClearWorking()
{
_editing = Guid.Empty;
_workingChannels = null;
_workingExtraChat = null;
_workingExtraChatAll = false;
}
private void MarkDirty()
{
_dirty = true;
_dirtyAt = Environment.TickCount64;
}
private void FlushIfIdle()
{
if (!_dirty || _dirtyAt + SaveIdleMs > Environment.TickCount64)
return;
_dirty = false;
_plugin.SaveConfig();
}
// RunOnTick does not offload: it runs on the framework thread, just at the
// start of a later tick rather than inside this draw. That is the point --
// clearing every tab mid-frame is where the hitch would come from. The
// refilter itself then goes to the thread pool.
private void RequestRefilter() =>
Plugin.Framework.RunOnTick(() =>
{
_plugin.MessageManager.ClearAllTabs();
_plugin.MessageManager.FilterAllTabsAsync();
});
}
@@ -7,14 +7,21 @@ namespace HellionChat.Ui.Components.Settings.Tabs;
internal sealed class ChannelsTab
{
private readonly SettingsWidgets _w;
private readonly TabEditor _editor;
public ChannelsTab(Plugin plugin, TokenResolver resolver)
{
_w = new SettingsWidgets(plugin, new SettingsPalette(resolver));
_editor = new TabEditor(plugin);
}
public void Draw()
{
// First, because it answers the question the rest of this tab assumes
// is already settled: which tabs exist and what do they collect.
if (_w.Section(ImGui.GetID("channels.tabs"u8), Language.Options_Tabs_Tab))
_editor.Draw();
if (
_w.Section(
ImGui.GetID("channels.autotell"u8),