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;
|
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.
|
// consistent across all properties for clarity.
|
||||||
internal Ui.Windows.MainWindow MainWindow { get; private set; } = null!;
|
internal Ui.Windows.MainWindow MainWindow { get; private set; } = null!;
|
||||||
internal Ui.Windows.SettingsWindow SettingsWindow { 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!;
|
public DbViewer DbViewer { get; private set; } = null!;
|
||||||
internal static InputPreview InputPreview { get; private set; } = null!;
|
internal static InputPreview InputPreview { get; private set; } = null!;
|
||||||
internal CommandHelpWindow CommandHelpWindow { 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>();
|
SeStringDebugger = _host.Services.GetRequiredService<SeStringDebugger>();
|
||||||
DebuggerWindow = _host.Services.GetRequiredService<DebuggerWindow>();
|
DebuggerWindow = _host.Services.GetRequiredService<DebuggerWindow>();
|
||||||
FirstRunWizard = _host.Services.GetRequiredService<FirstRunWizard>();
|
FirstRunWizard = _host.Services.GetRequiredService<FirstRunWizard>();
|
||||||
|
ChannelPopoutPool = _host.Services.GetRequiredService<Ui.Windows.ChannelPopoutPool>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoadAsync(CancellationToken cancellationToken)
|
public async Task LoadAsync(CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ internal static class PluginHostFactory
|
|||||||
sp.GetRequiredService<ThemeRegistry>(),
|
sp.GetRequiredService<ThemeRegistry>(),
|
||||||
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>(),
|
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>(),
|
||||||
sp.GetRequiredService<FontManager>(),
|
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(
|
services.AddSingleton(sp => new Ui.Components.MessageList(
|
||||||
sp.GetRequiredService<FontManager>(),
|
sp.GetRequiredService<FontManager>(),
|
||||||
@@ -248,6 +249,35 @@ internal static class PluginHostFactory
|
|||||||
services.AddSingleton(sp => MakePayloadHandler(sp));
|
services.AddSingleton(sp => MakePayloadHandler(sp));
|
||||||
services.AddSingleton(sp => new Lender<PayloadHandler>(() => 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
|
// Block C — Windows. WindowSystem.AddWindow is called from
|
||||||
// PluginLifecycle.LoadAsync on the framework thread.
|
// PluginLifecycle.LoadAsync on the framework thread.
|
||||||
services.AddSingleton(sp => new Ui.Windows.SettingsWindow(
|
services.AddSingleton(sp => new Ui.Windows.SettingsWindow(
|
||||||
@@ -327,6 +357,10 @@ internal static class PluginHostFactory
|
|||||||
sp.GetRequiredService<Ui.CommandHelpWindow>(),
|
sp.GetRequiredService<Ui.CommandHelpWindow>(),
|
||||||
sp.GetRequiredService<Ui.Windows.MainWindow>()
|
sp.GetRequiredService<Ui.Windows.MainWindow>()
|
||||||
));
|
));
|
||||||
|
services.AddHostedService(sp => new ChannelPopoutInitHostedService(
|
||||||
|
sp.GetRequiredService<Ui.Windows.ChannelPopoutPool>(),
|
||||||
|
sp.GetRequiredService<PayloadHandler>()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PayloadHandler MakePayloadHandler(IServiceProvider sp) =>
|
private static PayloadHandler MakePayloadHandler(IServiceProvider sp) =>
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ internal sealed class PluginLifecycle : IAsyncDisposable
|
|||||||
plugin.WindowSystem.AddWindow(plugin.SeStringDebugger);
|
plugin.WindowSystem.AddWindow(plugin.SeStringDebugger);
|
||||||
plugin.WindowSystem.AddWindow(plugin.DebuggerWindow);
|
plugin.WindowSystem.AddWindow(plugin.DebuggerWindow);
|
||||||
plugin.WindowSystem.AddWindow(plugin.FirstRunWizard);
|
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()
|
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
|
// 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
|
// between an icon-only column (38px) and an expanded column (150px) once
|
||||||
// the outer window crosses Config.SidebarAutoSwitchThresholdPx. The
|
// the outer window crosses Config.SidebarAutoSwitchThresholdPx. The
|
||||||
// pop-out trigger is wired later (channel-popout cycle); the hover button
|
// pop-out affordance (hover button + right-click menu) routes through the
|
||||||
// and right-click menu route through a log stub for now so the discovery
|
// injected ChannelPopoutPool via TryOpen, which reserves a slot and binds
|
||||||
// affordance is already in place.
|
// the tab to a pre-allocated pop-out window.
|
||||||
internal sealed class Sidebar
|
internal sealed class Sidebar
|
||||||
{
|
{
|
||||||
public const float IconOnlyWidth = 38f;
|
public const float IconOnlyWidth = 38f;
|
||||||
@@ -51,18 +51,21 @@ internal sealed class Sidebar
|
|||||||
private readonly TokenResolver _resolver;
|
private readonly TokenResolver _resolver;
|
||||||
private readonly FontManager _fonts;
|
private readonly FontManager _fonts;
|
||||||
private readonly ILogger<Sidebar> _logger;
|
private readonly ILogger<Sidebar> _logger;
|
||||||
|
private readonly Windows.ChannelPopoutPool _pool;
|
||||||
|
|
||||||
public Sidebar(
|
public Sidebar(
|
||||||
ThemeRegistry themes,
|
ThemeRegistry themes,
|
||||||
TokenResolver resolver,
|
TokenResolver resolver,
|
||||||
FontManager fonts,
|
FontManager fonts,
|
||||||
ILogger<Sidebar> logger
|
ILogger<Sidebar> logger,
|
||||||
|
Windows.ChannelPopoutPool pool
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_themes = themes;
|
_themes = themes;
|
||||||
_resolver = resolver;
|
_resolver = resolver;
|
||||||
_fonts = fonts;
|
_fonts = fonts;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_pool = pool;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsExpanded(float windowWidth) =>
|
public bool IsExpanded(float windowWidth) =>
|
||||||
@@ -152,7 +155,7 @@ internal sealed class Sidebar
|
|||||||
if (ImGui.BeginPopupContextItem("ctx"))
|
if (ImGui.BeginPopupContextItem("ctx"))
|
||||||
{
|
{
|
||||||
if (ImGui.MenuItem("Pop Out"))
|
if (ImGui.MenuItem("Pop Out"))
|
||||||
LogPopOutStub(tab);
|
_pool.TryOpen(tab);
|
||||||
ImGui.EndPopup();
|
ImGui.EndPopup();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +166,7 @@ internal sealed class Sidebar
|
|||||||
ImGui.InvisibleButton("popout", new Vector2(PopOutHitWidth, RowHeight));
|
ImGui.InvisibleButton("popout", new Vector2(PopOutHitWidth, RowHeight));
|
||||||
popHovered = ImGui.IsItemHovered();
|
popHovered = ImGui.IsItemHovered();
|
||||||
if (ImGui.IsItemClicked())
|
if (ImGui.IsItemClicked())
|
||||||
LogPopOutStub(tab);
|
_pool.TryOpen(tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPopOut && (rowHovered || popHovered))
|
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 List<ChannelPopoutWindow> _instances;
|
||||||
private readonly PopoutSlotMap _slots;
|
private readonly PopoutSlotMap _slots;
|
||||||
private readonly ILogger<ChannelPopoutPool> _logger;
|
private readonly ILogger<ChannelPopoutPool> _logger;
|
||||||
|
private readonly int _capacity;
|
||||||
|
|
||||||
public ChannelPopoutPool(
|
public ChannelPopoutPool(
|
||||||
Func<int, ChannelPopoutWindow> windowFactory,
|
Func<int, ChannelPopoutWindow> windowFactory,
|
||||||
@@ -20,10 +21,17 @@ internal sealed class ChannelPopoutPool
|
|||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
var capacity = Plugin.Config.MaxParallelPopouts;
|
var capacity = Plugin.Config.MaxParallelPopouts;
|
||||||
|
_capacity = capacity;
|
||||||
_instances = new List<ChannelPopoutWindow>(capacity);
|
_instances = new List<ChannelPopoutWindow>(capacity);
|
||||||
for (var i = 0; i < capacity; i++)
|
for (var i = 0; i < capacity; i++)
|
||||||
_instances.Add(windowFactory(i));
|
_instances.Add(windowFactory(i));
|
||||||
_slots = new PopoutSlotMap(capacity);
|
_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
|
// Iterated once by PluginLifecycle.RegisterWindows (framework thread) and
|
||||||
@@ -32,13 +40,28 @@ internal sealed class ChannelPopoutPool
|
|||||||
|
|
||||||
public bool TryOpen(Tab tab)
|
public bool TryOpen(Tab tab)
|
||||||
{
|
{
|
||||||
// Filled in Phase B.
|
var slot = _slots.TryReserve(tab.Identifier);
|
||||||
return false;
|
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)
|
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);
|
public bool IsOpen(Guid id) => _slots.IsActive(id);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using Dalamud.Bindings.ImGui;
|
using Dalamud.Bindings.ImGui;
|
||||||
|
using Dalamud.Interface;
|
||||||
|
using Dalamud.Interface.Utility.Raii;
|
||||||
using Dalamud.Interface.Windowing;
|
using Dalamud.Interface.Windowing;
|
||||||
using HellionChat.Ui.Components;
|
using HellionChat.Ui.Components;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -17,12 +19,14 @@ internal sealed class ChannelPopoutWindow : Window
|
|||||||
private readonly MessageList _messages;
|
private readonly MessageList _messages;
|
||||||
private readonly InputBar _input;
|
private readonly InputBar _input;
|
||||||
private readonly ILogger<ChannelPopoutWindow> _logger;
|
private readonly ILogger<ChannelPopoutWindow> _logger;
|
||||||
|
private readonly FontManager _fonts;
|
||||||
|
|
||||||
public ChannelPopoutWindow(
|
public ChannelPopoutWindow(
|
||||||
int slotIndex,
|
int slotIndex,
|
||||||
MessageList messages,
|
MessageList messages,
|
||||||
InputBar input,
|
InputBar input,
|
||||||
ILogger<ChannelPopoutWindow> logger
|
ILogger<ChannelPopoutWindow> logger,
|
||||||
|
FontManager fonts
|
||||||
)
|
)
|
||||||
: base($"{Plugin.PluginName}###hellion_popout_{slotIndex}")
|
: base($"{Plugin.PluginName}###hellion_popout_{slotIndex}")
|
||||||
{
|
{
|
||||||
@@ -30,22 +34,38 @@ internal sealed class ChannelPopoutWindow : Window
|
|||||||
_messages = messages;
|
_messages = messages;
|
||||||
_input = input;
|
_input = input;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_fonts = fonts;
|
||||||
IsOpen = false;
|
IsOpen = false;
|
||||||
RespectCloseHotkey = false;
|
RespectCloseHotkey = false;
|
||||||
|
ShowCloseButton = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int SlotIndex => _slotIndex;
|
public int SlotIndex => _slotIndex;
|
||||||
|
|
||||||
public Tab? Bound { get; private set; }
|
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.
|
// Post-build setter — see plan §B.2. Wired by ChannelPopoutInitHostedService.
|
||||||
public void AttachPayloadHandler(PayloadHandler handler) =>
|
public void AttachPayloadHandler(PayloadHandler handler) =>
|
||||||
_messages.AttachPayloadHandler(handler);
|
_messages.AttachPayloadHandler(handler);
|
||||||
|
|
||||||
public void Bind(Tab tab)
|
public void Bind(Tab tab)
|
||||||
{
|
{
|
||||||
// Filled in Phase B.
|
|
||||||
Bound = tab;
|
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;
|
IsOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,8 +77,44 @@ internal sealed class ChannelPopoutWindow : Window
|
|||||||
|
|
||||||
public override void Draw()
|
public override void Draw()
|
||||||
{
|
{
|
||||||
// Filled in Phase B. Defensive guard so an unbound slot renders nothing.
|
|
||||||
if (Bound is null)
|
if (Bound is null)
|
||||||
return;
|
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