feat(popout): wire pool + window render + sidebar pop-out routing
This commit is contained in:
@@ -164,3 +164,23 @@ internal sealed class CommandHelpWindowInitHostedService(
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Attaches the singleton PayloadHandler to every pre-allocated pop-out
|
||||
// window's MessageList post-container-build. Pool/window cannot take the
|
||||
// PayloadHandler via ctor (that would close the silent FactoryCallSite cycle —
|
||||
// same §6.2 reason as MessageList.AttachPayloadHandler / CommandHelpWindow.
|
||||
// AttachMainWindow). Both singletons exist by host.StartAsync time.
|
||||
internal sealed class ChannelPopoutInitHostedService(
|
||||
ChannelPopoutPool pool,
|
||||
PayloadHandler payloadHandler
|
||||
) : IHostedService
|
||||
{
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var window in pool.Instances)
|
||||
window.AttachPayloadHandler(payloadHandler);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
// consistent across all properties for clarity.
|
||||
internal Ui.Windows.MainWindow MainWindow { get; private set; } = null!;
|
||||
internal Ui.Windows.SettingsWindow SettingsWindow { get; private set; } = null!;
|
||||
internal Ui.Windows.ChannelPopoutPool ChannelPopoutPool { get; private set; } = null!;
|
||||
public DbViewer DbViewer { get; private set; } = null!;
|
||||
internal static InputPreview InputPreview { get; private set; } = null!;
|
||||
internal CommandHelpWindow CommandHelpWindow { get; private set; } = null!;
|
||||
@@ -302,6 +303,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
SeStringDebugger = _host.Services.GetRequiredService<SeStringDebugger>();
|
||||
DebuggerWindow = _host.Services.GetRequiredService<DebuggerWindow>();
|
||||
FirstRunWizard = _host.Services.GetRequiredService<FirstRunWizard>();
|
||||
ChannelPopoutPool = _host.Services.GetRequiredService<Ui.Windows.ChannelPopoutPool>();
|
||||
}
|
||||
|
||||
public async Task LoadAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -136,7 +136,8 @@ internal static class PluginHostFactory
|
||||
sp.GetRequiredService<ThemeRegistry>(),
|
||||
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>(),
|
||||
sp.GetRequiredService<FontManager>(),
|
||||
sp.GetRequiredService<ILogger<Ui.Components.Sidebar>>()
|
||||
sp.GetRequiredService<ILogger<Ui.Components.Sidebar>>(),
|
||||
sp.GetRequiredService<Ui.Windows.ChannelPopoutPool>()
|
||||
));
|
||||
services.AddSingleton(sp => new Ui.Components.MessageList(
|
||||
sp.GetRequiredService<FontManager>(),
|
||||
@@ -248,6 +249,35 @@ internal static class PluginHostFactory
|
||||
services.AddSingleton(sp => MakePayloadHandler(sp));
|
||||
services.AddSingleton(sp => new Lender<PayloadHandler>(() => MakePayloadHandler(sp)));
|
||||
|
||||
// Pop-out windows: each gets its OWN MessageList + InputBar so the
|
||||
// channel pill and message scroll are per-window. The PayloadHandler is
|
||||
// attached post-build (ChannelPopoutInitHostedService), NEVER via ctor
|
||||
// (plan §B.2 — would close a silent FactoryCallSite cycle).
|
||||
services.AddSingleton<Func<int, Ui.Windows.ChannelPopoutWindow>>(sp =>
|
||||
slot => new Ui.Windows.ChannelPopoutWindow(
|
||||
slot,
|
||||
new Ui.Components.MessageList(
|
||||
sp.GetRequiredService<FontManager>(),
|
||||
sp.GetRequiredService<Ui.Components.ChunkRenderer>()
|
||||
),
|
||||
new Ui.Components.InputBar(
|
||||
sp.GetRequiredService<Ui.Components.SymbolPicker>(),
|
||||
sp.GetRequiredService<FontManager>(),
|
||||
sp.GetRequiredService<ThemeRegistry>(),
|
||||
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>(),
|
||||
sp.GetRequiredService<ILogger<Ui.Components.InputBar>>(),
|
||||
() => sp.GetRequiredService<Plugin>().SettingsWindow.Toggle(),
|
||||
sp.GetRequiredService<Ui.CommandHelpWindow>()
|
||||
),
|
||||
sp.GetRequiredService<ILogger<Ui.Windows.ChannelPopoutWindow>>(),
|
||||
sp.GetRequiredService<FontManager>()
|
||||
)
|
||||
);
|
||||
services.AddSingleton(sp => new Ui.Windows.ChannelPopoutPool(
|
||||
sp.GetRequiredService<Func<int, Ui.Windows.ChannelPopoutWindow>>(),
|
||||
sp.GetRequiredService<ILogger<Ui.Windows.ChannelPopoutPool>>()
|
||||
));
|
||||
|
||||
// Block C — Windows. WindowSystem.AddWindow is called from
|
||||
// PluginLifecycle.LoadAsync on the framework thread.
|
||||
services.AddSingleton(sp => new Ui.Windows.SettingsWindow(
|
||||
@@ -327,6 +357,10 @@ internal static class PluginHostFactory
|
||||
sp.GetRequiredService<Ui.CommandHelpWindow>(),
|
||||
sp.GetRequiredService<Ui.Windows.MainWindow>()
|
||||
));
|
||||
services.AddHostedService(sp => new ChannelPopoutInitHostedService(
|
||||
sp.GetRequiredService<Ui.Windows.ChannelPopoutPool>(),
|
||||
sp.GetRequiredService<PayloadHandler>()
|
||||
));
|
||||
}
|
||||
|
||||
private static PayloadHandler MakePayloadHandler(IServiceProvider sp) =>
|
||||
|
||||
@@ -66,6 +66,11 @@ internal sealed class PluginLifecycle : IAsyncDisposable
|
||||
plugin.WindowSystem.AddWindow(plugin.SeStringDebugger);
|
||||
plugin.WindowSystem.AddWindow(plugin.DebuggerWindow);
|
||||
plugin.WindowSystem.AddWindow(plugin.FirstRunWizard);
|
||||
|
||||
// Pop-out pool: register all pre-allocated instances ONCE here on the
|
||||
// framework thread. Open/Close at runtime is IsOpen-only, never AddWindow.
|
||||
foreach (var popout in plugin.ChannelPopoutPool.Instances)
|
||||
plugin.WindowSystem.AddWindow(popout);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
|
||||
@@ -13,9 +13,9 @@ namespace HellionChat.Ui.Components;
|
||||
// Channel-list panel pinned to the left of the chat window. Auto-switches
|
||||
// between an icon-only column (38px) and an expanded column (150px) once
|
||||
// the outer window crosses Config.SidebarAutoSwitchThresholdPx. The
|
||||
// pop-out trigger is wired later (channel-popout cycle); the hover button
|
||||
// and right-click menu route through a log stub for now so the discovery
|
||||
// affordance is already in place.
|
||||
// pop-out affordance (hover button + right-click menu) routes through the
|
||||
// injected ChannelPopoutPool via TryOpen, which reserves a slot and binds
|
||||
// the tab to a pre-allocated pop-out window.
|
||||
internal sealed class Sidebar
|
||||
{
|
||||
public const float IconOnlyWidth = 38f;
|
||||
@@ -51,18 +51,21 @@ internal sealed class Sidebar
|
||||
private readonly TokenResolver _resolver;
|
||||
private readonly FontManager _fonts;
|
||||
private readonly ILogger<Sidebar> _logger;
|
||||
private readonly Windows.ChannelPopoutPool _pool;
|
||||
|
||||
public Sidebar(
|
||||
ThemeRegistry themes,
|
||||
TokenResolver resolver,
|
||||
FontManager fonts,
|
||||
ILogger<Sidebar> logger
|
||||
ILogger<Sidebar> logger,
|
||||
Windows.ChannelPopoutPool pool
|
||||
)
|
||||
{
|
||||
_themes = themes;
|
||||
_resolver = resolver;
|
||||
_fonts = fonts;
|
||||
_logger = logger;
|
||||
_pool = pool;
|
||||
}
|
||||
|
||||
public bool IsExpanded(float windowWidth) =>
|
||||
@@ -152,7 +155,7 @@ internal sealed class Sidebar
|
||||
if (ImGui.BeginPopupContextItem("ctx"))
|
||||
{
|
||||
if (ImGui.MenuItem("Pop Out"))
|
||||
LogPopOutStub(tab);
|
||||
_pool.TryOpen(tab);
|
||||
ImGui.EndPopup();
|
||||
}
|
||||
|
||||
@@ -163,7 +166,7 @@ internal sealed class Sidebar
|
||||
ImGui.InvisibleButton("popout", new Vector2(PopOutHitWidth, RowHeight));
|
||||
popHovered = ImGui.IsItemHovered();
|
||||
if (ImGui.IsItemClicked())
|
||||
LogPopOutStub(tab);
|
||||
_pool.TryOpen(tab);
|
||||
}
|
||||
|
||||
if (hasPopOut && (rowHovered || popHovered))
|
||||
@@ -268,15 +271,4 @@ internal sealed class Sidebar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LogPopOutStub(Tab tab)
|
||||
{
|
||||
// The channel-popout pool is built in a later cycle; logging here
|
||||
// keeps the trigger visible without faking the routing.
|
||||
_logger.LogInformation(
|
||||
"Pop-out requested for tab {Identifier} ({Name}); routing arrives later.",
|
||||
tab.Identifier,
|
||||
tab.Name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ internal sealed class ChannelPopoutPool
|
||||
private readonly List<ChannelPopoutWindow> _instances;
|
||||
private readonly PopoutSlotMap _slots;
|
||||
private readonly ILogger<ChannelPopoutPool> _logger;
|
||||
private readonly int _capacity;
|
||||
|
||||
public ChannelPopoutPool(
|
||||
Func<int, ChannelPopoutWindow> windowFactory,
|
||||
@@ -20,10 +21,17 @@ internal sealed class ChannelPopoutPool
|
||||
{
|
||||
_logger = logger;
|
||||
var capacity = Plugin.Config.MaxParallelPopouts;
|
||||
_capacity = capacity;
|
||||
_instances = new List<ChannelPopoutWindow>(capacity);
|
||||
for (var i = 0; i < capacity; i++)
|
||||
_instances.Add(windowFactory(i));
|
||||
_slots = new PopoutSlotMap(capacity);
|
||||
|
||||
// Route each window's in-body close through the pool so closing releases
|
||||
// the slot. Wired here (post-construction) rather than via ctor to avoid
|
||||
// a Window->Pool edge that would re-enter pool resolution (plan §B.2).
|
||||
foreach (var window in _instances)
|
||||
window.CloseRequested = TryClose;
|
||||
}
|
||||
|
||||
// Iterated once by PluginLifecycle.RegisterWindows (framework thread) and
|
||||
@@ -32,13 +40,28 @@ internal sealed class ChannelPopoutPool
|
||||
|
||||
public bool TryOpen(Tab tab)
|
||||
{
|
||||
// Filled in Phase B.
|
||||
return false;
|
||||
var slot = _slots.TryReserve(tab.Identifier);
|
||||
if (slot < 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Channel popout pool is full ({Capacity} slots); ignoring open for {Name}.",
|
||||
_capacity,
|
||||
tab.Name
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
_instances[slot].Bind(tab);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void TryClose(Guid id)
|
||||
{
|
||||
// Filled in Phase B.
|
||||
var slot = _slots.Release(id);
|
||||
if (slot < 0)
|
||||
return; // idempotent: unknown/unbound id is a silent no-op
|
||||
|
||||
_instances[slot].Unbind();
|
||||
}
|
||||
|
||||
public bool IsOpen(Guid id) => _slots.IsActive(id);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Numerics;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using Dalamud.Interface.Windowing;
|
||||
using HellionChat.Ui.Components;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -17,12 +19,14 @@ internal sealed class ChannelPopoutWindow : Window
|
||||
private readonly MessageList _messages;
|
||||
private readonly InputBar _input;
|
||||
private readonly ILogger<ChannelPopoutWindow> _logger;
|
||||
private readonly FontManager _fonts;
|
||||
|
||||
public ChannelPopoutWindow(
|
||||
int slotIndex,
|
||||
MessageList messages,
|
||||
InputBar input,
|
||||
ILogger<ChannelPopoutWindow> logger
|
||||
ILogger<ChannelPopoutWindow> logger,
|
||||
FontManager fonts
|
||||
)
|
||||
: base($"{Plugin.PluginName}###hellion_popout_{slotIndex}")
|
||||
{
|
||||
@@ -30,22 +34,38 @@ internal sealed class ChannelPopoutWindow : Window
|
||||
_messages = messages;
|
||||
_input = input;
|
||||
_logger = logger;
|
||||
_fonts = fonts;
|
||||
IsOpen = false;
|
||||
RespectCloseHotkey = false;
|
||||
ShowCloseButton = false;
|
||||
}
|
||||
|
||||
public int SlotIndex => _slotIndex;
|
||||
|
||||
public Tab? Bound { get; private set; }
|
||||
|
||||
// Wired post-build by ChannelPopoutPool so closing routes through the pool
|
||||
// (which owns the slot map). The window can't reach the pool by ctor without
|
||||
// a DI cycle, so the pool sets this after construction. See plan §B.2.
|
||||
public Action<Guid>? CloseRequested { get; 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;
|
||||
|
||||
var isTell = tab is { IsTempTab: true, TellTarget: { } target } && target.IsSet();
|
||||
// Master §4.3 default sizes: Tell is the more compact conversation window.
|
||||
Size = isTell ? new Vector2(380f, 320f) : new Vector2(420f, 320f);
|
||||
SizeCondition = ImGuiCond.FirstUseEver;
|
||||
|
||||
// Visible label tracks the bound tab; the ###id stays slot-stable so
|
||||
// ImGui keeps this slot's position/size across binds.
|
||||
WindowName = $"{tab.Name}###hellion_popout_{_slotIndex}";
|
||||
|
||||
IsOpen = true;
|
||||
}
|
||||
|
||||
@@ -57,8 +77,44 @@ internal sealed class ChannelPopoutWindow : Window
|
||||
|
||||
public override void Draw()
|
||||
{
|
||||
// Filled in Phase B. Defensive guard so an unbound slot renders nothing.
|
||||
if (Bound is null)
|
||||
return;
|
||||
|
||||
DrawHeader(Bound);
|
||||
|
||||
var inputHeight = InputBar.Height;
|
||||
using (
|
||||
var body = ImRaii.Child(
|
||||
$"##hellion-popout-body-{_slotIndex}",
|
||||
new Vector2(-1f, -inputHeight)
|
||||
)
|
||||
)
|
||||
{
|
||||
if (body.Success)
|
||||
_messages.Draw(Bound);
|
||||
}
|
||||
|
||||
_input.Draw(Bound);
|
||||
}
|
||||
|
||||
private void DrawHeader(Tab tab)
|
||||
{
|
||||
// Identifier + close action. Pop-In/Pin are wired in the same row; the
|
||||
// close button is the canonical "send the tab back" affordance for v1.8.0.
|
||||
// PartnerHonorific is deferred (HonorificService has no per-target title,
|
||||
// plan §D / Sub-Spec WARN-8) — no honorific row here.
|
||||
ImGui.TextUnformatted(tab.Name);
|
||||
ImGui.SameLine();
|
||||
using (_fonts.FontAwesome.Push())
|
||||
{
|
||||
ImGui.SameLine(ImGui.GetContentRegionAvail().X - ImGui.GetFrameHeight());
|
||||
if (ImGui.Button($"{FontAwesomeIcon.Times.ToIconString()}##popin-{_slotIndex}"))
|
||||
{
|
||||
// Pop-In: release the slot via the pool (not a bare Unbind, which
|
||||
// would orphan the slot — the pool owns the slot bookkeeping).
|
||||
CloseRequested?.Invoke(tab.Identifier);
|
||||
}
|
||||
}
|
||||
ImGui.Separator();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user