diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index 94427ac..4d83a50 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -472,6 +472,34 @@ public class Tab [NonSerialized] internal float _cardHoverAlpha; + // Copy-on-write for the three channel-filter fields. They are read without + // any lock from the pending-message thread, the filter worker and the draw + // thread, and until v1.12.0 nothing ever wrote them after load -- so the + // tab editor is their first writer, and mutating a live Dictionary while + // Matches enumerates it is the classic way to get a wrong answer or an + // exception on somebody else's thread. + // + // Building the replacements and swapping the references means a reader sees + // either the old set or the new one, never half of either. + // + // What this deliberately does not do is make the three writes one atomic + // step. A reader can catch the new dictionary with the old ExtraChat flag + // for a single message. That is harmless: the editor finishes by clearing + // and refiltering every tab, so any message placed by a mixed view is + // reconsidered a moment later. Making it truly atomic would mean one + // reference for all three, and these three are serialized fields with a + // shape the config file already has. + internal void ReplaceChannelFilter( + Dictionary selected, + bool extraChatAll, + HashSet extraChatChannels + ) + { + Volatile.Write(ref SelectedChannels, selected); + Volatile.Write(ref ExtraChatChannels, extraChatChannels); + Volatile.Write(ref ExtraChatAll, extraChatAll); + } + public bool Matches(Message message) { if (!message.Matches(SelectedChannels, ExtraChatAll, ExtraChatChannels)) diff --git a/HellionChat/Util/TabLifecycleHelpers.cs b/HellionChat/Util/TabLifecycleHelpers.cs index 093eec4..f08aaa6 100644 --- a/HellionChat/Util/TabLifecycleHelpers.cs +++ b/HellionChat/Util/TabLifecycleHelpers.cs @@ -13,6 +13,79 @@ internal static class TabLifecycleHelpers public static bool IsInPinnedPool(Tab t) => t.IsTempTab && t.IsPinned; + // A temp tab belongs to its conversation, not to the user's layout. It is + // created and dropped by the auto-tell service, its name is the partner, and + // deleting it in an editor would be deleting a conversation. Pinning is the + // only editing gesture it accepts, and that lives in the context menu. + public static bool IsEditable(Tab t) => !t.IsTempTab; + + // Where a new tab lands: after the last editable one, so it never appears + // among the temp tabs at the bottom of the list. + public static int InsertIndexForNewTab(IReadOnlyList tabs) + { + for (var i = tabs.Count - 1; i >= 0; i--) + if (IsEditable(tabs[i])) + return i + 1; + return 0; + } + + // Where an editable tab ends up when the user moves it by one step. + // + // Steps over temp tabs rather than swapping with them: a swap would push a + // conversation into the middle of the layout. Reversible in the editable + // order, which is the order the user sees -- the temp tab keeps its slot in + // the collection and the sidebar draws it under its own section header + // anyway. Returns the original index when there is no editable neighbour in + // that direction, which the caller reads as "no move". + public static int MoveIndex(IReadOnlyList tabs, int index, int delta) + { + if (index < 0 || index >= tabs.Count || delta == 0) + return index; + if (!IsEditable(tabs[index])) + return index; + + var step = Math.Sign(delta); + for (var i = index + step; i >= 0 && i < tabs.Count; i += step) + if (IsEditable(tabs[i])) + return i; + + return index; + } + + // Which tab the editor should select after deleting the one at index. + // + // The next editable tab below, else the one above, else nothing. Returning + // the index the caller should select *after* the removal, so it is already + // shifted. + public static int SelectionAfterDelete(IReadOnlyList tabs, int deletedIndex) + { + for (var i = deletedIndex + 1; i < tabs.Count; i++) + if (IsEditable(tabs[i])) + return i - 1; + + for (var i = deletedIndex - 1; i >= 0; i--) + if (IsEditable(tabs[i])) + return i; + + return -1; + } + + // Deleting the last editable tab leaves a window with nothing to draw, and + // the message list has no empty state. The editor offers templates instead + // of a delete in that situation. + public static bool CanDelete(IReadOnlyList tabs, int index) + { + if (index < 0 || index >= tabs.Count || !IsEditable(tabs[index])) + return false; + + var editable = 0; + foreach (var tab in tabs) + if (IsEditable(tab)) + editable++; + + return editable > 1; + } + public static bool ShouldStripOnLoad(Tab t) => IsInUnpinnedPool(t); public static bool ShouldStripOnSave(Tab t) => IsInUnpinnedPool(t);