From e786257cb31bfd390aac4d43d746611c9c21184c Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Fri, 29 May 2026 11:56:18 +0200 Subject: [PATCH] feat(config): add MainWindowLayoutMode + v22 migration; scaffold popout pool --- HellionChat/Configuration.cs | 22 ++++++- HellionChat/Plugin.cs | 4 +- .../SelfTests/ConfigMigrationV22Step.cs | 66 +++++++++++++++++++ HellionChat/Ui/Windows/ChannelPopoutPool.cs | 45 +++++++++++++ HellionChat/Ui/Windows/ChannelPopoutWindow.cs | 64 ++++++++++++++++++ HellionChat/Ui/Windows/PopoutSlotMap.cs | 55 ++++++++++++++++ 6 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 HellionChat/SelfTests/ConfigMigrationV22Step.cs create mode 100644 HellionChat/Ui/Windows/ChannelPopoutPool.cs create mode 100644 HellionChat/Ui/Windows/ChannelPopoutWindow.cs create mode 100644 HellionChat/Ui/Windows/PopoutSlotMap.cs diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index 358d79b..18c2787 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -35,7 +35,7 @@ public class ConfigKeyBind [Serializable] public class Configuration : IPluginConfiguration { - internal const int LatestVersion = 21; + internal const int LatestVersion = 22; public int Version { get; set; } = LatestVersion; @@ -262,11 +262,23 @@ public class Configuration : IPluginConfiguration public TellAutoOpenMode TellAutoOpenMode = TellAutoOpenMode.Sidebar; public int SidebarAutoSwitchThresholdPx = 800; + // v22 field: MainWindow layout mode (sidebar vs. horizontal top tabs). + // Initializer doubles as the migration default for configs loaded at v21. + public MainWindowLayoutMode MainWindowLayoutMode = MainWindowLayoutMode.Sidebar; + public void UpdateFrom(Configuration other, bool backToOriginal) { if (backToOriginal) + { + // NOTE (v1.8.0): this only flips the PopOut flag back. If a future + // caller ever wires UpdateFrom(backToOriginal: true) to a live + // settings-cancel path, that CALL-SITE must also iterate + // ChannelPopoutPool.TryClose over the affected Tab.Identifiers, + // otherwise pool windows stay IsOpen=true while the flag is false + // (orphan window). The pool is not reachable from this POCO by design. foreach (var tab in Tabs.Where(t => t.PopOut)) tab.PopOut = false; + } HideChat = other.HideChat; HideDuringCutscenes = other.HideDuringCutscenes; @@ -409,6 +421,7 @@ public class Configuration : IPluginConfiguration MaxParallelPopouts = other.MaxParallelPopouts; TellAutoOpenMode = other.TellAutoOpenMode; SidebarAutoSwitchThresholdPx = other.SidebarAutoSwitchThresholdPx; + MainWindowLayoutMode = other.MainWindowLayoutMode; } } @@ -421,6 +434,13 @@ public enum TellAutoOpenMode Popout, } +[Serializable] +public enum MainWindowLayoutMode +{ + Sidebar, + TopTabs, +} + [Serializable] public enum UnreadMode { diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index bbfad53..c9c2572 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -214,7 +214,7 @@ public sealed class Plugin : IAsyncDalamudPlugin + "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.10." ); } - Config.Version = 21; + Config.Version = 22; // Unpinned TempTabs are session-only and dropped on every load. Pinned // TempTabs survive reload — Jin's tester feedback (v1.4.7). @@ -347,7 +347,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.SettingsWindowOpenStep(this), new SelfTests.OnOpenMainUiRoutesMainWindowStep(this), new SelfTests.TypingIpcStateStep(this), - new SelfTests.ConfigMigrationV21Step(this), + new SelfTests.ConfigMigrationV22Step(this), new SelfTests.HoverSheenAllocStep(this), new SelfTests.HonorificHeaderRenderStep(this), new SelfTests.PerformanceBaselineStep(this), diff --git a/HellionChat/SelfTests/ConfigMigrationV22Step.cs b/HellionChat/SelfTests/ConfigMigrationV22Step.cs new file mode 100644 index 0000000..e307176 --- /dev/null +++ b/HellionChat/SelfTests/ConfigMigrationV22Step.cs @@ -0,0 +1,66 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; + +namespace HellionChat.SelfTests; + +// Pins the post-migration shape of the v22 config. By /xlperf time the schema +// gate has already stamped Config.Version = 22, so the v21 fields plus the new +// MainWindowLayoutMode must carry valid values here; this probe never rewrites config. +internal sealed class ConfigMigrationV22Step : ISelfTestStep +{ + public ConfigMigrationV22Step(Plugin plugin) + { + _ = plugin; + } + + public string Name => "Hellion Chat - Config v22 migration"; + + public SelfTestStepResult RunStep() + { + if (Plugin.Config.Version != 22) + { + ImGui.Text($"Config.Version is {Plugin.Config.Version}, expected 22"); + return SelfTestStepResult.Fail; + } + + if (Plugin.Config.MaxParallelPopouts <= 0) + { + ImGui.Text( + $"Config.MaxParallelPopouts is {Plugin.Config.MaxParallelPopouts}, must be > 0" + ); + return SelfTestStepResult.Fail; + } + + if (Plugin.Config.SidebarAutoSwitchThresholdPx <= 0) + { + ImGui.Text( + $"Config.SidebarAutoSwitchThresholdPx is {Plugin.Config.SidebarAutoSwitchThresholdPx}, must be > 0" + ); + return SelfTestStepResult.Fail; + } + + if (!Enum.IsDefined(Plugin.Config.TellAutoOpenMode)) + { + ImGui.Text($"Config.TellAutoOpenMode {Plugin.Config.TellAutoOpenMode} is out of range"); + return SelfTestStepResult.Fail; + } + + if (!Enum.IsDefined(Plugin.Config.MainWindowLayoutMode)) + { + ImGui.Text( + $"Config.MainWindowLayoutMode {Plugin.Config.MainWindowLayoutMode} is out of range" + ); + return SelfTestStepResult.Fail; + } + + // Touch-tests: declaration proves the migration emitted these with + // defaults; reading them confirms the property is reachable. + _ = Plugin.Config.MainWindowOpen; + _ = Plugin.Config.SettingsWindowOpen; + _ = Plugin.Config.ScreenshotMode; + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/Ui/Windows/ChannelPopoutPool.cs b/HellionChat/Ui/Windows/ChannelPopoutPool.cs new file mode 100644 index 0000000..5c97850 --- /dev/null +++ b/HellionChat/Ui/Windows/ChannelPopoutPool.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Logging; + +namespace HellionChat.Ui.Windows; + +// Central orchestration: pre-allocates Config.MaxParallelPopouts pop-out +// windows via the injected factory, all registered once in the WindowSystem +// (PluginLifecycle.RegisterWindows, framework thread). Open/Close is IsOpen + +// Bind/Unbind only — NEVER runtime AddWindow/RemoveWindow (v1.4.9 Stage-2 +// freeze lesson). Pure DI-sink: no PayloadHandler in the ctor (plan §B.2). +internal sealed class ChannelPopoutPool +{ + private readonly List _instances; + private readonly PopoutSlotMap _slots; + private readonly ILogger _logger; + + public ChannelPopoutPool( + Func windowFactory, + ILogger logger + ) + { + _logger = logger; + var capacity = Plugin.Config.MaxParallelPopouts; + _instances = new List(capacity); + for (var i = 0; i < capacity; i++) + _instances.Add(windowFactory(i)); + _slots = new PopoutSlotMap(capacity); + } + + // Iterated once by PluginLifecycle.RegisterWindows (framework thread) and + // by ChannelPopoutInitHostedService (PayloadHandler setter). + public IReadOnlyList Instances => _instances; + + public bool TryOpen(Tab tab) + { + // Filled in Phase B. + return false; + } + + public void TryClose(Guid id) + { + // Filled in Phase B. + } + + public bool IsOpen(Guid id) => _slots.IsActive(id); +} diff --git a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs new file mode 100644 index 0000000..c658629 --- /dev/null +++ b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs @@ -0,0 +1,64 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Windowing; +using HellionChat.Ui.Components; +using Microsoft.Extensions.Logging; + +namespace HellionChat.Ui.Windows; + +// One pre-allocated pop-out window bound to a single Tab. Pure DI-sink: the +// PayloadHandler arrives via AttachPayloadHandler (post-build setter), NEVER +// via ctor — see plan §B.2. The ###id carries the slot index so all N +// instances are unique for WindowSystem.AddWindow and ImGui state is stable +// per slot (not per bound tab). +internal sealed class ChannelPopoutWindow : Window +{ + private readonly int _slotIndex; + private readonly MessageList _messages; + private readonly InputBar _input; + private readonly ILogger _logger; + + public ChannelPopoutWindow( + int slotIndex, + MessageList messages, + InputBar input, + ILogger logger + ) + : base($"{Plugin.PluginName}###hellion_popout_{slotIndex}") + { + _slotIndex = slotIndex; + _messages = messages; + _input = input; + _logger = logger; + IsOpen = false; + RespectCloseHotkey = false; + } + + public int SlotIndex => _slotIndex; + + public Tab? Bound { get; private set; } + + // Post-build setter — see plan §B.2. Wired by ChannelPopoutInitHostedService. + public void AttachPayloadHandler(PayloadHandler handler) => + _messages.AttachPayloadHandler(handler); + + public void Bind(Tab tab) + { + // Filled in Phase B. + Bound = tab; + IsOpen = true; + } + + public void Unbind() + { + Bound = null; + IsOpen = false; + } + + public override void Draw() + { + // Filled in Phase B. Defensive guard so an unbound slot renders nothing. + if (Bound is null) + return; + } +} diff --git a/HellionChat/Ui/Windows/PopoutSlotMap.cs b/HellionChat/Ui/Windows/PopoutSlotMap.cs new file mode 100644 index 0000000..c1ff106 --- /dev/null +++ b/HellionChat/Ui/Windows/PopoutSlotMap.cs @@ -0,0 +1,55 @@ +namespace HellionChat.Ui.Windows; + +// Pure slot bookkeeping for the channel-popout pool: maps a tab's session +// identifier (Guid) to a fixed slot index. Deliberately Dalamud-free so the +// Build-Suite can unit-test reserve/release/capacity in isolation +// (Dalamud-coupled classes cannot be instantiated in the xUnit AppDomain). +internal sealed class PopoutSlotMap +{ + private readonly int _capacity; + private readonly Dictionary _active = new(); + private readonly bool[] _slotUsed; + + public PopoutSlotMap(int capacity) + { + _capacity = capacity < 0 ? 0 : capacity; + _slotUsed = new bool[_capacity]; + } + + public int Count => _active.Count; + + public bool IsActive(Guid id) => _active.ContainsKey(id); + + // Reserves the lowest free slot for id and returns its index. If id is + // already bound, returns its existing slot (idempotent re-open). Returns + // -1 when the pool is full. + public int TryReserve(Guid id) + { + if (_active.TryGetValue(id, out var existing)) + return existing; + + for (var i = 0; i < _capacity; i++) + { + if (!_slotUsed[i]) + { + _slotUsed[i] = true; + _active[id] = i; + return i; + } + } + + return -1; + } + + // Releases id's slot and returns its index, or -1 if id was not bound + // (idempotent no-op for unknown ids). + public int Release(Guid id) + { + if (!_active.TryGetValue(id, out var slot)) + return -1; + + _active.Remove(id); + _slotUsed[slot] = false; + return slot; + } +}