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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user