From bbfb9fc630902fc75cddf9989d53eb6a94cae6bc Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 06:50:23 +0200 Subject: [PATCH] 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. --- HellionChat/Configuration.cs | 22 +- .../Ui/Components/Settings/TabEditor.cs | 419 ++++++++++++++++++ .../Components/Settings/Tabs/ChannelsTab.cs | 7 + 3 files changed, 434 insertions(+), 14 deletions(-) create mode 100644 HellionChat/Ui/Components/Settings/TabEditor.cs diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index 4d83a50..731355f 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -185,12 +185,6 @@ public class Configuration : IPluginConfiguration public bool HideInNewGamePlusMenu = true; public bool HideWhenInactive; - [Obsolete("Use InactivityHideChannelsV2 instead")] - public Dictionary InactivityHideChannels = []; - - public Dictionary InactivityHideChannelsV2 = []; - public bool InactivityHideExtraChatAll = true; - public HashSet 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(); diff --git a/HellionChat/Ui/Components/Settings/TabEditor.cs b/HellionChat/Ui/Components/Settings/TabEditor.cs new file mode 100644 index 0000000..739ed1a --- /dev/null +++ b/HellionChat/Ui/Components/Settings/TabEditor.cs @@ -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? _workingChannels; + private HashSet? _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 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 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 Label, Func 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 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 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 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.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(tab.SelectedChannels); + _workingExtraChat = new HashSet(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(); + }); +} diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs index a4deb90..8b1965f 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs @@ -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),