From db47708264c35f724492a8284cd83be78d7bf83b Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Fri, 29 May 2026 12:49:21 +0200 Subject: [PATCH] feat(popout): wire pool + window render + sidebar pop-out routing --- .../Hosting/InitHostedServices.cs | 20 ++++++ HellionChat/Plugin.cs | 2 + HellionChat/PluginHostFactory.cs | 36 ++++++++++- HellionChat/PluginLifecycle.cs | 5 ++ HellionChat/Ui/Components/Sidebar.cs | 26 +++----- HellionChat/Ui/Windows/ChannelPopoutPool.cs | 29 ++++++++- HellionChat/Ui/Windows/ChannelPopoutWindow.cs | 62 ++++++++++++++++++- 7 files changed, 156 insertions(+), 24 deletions(-) diff --git a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs index 49ed022..bc837ea 100644 --- a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs +++ b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs @@ -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; +} diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index c9c2572..4a06ccc 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -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(); DebuggerWindow = _host.Services.GetRequiredService(); FirstRunWizard = _host.Services.GetRequiredService(); + ChannelPopoutPool = _host.Services.GetRequiredService(); } public async Task LoadAsync(CancellationToken cancellationToken) diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index b0dbb14..a78a221 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -136,7 +136,8 @@ internal static class PluginHostFactory sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService>() + sp.GetRequiredService>(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.MessageList( sp.GetRequiredService(), @@ -248,6 +249,35 @@ internal static class PluginHostFactory services.AddSingleton(sp => MakePayloadHandler(sp)); services.AddSingleton(sp => new Lender(() => 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>(sp => + slot => new Ui.Windows.ChannelPopoutWindow( + slot, + new Ui.Components.MessageList( + sp.GetRequiredService(), + sp.GetRequiredService() + ), + new Ui.Components.InputBar( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + () => sp.GetRequiredService().SettingsWindow.Toggle(), + sp.GetRequiredService() + ), + sp.GetRequiredService>(), + sp.GetRequiredService() + ) + ); + services.AddSingleton(sp => new Ui.Windows.ChannelPopoutPool( + sp.GetRequiredService>(), + sp.GetRequiredService>() + )); + // 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(), sp.GetRequiredService() )); + services.AddHostedService(sp => new ChannelPopoutInitHostedService( + sp.GetRequiredService(), + sp.GetRequiredService() + )); } private static PayloadHandler MakePayloadHandler(IServiceProvider sp) => diff --git a/HellionChat/PluginLifecycle.cs b/HellionChat/PluginLifecycle.cs index 855d24a..369bd24 100644 --- a/HellionChat/PluginLifecycle.cs +++ b/HellionChat/PluginLifecycle.cs @@ -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() diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index cf0e6c9..4805829 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -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 _logger; + private readonly Windows.ChannelPopoutPool _pool; public Sidebar( ThemeRegistry themes, TokenResolver resolver, FontManager fonts, - ILogger logger + ILogger 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 - ); - } } diff --git a/HellionChat/Ui/Windows/ChannelPopoutPool.cs b/HellionChat/Ui/Windows/ChannelPopoutPool.cs index 5c97850..5f5b79c 100644 --- a/HellionChat/Ui/Windows/ChannelPopoutPool.cs +++ b/HellionChat/Ui/Windows/ChannelPopoutPool.cs @@ -12,6 +12,7 @@ internal sealed class ChannelPopoutPool private readonly List _instances; private readonly PopoutSlotMap _slots; private readonly ILogger _logger; + private readonly int _capacity; public ChannelPopoutPool( Func windowFactory, @@ -20,10 +21,17 @@ internal sealed class ChannelPopoutPool { _logger = logger; var capacity = Plugin.Config.MaxParallelPopouts; + _capacity = capacity; _instances = new List(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); diff --git a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs index c658629..337f73b 100644 --- a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs +++ b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs @@ -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 _logger; + private readonly FontManager _fonts; public ChannelPopoutWindow( int slotIndex, MessageList messages, InputBar input, - ILogger logger + ILogger 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? 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(); } }