feat(config): add MainWindowLayoutMode + v22 migration; scaffold popout pool

This commit is contained in:
2026-05-29 11:56:18 +02:00
parent b221a6e418
commit e786257cb3
6 changed files with 253 additions and 3 deletions
+21 -1
View File
@@ -35,7 +35,7 @@ public class ConfigKeyBind
[Serializable] [Serializable]
public class Configuration : IPluginConfiguration public class Configuration : IPluginConfiguration
{ {
internal const int LatestVersion = 21; internal const int LatestVersion = 22;
public int Version { get; set; } = LatestVersion; public int Version { get; set; } = LatestVersion;
@@ -262,11 +262,23 @@ public class Configuration : IPluginConfiguration
public TellAutoOpenMode TellAutoOpenMode = TellAutoOpenMode.Sidebar; public TellAutoOpenMode TellAutoOpenMode = TellAutoOpenMode.Sidebar;
public int SidebarAutoSwitchThresholdPx = 800; 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) public void UpdateFrom(Configuration other, bool backToOriginal)
{ {
if (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)) foreach (var tab in Tabs.Where(t => t.PopOut))
tab.PopOut = false; tab.PopOut = false;
}
HideChat = other.HideChat; HideChat = other.HideChat;
HideDuringCutscenes = other.HideDuringCutscenes; HideDuringCutscenes = other.HideDuringCutscenes;
@@ -409,6 +421,7 @@ public class Configuration : IPluginConfiguration
MaxParallelPopouts = other.MaxParallelPopouts; MaxParallelPopouts = other.MaxParallelPopouts;
TellAutoOpenMode = other.TellAutoOpenMode; TellAutoOpenMode = other.TellAutoOpenMode;
SidebarAutoSwitchThresholdPx = other.SidebarAutoSwitchThresholdPx; SidebarAutoSwitchThresholdPx = other.SidebarAutoSwitchThresholdPx;
MainWindowLayoutMode = other.MainWindowLayoutMode;
} }
} }
@@ -421,6 +434,13 @@ public enum TellAutoOpenMode
Popout, Popout,
} }
[Serializable]
public enum MainWindowLayoutMode
{
Sidebar,
TopTabs,
}
[Serializable] [Serializable]
public enum UnreadMode public enum UnreadMode
{ {
+2 -2
View File
@@ -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." + "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 // Unpinned TempTabs are session-only and dropped on every load. Pinned
// TempTabs survive reload — Jin's tester feedback (v1.4.7). // 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.SettingsWindowOpenStep(this),
new SelfTests.OnOpenMainUiRoutesMainWindowStep(this), new SelfTests.OnOpenMainUiRoutesMainWindowStep(this),
new SelfTests.TypingIpcStateStep(this), new SelfTests.TypingIpcStateStep(this),
new SelfTests.ConfigMigrationV21Step(this), new SelfTests.ConfigMigrationV22Step(this),
new SelfTests.HoverSheenAllocStep(this), new SelfTests.HoverSheenAllocStep(this),
new SelfTests.HonorificHeaderRenderStep(this), new SelfTests.HonorificHeaderRenderStep(this),
new SelfTests.PerformanceBaselineStep(this), new SelfTests.PerformanceBaselineStep(this),
@@ -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() { }
}
@@ -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<ChannelPopoutWindow> _instances;
private readonly PopoutSlotMap _slots;
private readonly ILogger<ChannelPopoutPool> _logger;
public ChannelPopoutPool(
Func<int, ChannelPopoutWindow> windowFactory,
ILogger<ChannelPopoutPool> logger
)
{
_logger = logger;
var capacity = Plugin.Config.MaxParallelPopouts;
_instances = new List<ChannelPopoutWindow>(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<ChannelPopoutWindow> 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);
}
@@ -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<ChannelPopoutWindow> _logger;
public ChannelPopoutWindow(
int slotIndex,
MessageList messages,
InputBar input,
ILogger<ChannelPopoutWindow> 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;
}
}
+55
View File
@@ -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<Guid, int> _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;
}
}