feat(config): add MainWindowLayoutMode + v22 migration; scaffold popout pool
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user