feat(popout): wire pool + window render + sidebar pop-out routing
This commit is contained in:
@@ -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