feat(popout): wire pool + window render + sidebar pop-out routing

This commit is contained in:
2026-05-29 12:49:21 +02:00
parent e786257cb3
commit db47708264
7 changed files with 156 additions and 24 deletions
@@ -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;
}
+2
View File
@@ -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)
+35 -1
View File
@@ -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) =>
+5
View File
@@ -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()
+9 -17
View File
@@ -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
);
}
}
+26 -3
View File
@@ -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);
+59 -3
View File
@@ -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();
}
}