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