mirror of
https://github.com/goatcorp/Dalamud.git
synced 2025-12-12 10:17:22 +01:00
Add standalone testbed
This commit is contained in:
parent
5ddf473450
commit
b8ce2d4001
21 changed files with 948 additions and 2 deletions
|
|
@ -70,6 +70,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dalamud.Bindings.ImPlot", "
|
|||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Bindings", "Bindings", "{A217B3DF-607A-4EFB-B107-3C4809348043}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandaloneImGuiTestbed", "imgui\StandaloneImGuiTestbed\StandaloneImGuiTestbed.csproj", "{4702A911-2513-478C-A434-2776393FDE77}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
|
@ -156,6 +158,10 @@ Global
|
|||
{9C70BD06-D52C-425E-9C14-5D66BC6046EF}.Debug|Any CPU.Build.0 = Debug|x64
|
||||
{9C70BD06-D52C-425E-9C14-5D66BC6046EF}.Release|Any CPU.ActiveCfg = Release|x64
|
||||
{9C70BD06-D52C-425E-9C14-5D66BC6046EF}.Release|Any CPU.Build.0 = Release|x64
|
||||
{4702A911-2513-478C-A434-2776393FDE77}.Debug|Any CPU.ActiveCfg = Debug|x64
|
||||
{4702A911-2513-478C-A434-2776393FDE77}.Debug|Any CPU.Build.0 = Debug|x64
|
||||
{4702A911-2513-478C-A434-2776393FDE77}.Release|Any CPU.ActiveCfg = Release|x64
|
||||
{4702A911-2513-478C-A434-2776393FDE77}.Release|Any CPU.Build.0 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
|
@ -177,6 +183,7 @@ Global
|
|||
{B0AA8737-33A3-4766-8CBE-A48F2EF283BA} = {A217B3DF-607A-4EFB-B107-3C4809348043}
|
||||
{5E6EDD75-AE95-43A6-9D67-95B840EB4B71} = {A217B3DF-607A-4EFB-B107-3C4809348043}
|
||||
{9C70BD06-D52C-425E-9C14-5D66BC6046EF} = {A217B3DF-607A-4EFB-B107-3C4809348043}
|
||||
{4702A911-2513-478C-A434-2776393FDE77} = {A217B3DF-607A-4EFB-B107-3C4809348043}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {79B65AC9-C940-410E-AB61-7EA7E12C7599}
|
||||
|
|
|
|||
|
|
@ -154,7 +154,6 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
|
|||
// Handle cases where ImGui.AlignTextToFramePadding has been called.
|
||||
var context = ImGui.GetCurrentContext();
|
||||
var currLineTextBaseOffset = 0f;
|
||||
/*
|
||||
if (!context.IsNull)
|
||||
{
|
||||
var currentWindow = context.CurrentWindow;
|
||||
|
|
@ -163,7 +162,6 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
|
|||
currLineTextBaseOffset = currentWindow.DC.CurrLineTextBaseOffset;
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
var itemSize = size;
|
||||
if (currLineTextBaseOffset != 0f)
|
||||
|
|
|
|||
638
imgui/StandaloneImGuiTestbed/ImGuiBackend.cs
Normal file
638
imgui/StandaloneImGuiTestbed/ImGuiBackend.cs
Normal file
|
|
@ -0,0 +1,638 @@
|
|||
using System.Numerics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
using Dalamud.Bindings.ImGui;
|
||||
|
||||
using Veldrid;
|
||||
using Veldrid.Sdl2;
|
||||
|
||||
namespace StandaloneImGuiTestbed;
|
||||
|
||||
public class ImGuiBackend : IDisposable
|
||||
{
|
||||
private GraphicsDevice gd;
|
||||
private bool frameBegun;
|
||||
|
||||
// Veldrid objects
|
||||
private DeviceBuffer? vertexBuffer;
|
||||
private DeviceBuffer? indexBuffer;
|
||||
private DeviceBuffer? projMatrixBuffer;
|
||||
private Texture? fontTexture;
|
||||
private TextureView? fontTextureView;
|
||||
private Shader? vertexShader;
|
||||
private Shader? fragmentShader;
|
||||
private ResourceLayout? layout;
|
||||
private ResourceLayout? textureLayout;
|
||||
private Pipeline? pipeline;
|
||||
private ResourceSet? mainResourceSet;
|
||||
private ResourceSet? fontTextureResourceSet;
|
||||
|
||||
private readonly IntPtr fontAtlasId = (IntPtr)1;
|
||||
private bool controlDown;
|
||||
private bool shiftDown;
|
||||
private bool altDown;
|
||||
private bool winKeyDown;
|
||||
|
||||
private IntPtr iniPathPtr;
|
||||
|
||||
private int windowWidth;
|
||||
private int windowHeight;
|
||||
private Vector2 scaleFactor = Vector2.One;
|
||||
|
||||
// Image trackers
|
||||
private readonly Dictionary<TextureView, ResourceSetInfo> setsByView = new();
|
||||
|
||||
private readonly Dictionary<Texture, TextureView> autoViewsByTexture = new();
|
||||
|
||||
private readonly Dictionary<IntPtr, ResourceSetInfo> viewsById = new Dictionary<IntPtr, ResourceSetInfo>();
|
||||
private readonly List<IDisposable> ownedResources = new List<IDisposable>();
|
||||
private int lastAssignedID = 100;
|
||||
|
||||
private delegate void SetClipboardTextDelegate(IntPtr userData, string text);
|
||||
|
||||
private delegate string GetClipboardTextDelegate();
|
||||
|
||||
// variables because they need to exist for the program duration without being gc'd
|
||||
private SetClipboardTextDelegate setText;
|
||||
private GetClipboardTextDelegate getText;
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a new ImGuiBackend.
|
||||
/// </summary>
|
||||
public unsafe ImGuiBackend(GraphicsDevice gd, OutputDescription outputDescription, int width, int height, FileInfo iniPath, float fontPxSize)
|
||||
{
|
||||
this.gd = gd;
|
||||
windowWidth = width;
|
||||
windowHeight = height;
|
||||
|
||||
ImGui.CreateContext();
|
||||
|
||||
ImGui.GetIO().ConfigFlags |= ImGuiConfigFlags.NavEnableKeyboard | ImGuiConfigFlags.NavEnableGamepad;
|
||||
ImGui.GetIO().BackendFlags |= ImGuiBackendFlags.HasGamepad;
|
||||
|
||||
SetIniPath(iniPath.FullName);
|
||||
|
||||
setText = SetClipboardText;
|
||||
getText = GetClipboardText;
|
||||
|
||||
var io = ImGui.GetIO();
|
||||
io.SetClipboardTextFn = Marshal.GetFunctionPointerForDelegate(setText).ToPointer();
|
||||
io.GetClipboardTextFn = Marshal.GetFunctionPointerForDelegate(getText).ToPointer();
|
||||
io.ClipboardUserData = null;
|
||||
|
||||
CreateDeviceResources(gd, outputDescription, fontPxSize);
|
||||
SetKeyMappings();
|
||||
|
||||
SetPerFrameImGuiData(1f / 60f);
|
||||
|
||||
ImGui.NewFrame();
|
||||
frameBegun = true;
|
||||
}
|
||||
|
||||
private void SetIniPath(string iniPath)
|
||||
{
|
||||
if (iniPathPtr != IntPtr.Zero)
|
||||
{
|
||||
Marshal.FreeHGlobal(iniPathPtr);
|
||||
}
|
||||
|
||||
iniPathPtr = Marshal.StringToHGlobalAnsi(iniPath);
|
||||
|
||||
unsafe
|
||||
{
|
||||
var io = ImGui.GetIO();
|
||||
io.IniFilename = (byte*)iniPathPtr.ToPointer();
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetClipboardText(IntPtr userData, string text)
|
||||
{
|
||||
// text always seems to have an extra newline, but I'll leave it for now
|
||||
Sdl2Native.SDL_SetClipboardText(text);
|
||||
}
|
||||
|
||||
private static string GetClipboardText()
|
||||
{
|
||||
return Sdl2Native.SDL_GetClipboardText();
|
||||
}
|
||||
|
||||
public void WindowResized(int width, int height)
|
||||
{
|
||||
windowWidth = width;
|
||||
windowHeight = height;
|
||||
}
|
||||
|
||||
public void DestroyDeviceObjects()
|
||||
{
|
||||
Dispose();
|
||||
}
|
||||
|
||||
public void CreateDeviceResources(GraphicsDevice gd, OutputDescription outputDescription, float fontPxSize)
|
||||
{
|
||||
this.gd = gd;
|
||||
var factory = gd.ResourceFactory;
|
||||
vertexBuffer = factory.CreateBuffer(new BufferDescription(10000, BufferUsage.VertexBuffer | BufferUsage.Dynamic));
|
||||
vertexBuffer.Name = "ImGui.NET Vertex Buffer";
|
||||
indexBuffer = factory.CreateBuffer(new BufferDescription(2000, BufferUsage.IndexBuffer | BufferUsage.Dynamic));
|
||||
indexBuffer.Name = "ImGui.NET Index Buffer";
|
||||
|
||||
var ioFonts = ImGui.GetIO().Fonts;
|
||||
|
||||
ImGui.GetIO().Fonts.Clear();
|
||||
ImGui.GetIO().Fonts.AddFontDefault();
|
||||
ImGui.GetIO().Fonts.Build();
|
||||
|
||||
RecreateFontDeviceTexture(gd);
|
||||
|
||||
projMatrixBuffer = factory.CreateBuffer(new BufferDescription(64, BufferUsage.UniformBuffer | BufferUsage.Dynamic));
|
||||
projMatrixBuffer.Name = "ImGui.NET Projection Buffer";
|
||||
|
||||
var vertexShaderBytes = LoadEmbeddedShaderCode(gd.ResourceFactory, "imgui-vertex", ShaderStages.Vertex);
|
||||
var fragmentShaderBytes = LoadEmbeddedShaderCode(gd.ResourceFactory, "imgui-frag", ShaderStages.Fragment);
|
||||
vertexShader = factory.CreateShader(new ShaderDescription(ShaderStages.Vertex, vertexShaderBytes, gd.BackendType == GraphicsBackend.Metal ? "VS" : "main"));
|
||||
fragmentShader = factory.CreateShader(new ShaderDescription(ShaderStages.Fragment, fragmentShaderBytes, gd.BackendType == GraphicsBackend.Metal ? "FS" : "main"));
|
||||
|
||||
var vertexLayouts = new VertexLayoutDescription[]
|
||||
{
|
||||
new VertexLayoutDescription(
|
||||
new VertexElementDescription("in_position", VertexElementSemantic.Position, VertexElementFormat.Float2),
|
||||
new VertexElementDescription("in_texCoord", VertexElementSemantic.TextureCoordinate, VertexElementFormat.Float2),
|
||||
new VertexElementDescription("in_color", VertexElementSemantic.Color, VertexElementFormat.Byte4_Norm))
|
||||
};
|
||||
|
||||
layout = factory.CreateResourceLayout(new ResourceLayoutDescription(
|
||||
new ResourceLayoutElementDescription("ProjectionMatrixBuffer", ResourceKind.UniformBuffer, ShaderStages.Vertex),
|
||||
new ResourceLayoutElementDescription("MainSampler", ResourceKind.Sampler, ShaderStages.Fragment)));
|
||||
textureLayout = factory.CreateResourceLayout(new ResourceLayoutDescription(
|
||||
new ResourceLayoutElementDescription("MainTexture", ResourceKind.TextureReadOnly, ShaderStages.Fragment)));
|
||||
|
||||
var pd = new GraphicsPipelineDescription(
|
||||
BlendStateDescription.SingleAlphaBlend,
|
||||
new DepthStencilStateDescription(false, false, ComparisonKind.Always),
|
||||
new RasterizerStateDescription(FaceCullMode.None, PolygonFillMode.Solid, FrontFace.Clockwise, false, true),
|
||||
PrimitiveTopology.TriangleList,
|
||||
new ShaderSetDescription(vertexLayouts, new[] { vertexShader, fragmentShader }),
|
||||
new ResourceLayout[] { layout, textureLayout },
|
||||
outputDescription,
|
||||
ResourceBindingModel.Default);
|
||||
pipeline = factory.CreateGraphicsPipeline(ref pd);
|
||||
|
||||
mainResourceSet = factory.CreateResourceSet(new ResourceSetDescription(layout,
|
||||
projMatrixBuffer,
|
||||
gd.PointSampler));
|
||||
|
||||
fontTextureResourceSet = factory.CreateResourceSet(new ResourceSetDescription(textureLayout, fontTextureView));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a handle for a texture to be drawn with ImGui.
|
||||
/// Pass the returned handle to Image() or ImageButton().
|
||||
/// </summary>
|
||||
public IntPtr GetOrCreateImGuiBinding(ResourceFactory factory, TextureView textureView)
|
||||
{
|
||||
if (!setsByView.TryGetValue(textureView, out var rsi))
|
||||
{
|
||||
var resourceSet = factory.CreateResourceSet(new ResourceSetDescription(textureLayout, textureView));
|
||||
rsi = new ResourceSetInfo(this.GetNextImGuiBindingId(), resourceSet);
|
||||
|
||||
setsByView.Add(textureView, rsi);
|
||||
viewsById.Add(rsi.ImGuiBinding, rsi);
|
||||
ownedResources.Add(resourceSet);
|
||||
}
|
||||
|
||||
return rsi.ImGuiBinding;
|
||||
}
|
||||
|
||||
private IntPtr GetNextImGuiBindingId()
|
||||
{
|
||||
var newId = lastAssignedID++;
|
||||
return (IntPtr)newId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a handle for a texture to be drawn with ImGui.
|
||||
/// Pass the returned handle to Image() or ImageButton().
|
||||
/// </summary>
|
||||
public IntPtr GetOrCreateImGuiBinding(ResourceFactory factory, Texture texture)
|
||||
{
|
||||
if (!autoViewsByTexture.TryGetValue(texture, out var textureView))
|
||||
{
|
||||
textureView = factory.CreateTextureView(texture);
|
||||
autoViewsByTexture.Add(texture, textureView);
|
||||
ownedResources.Add(textureView);
|
||||
}
|
||||
|
||||
return GetOrCreateImGuiBinding(factory, textureView);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the shader texture binding for the given helper handle.
|
||||
/// </summary>
|
||||
public ResourceSet GetImageResourceSet(IntPtr imGuiBinding)
|
||||
{
|
||||
if (!viewsById.TryGetValue(imGuiBinding, out var tvi))
|
||||
{
|
||||
throw new InvalidOperationException("No registered ImGui binding with id " + imGuiBinding.ToString());
|
||||
}
|
||||
|
||||
return tvi.ResourceSet;
|
||||
}
|
||||
|
||||
public void ClearCachedImageResources()
|
||||
{
|
||||
foreach (var resource in ownedResources)
|
||||
{
|
||||
resource.Dispose();
|
||||
}
|
||||
|
||||
ownedResources.Clear();
|
||||
setsByView.Clear();
|
||||
viewsById.Clear();
|
||||
autoViewsByTexture.Clear();
|
||||
lastAssignedID = 100;
|
||||
}
|
||||
|
||||
public static byte[] GetEmbeddedResourceBytes(string resourceName)
|
||||
{
|
||||
var assembly = typeof(ImGuiBackend).Assembly;
|
||||
|
||||
using var s = assembly.GetManifestResourceStream(resourceName);
|
||||
if (s == null)
|
||||
throw new ArgumentException($"Resource {resourceName} not found", nameof(resourceName));
|
||||
|
||||
var ret = new byte[s.Length];
|
||||
s.ReadExactly(ret, 0, (int)s.Length);
|
||||
return ret;
|
||||
}
|
||||
|
||||
private byte[] LoadEmbeddedShaderCode(ResourceFactory factory, string name, ShaderStages stage)
|
||||
{
|
||||
switch (factory.BackendType)
|
||||
{
|
||||
case GraphicsBackend.Direct3D11:
|
||||
{
|
||||
var resourceName = name + ".hlsl.bytes";
|
||||
return GetEmbeddedResourceBytes(resourceName);
|
||||
}
|
||||
|
||||
case GraphicsBackend.OpenGL:
|
||||
{
|
||||
var resourceName = name + ".glsl";
|
||||
return GetEmbeddedResourceBytes(resourceName);
|
||||
}
|
||||
|
||||
case GraphicsBackend.Vulkan:
|
||||
{
|
||||
var resourceName = name + ".spv";
|
||||
return GetEmbeddedResourceBytes(resourceName);
|
||||
}
|
||||
|
||||
case GraphicsBackend.Metal:
|
||||
{
|
||||
var resourceName = name + ".metallib";
|
||||
return GetEmbeddedResourceBytes(resourceName);
|
||||
}
|
||||
|
||||
default:
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recreates the device texture used to render text.
|
||||
/// </summary>
|
||||
public unsafe void RecreateFontDeviceTexture(GraphicsDevice gd)
|
||||
{
|
||||
var io = ImGui.GetIO();
|
||||
// Build
|
||||
byte* pixels = null;
|
||||
int width, height, bytesPerPixel;
|
||||
io.Fonts.GetTexDataAsRGBA32(0, &pixels, &width, &height, &bytesPerPixel);
|
||||
// Store our identifier
|
||||
io.Fonts.SetTexID(0, new ImTextureID(this.fontAtlasId));
|
||||
|
||||
fontTexture = gd.ResourceFactory.CreateTexture(TextureDescription.Texture2D(
|
||||
(uint)width,
|
||||
(uint)height,
|
||||
1,
|
||||
1,
|
||||
PixelFormat.R8_G8_B8_A8_UNorm,
|
||||
TextureUsage.Sampled));
|
||||
fontTexture.Name = "ImGui.NET Font Texture";
|
||||
gd.UpdateTexture(
|
||||
fontTexture,
|
||||
new IntPtr(pixels),
|
||||
(uint)(bytesPerPixel * width * height),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
(uint)width,
|
||||
(uint)height,
|
||||
1,
|
||||
0,
|
||||
0);
|
||||
fontTextureView = gd.ResourceFactory.CreateTextureView(fontTexture);
|
||||
|
||||
io.Fonts.ClearTexData();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the ImGui draw list data.
|
||||
/// This method requires a <see cref="GraphicsDevice"/> because it may create new DeviceBuffers if the size of vertex
|
||||
/// or index data has increased beyond the capacity of the existing buffers.
|
||||
/// A <see cref="CommandList"/> is needed to submit drawing and resource update commands.
|
||||
/// </summary>
|
||||
public void Render(GraphicsDevice gd, CommandList cl)
|
||||
{
|
||||
if (frameBegun)
|
||||
{
|
||||
frameBegun = false;
|
||||
ImGui.Render();
|
||||
RenderImDrawData(ImGui.GetDrawData(), gd, cl);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates ImGui input and IO configuration state.
|
||||
/// </summary>
|
||||
public void Update(float deltaSeconds, InputSnapshot snapshot)
|
||||
{
|
||||
if (frameBegun)
|
||||
{
|
||||
ImGui.Render();
|
||||
}
|
||||
|
||||
SetPerFrameImGuiData(deltaSeconds);
|
||||
UpdateImGuiInput(snapshot);
|
||||
|
||||
frameBegun = true;
|
||||
ImGui.NewFrame();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets per-frame data based on the associated window.
|
||||
/// This is called by Update(float).
|
||||
/// </summary>
|
||||
private void SetPerFrameImGuiData(float deltaSeconds)
|
||||
{
|
||||
var io = ImGui.GetIO();
|
||||
io.DisplaySize = new Vector2(
|
||||
windowWidth / scaleFactor.X,
|
||||
windowHeight / scaleFactor.Y);
|
||||
io.DisplayFramebufferScale = scaleFactor;
|
||||
io.DeltaTime = deltaSeconds; // DeltaTime is in seconds.
|
||||
}
|
||||
|
||||
private void UpdateImGuiInput(InputSnapshot snapshot)
|
||||
{
|
||||
var io = ImGui.GetIO();
|
||||
|
||||
var mousePosition = snapshot.MousePosition;
|
||||
|
||||
// Determine if any of the mouse buttons were pressed during this snapshot period, even if they are no longer held.
|
||||
var leftPressed = false;
|
||||
var middlePressed = false;
|
||||
var rightPressed = false;
|
||||
|
||||
foreach (var me in snapshot.MouseEvents)
|
||||
{
|
||||
if (me.Down)
|
||||
{
|
||||
switch (me.MouseButton)
|
||||
{
|
||||
case MouseButton.Left:
|
||||
leftPressed = true;
|
||||
break;
|
||||
|
||||
case MouseButton.Middle:
|
||||
middlePressed = true;
|
||||
break;
|
||||
|
||||
case MouseButton.Right:
|
||||
rightPressed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
io.MouseDown[0] = leftPressed || snapshot.IsMouseDown(MouseButton.Left);
|
||||
io.MouseDown[1] = rightPressed || snapshot.IsMouseDown(MouseButton.Right);
|
||||
io.MouseDown[2] = middlePressed || snapshot.IsMouseDown(MouseButton.Middle);
|
||||
io.MousePos = mousePosition;
|
||||
io.MouseWheel = snapshot.WheelDelta;
|
||||
|
||||
var keyCharPresses = snapshot.KeyCharPresses;
|
||||
|
||||
for (var i = 0; i < keyCharPresses.Count; i++)
|
||||
{
|
||||
var c = keyCharPresses[i];
|
||||
io.AddInputCharacter(c);
|
||||
}
|
||||
|
||||
var keyEvents = snapshot.KeyEvents;
|
||||
|
||||
for (var i = 0; i < keyEvents.Count; i++)
|
||||
{
|
||||
var keyEvent = keyEvents[i];
|
||||
io.KeysDown[(int)keyEvent.Key] = keyEvent.Down;
|
||||
|
||||
if (keyEvent.Key == Key.ControlLeft)
|
||||
{
|
||||
controlDown = keyEvent.Down;
|
||||
}
|
||||
|
||||
if (keyEvent.Key == Key.ShiftLeft)
|
||||
{
|
||||
shiftDown = keyEvent.Down;
|
||||
}
|
||||
|
||||
if (keyEvent.Key == Key.AltLeft)
|
||||
{
|
||||
altDown = keyEvent.Down;
|
||||
}
|
||||
|
||||
if (keyEvent.Key == Key.WinLeft)
|
||||
{
|
||||
winKeyDown = keyEvent.Down;
|
||||
}
|
||||
}
|
||||
|
||||
io.KeyCtrl = controlDown;
|
||||
io.KeyAlt = altDown;
|
||||
io.KeyShift = shiftDown;
|
||||
io.KeySuper = winKeyDown;
|
||||
}
|
||||
|
||||
private static void SetKeyMappings()
|
||||
{
|
||||
var io = ImGui.GetIO();
|
||||
io.KeyMap[(int)ImGuiKey.Tab] = (int)Key.Tab;
|
||||
io.KeyMap[(int)ImGuiKey.LeftArrow] = (int)Key.Left;
|
||||
io.KeyMap[(int)ImGuiKey.RightArrow] = (int)Key.Right;
|
||||
io.KeyMap[(int)ImGuiKey.UpArrow] = (int)Key.Up;
|
||||
io.KeyMap[(int)ImGuiKey.DownArrow] = (int)Key.Down;
|
||||
io.KeyMap[(int)ImGuiKey.PageUp] = (int)Key.PageUp;
|
||||
io.KeyMap[(int)ImGuiKey.PageDown] = (int)Key.PageDown;
|
||||
io.KeyMap[(int)ImGuiKey.Home] = (int)Key.Home;
|
||||
io.KeyMap[(int)ImGuiKey.End] = (int)Key.End;
|
||||
io.KeyMap[(int)ImGuiKey.Delete] = (int)Key.Delete;
|
||||
io.KeyMap[(int)ImGuiKey.Backspace] = (int)Key.BackSpace;
|
||||
io.KeyMap[(int)ImGuiKey.Enter] = (int)Key.Enter;
|
||||
io.KeyMap[(int)ImGuiKey.Escape] = (int)Key.Escape;
|
||||
io.KeyMap[(int)ImGuiKey.Space] = (int)Key.Space;
|
||||
io.KeyMap[(int)ImGuiKey.KeypadEnter] = (int)Key.KeypadEnter;
|
||||
io.KeyMap[(int)ImGuiKey.A] = (int)Key.A;
|
||||
io.KeyMap[(int)ImGuiKey.C] = (int)Key.C;
|
||||
io.KeyMap[(int)ImGuiKey.V] = (int)Key.V;
|
||||
io.KeyMap[(int)ImGuiKey.X] = (int)Key.X;
|
||||
io.KeyMap[(int)ImGuiKey.Y] = (int)Key.Y;
|
||||
io.KeyMap[(int)ImGuiKey.Z] = (int)Key.Z;
|
||||
}
|
||||
|
||||
private unsafe void RenderImDrawData(ImDrawDataPtr drawData, GraphicsDevice gd, CommandList cl)
|
||||
{
|
||||
uint vertexOffsetInVertices = 0;
|
||||
uint indexOffsetInElements = 0;
|
||||
|
||||
if (drawData.CmdListsCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var totalVbSize = (uint)(drawData.TotalVtxCount * Unsafe.SizeOf<ImDrawVert>());
|
||||
|
||||
if (totalVbSize > vertexBuffer!.SizeInBytes)
|
||||
{
|
||||
gd.DisposeWhenIdle(vertexBuffer);
|
||||
vertexBuffer = gd.ResourceFactory.CreateBuffer(new BufferDescription((uint)(totalVbSize * 1.5f), BufferUsage.VertexBuffer | BufferUsage.Dynamic));
|
||||
}
|
||||
|
||||
var totalIbSize = (uint)(drawData.TotalIdxCount * sizeof(ushort));
|
||||
|
||||
if (totalIbSize > indexBuffer!.SizeInBytes)
|
||||
{
|
||||
gd.DisposeWhenIdle(indexBuffer);
|
||||
indexBuffer = gd.ResourceFactory.CreateBuffer(new BufferDescription((uint)(totalIbSize * 1.5f), BufferUsage.IndexBuffer | BufferUsage.Dynamic));
|
||||
}
|
||||
|
||||
for (var i = 0; i < drawData.CmdListsCount; i++)
|
||||
{
|
||||
ImDrawListPtr cmdList = drawData.CmdLists[i];
|
||||
|
||||
cl.UpdateBuffer(
|
||||
vertexBuffer,
|
||||
vertexOffsetInVertices * (uint)Unsafe.SizeOf<ImDrawVert>(),
|
||||
new IntPtr(cmdList.VtxBuffer.Data),
|
||||
(uint)(cmdList.VtxBuffer.Size * Unsafe.SizeOf<ImDrawVert>()));
|
||||
|
||||
cl.UpdateBuffer(
|
||||
indexBuffer,
|
||||
indexOffsetInElements * sizeof(ushort),
|
||||
new IntPtr(cmdList.IdxBuffer.Data),
|
||||
(uint)(cmdList.IdxBuffer.Size * sizeof(ushort)));
|
||||
|
||||
vertexOffsetInVertices += (uint)cmdList.VtxBuffer.Size;
|
||||
indexOffsetInElements += (uint)cmdList.IdxBuffer.Size;
|
||||
}
|
||||
|
||||
// Setup orthographic projection matrix into our constant buffer
|
||||
var io = ImGui.GetIO();
|
||||
var mvp = Matrix4x4.CreateOrthographicOffCenter(
|
||||
0f,
|
||||
io.DisplaySize.X,
|
||||
io.DisplaySize.Y,
|
||||
0.0f,
|
||||
-1.0f,
|
||||
1.0f);
|
||||
|
||||
this.gd.UpdateBuffer(this.projMatrixBuffer, 0, ref mvp);
|
||||
|
||||
cl.SetVertexBuffer(0, vertexBuffer);
|
||||
cl.SetIndexBuffer(indexBuffer, IndexFormat.UInt16);
|
||||
cl.SetPipeline(pipeline);
|
||||
cl.SetGraphicsResourceSet(0, mainResourceSet);
|
||||
|
||||
drawData.ScaleClipRects(io.DisplayFramebufferScale);
|
||||
|
||||
// Render command lists
|
||||
var globalIdxOffset = 0;
|
||||
var globalVtxOffset = 0;
|
||||
|
||||
for (var n = 0; n < drawData.CmdListsCount; n++)
|
||||
{
|
||||
ImDrawListPtr cmdList = drawData.CmdLists[n];
|
||||
|
||||
for (var cmdI = 0; cmdI < cmdList.CmdBuffer.Size; cmdI++)
|
||||
{
|
||||
var pcmd = cmdList.CmdBuffer[cmdI];
|
||||
|
||||
if (pcmd.UserCallback != null)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (pcmd.TextureId != IntPtr.Zero)
|
||||
{
|
||||
if (pcmd.TextureId == this.fontAtlasId)
|
||||
{
|
||||
cl.SetGraphicsResourceSet(1, fontTextureResourceSet);
|
||||
}
|
||||
else
|
||||
{
|
||||
cl.SetGraphicsResourceSet(1, GetImageResourceSet((nint)pcmd.TextureId.Handle));
|
||||
}
|
||||
}
|
||||
|
||||
cl.SetScissorRect(
|
||||
0,
|
||||
(uint)pcmd.ClipRect.X,
|
||||
(uint)pcmd.ClipRect.Y,
|
||||
(uint)(pcmd.ClipRect.Z - pcmd.ClipRect.X),
|
||||
(uint)(pcmd.ClipRect.W - pcmd.ClipRect.Y));
|
||||
|
||||
cl.DrawIndexed(pcmd.ElemCount, 1, pcmd.IdxOffset + (uint)globalIdxOffset, (int)pcmd.VtxOffset + globalVtxOffset, 0);
|
||||
}
|
||||
}
|
||||
|
||||
globalIdxOffset += cmdList.IdxBuffer.Size;
|
||||
globalVtxOffset += cmdList.VtxBuffer.Size;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frees all graphics resources used by the renderer.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
vertexBuffer?.Dispose();
|
||||
indexBuffer?.Dispose();
|
||||
projMatrixBuffer?.Dispose();
|
||||
fontTexture?.Dispose();
|
||||
fontTextureView?.Dispose();
|
||||
vertexShader?.Dispose();
|
||||
fragmentShader?.Dispose();
|
||||
layout?.Dispose();
|
||||
textureLayout?.Dispose();
|
||||
pipeline?.Dispose();
|
||||
mainResourceSet?.Dispose();
|
||||
|
||||
foreach (var resource in ownedResources)
|
||||
{
|
||||
resource.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private struct ResourceSetInfo
|
||||
{
|
||||
public readonly IntPtr ImGuiBinding;
|
||||
public readonly ResourceSet ResourceSet;
|
||||
|
||||
public ResourceSetInfo(IntPtr imGuiBinding, ResourceSet resourceSet)
|
||||
{
|
||||
ImGuiBinding = imGuiBinding;
|
||||
ResourceSet = resourceSet;
|
||||
}
|
||||
}
|
||||
}
|
||||
61
imgui/StandaloneImGuiTestbed/Program.cs
Normal file
61
imgui/StandaloneImGuiTestbed/Program.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using Veldrid;
|
||||
using Veldrid.Sdl2;
|
||||
using Veldrid.StartupUtilities;
|
||||
|
||||
namespace StandaloneImGuiTestbed;
|
||||
|
||||
class Program
|
||||
{
|
||||
static void Main(string[] args)
|
||||
{
|
||||
Sdl2Window window;
|
||||
GraphicsDevice gd;
|
||||
|
||||
ImGuiBackend? backend = null;
|
||||
|
||||
VeldridStartup.CreateWindowAndGraphicsDevice(
|
||||
new WindowCreateInfo(50, 50, 1280, 800, WindowState.Normal, "Dalamud Standalone ImGui Testbed"),
|
||||
new GraphicsDeviceOptions(false, null, true, ResourceBindingModel.Improved, true, true),
|
||||
out window,
|
||||
out gd);
|
||||
|
||||
window.Resized += () =>
|
||||
{
|
||||
gd.MainSwapchain.Resize((uint)window.Width, (uint)window.Height);
|
||||
backend!.WindowResized(window.Width, window.Height);
|
||||
};
|
||||
|
||||
var cl = gd.ResourceFactory.CreateCommandList();
|
||||
backend = new ImGuiBackend(gd, gd.MainSwapchain.Framebuffer.OutputDescription, window.Width, window.Height, new FileInfo("imgui.ini"), 21.0f);
|
||||
|
||||
var testbed = new Testbed();
|
||||
|
||||
while (window.Exists)
|
||||
{
|
||||
Thread.Sleep(50);
|
||||
|
||||
var snapshot = window.PumpEvents();
|
||||
|
||||
if (!window.Exists)
|
||||
break;
|
||||
|
||||
backend.Update(1f / 60f, snapshot);
|
||||
|
||||
testbed.Draw();
|
||||
|
||||
cl.Begin();
|
||||
cl.SetFramebuffer(gd.MainSwapchain.Framebuffer);
|
||||
cl.ClearColorTarget(0, new RgbaFloat(0, 0, 0, 1f));
|
||||
backend.Render(gd, cl);
|
||||
cl.End();
|
||||
gd.SubmitCommands(cl);
|
||||
gd.SwapBuffers(gd.MainSwapchain);
|
||||
}
|
||||
|
||||
// Clean up Veldrid resources
|
||||
gd.WaitForIdle();
|
||||
backend.Dispose();
|
||||
cl.Dispose();
|
||||
gd.Dispose();
|
||||
}
|
||||
}
|
||||
13
imgui/StandaloneImGuiTestbed/Shaders/GLSL/imgui-frag.glsl
Normal file
13
imgui/StandaloneImGuiTestbed/Shaders/GLSL/imgui-frag.glsl
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
#version 330 core
|
||||
|
||||
uniform sampler2D FontTexture;
|
||||
|
||||
in vec4 color;
|
||||
in vec2 texCoord;
|
||||
|
||||
out vec4 outputColor;
|
||||
|
||||
void main()
|
||||
{
|
||||
outputColor = color * texture(FontTexture, texCoord);
|
||||
}
|
||||
20
imgui/StandaloneImGuiTestbed/Shaders/GLSL/imgui-vertex.glsl
Normal file
20
imgui/StandaloneImGuiTestbed/Shaders/GLSL/imgui-vertex.glsl
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
#version 330 core
|
||||
|
||||
uniform ProjectionMatrixBuffer
|
||||
{
|
||||
mat4 projection_matrix;
|
||||
};
|
||||
|
||||
in vec2 in_position;
|
||||
in vec2 in_texCoord;
|
||||
in vec4 in_color;
|
||||
|
||||
out vec4 color;
|
||||
out vec2 texCoord;
|
||||
|
||||
void main()
|
||||
{
|
||||
gl_Position = projection_matrix * vec4(in_position, 0, 1);
|
||||
color = in_color;
|
||||
texCoord = in_texCoord;
|
||||
}
|
||||
15
imgui/StandaloneImGuiTestbed/Shaders/HLSL/imgui-frag.hlsl
Normal file
15
imgui/StandaloneImGuiTestbed/Shaders/HLSL/imgui-frag.hlsl
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
struct PS_INPUT
|
||||
{
|
||||
float4 pos : SV_POSITION;
|
||||
float4 col : COLOR0;
|
||||
float2 uv : TEXCOORD0;
|
||||
};
|
||||
|
||||
Texture2D FontTexture : register(t0);
|
||||
sampler FontSampler : register(s0);
|
||||
|
||||
float4 FS(PS_INPUT input) : SV_Target
|
||||
{
|
||||
float4 out_col = input.col * FontTexture.Sample(FontSampler, input.uv);
|
||||
return out_col;
|
||||
}
|
||||
BIN
imgui/StandaloneImGuiTestbed/Shaders/HLSL/imgui-frag.hlsl.bytes
Normal file
BIN
imgui/StandaloneImGuiTestbed/Shaders/HLSL/imgui-frag.hlsl.bytes
Normal file
Binary file not shown.
27
imgui/StandaloneImGuiTestbed/Shaders/HLSL/imgui-vertex.hlsl
Normal file
27
imgui/StandaloneImGuiTestbed/Shaders/HLSL/imgui-vertex.hlsl
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
cbuffer ProjectionMatrixBuffer : register(b0)
|
||||
{
|
||||
float4x4 ProjectionMatrix;
|
||||
};
|
||||
|
||||
struct VS_INPUT
|
||||
{
|
||||
float2 pos : POSITION;
|
||||
float2 uv : TEXCOORD0;
|
||||
float4 col : COLOR0;
|
||||
};
|
||||
|
||||
struct PS_INPUT
|
||||
{
|
||||
float4 pos : SV_POSITION;
|
||||
float4 col : COLOR0;
|
||||
float2 uv : TEXCOORD0;
|
||||
};
|
||||
|
||||
PS_INPUT VS(VS_INPUT input)
|
||||
{
|
||||
PS_INPUT output;
|
||||
output.pos = mul(ProjectionMatrix, float4(input.pos.xy, 0.f, 1.f));
|
||||
output.col = input.col;
|
||||
output.uv = input.uv;
|
||||
return output;
|
||||
}
|
||||
Binary file not shown.
18
imgui/StandaloneImGuiTestbed/Shaders/Metal/imgui-frag.metal
Normal file
18
imgui/StandaloneImGuiTestbed/Shaders/Metal/imgui-frag.metal
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
|
||||
struct PS_INPUT
|
||||
{
|
||||
float4 pos [[ position ]];
|
||||
float4 col;
|
||||
float2 uv;
|
||||
};
|
||||
|
||||
fragment float4 FS(
|
||||
PS_INPUT input [[ stage_in ]],
|
||||
texture2d<float> FontTexture [[ texture(0) ]],
|
||||
sampler FontSampler [[ sampler(0) ]])
|
||||
{
|
||||
float4 out_col = input.col * FontTexture.sample(FontSampler, input.uv);
|
||||
return out_col;
|
||||
}
|
||||
BIN
imgui/StandaloneImGuiTestbed/Shaders/Metal/imgui-frag.metallib
Normal file
BIN
imgui/StandaloneImGuiTestbed/Shaders/Metal/imgui-frag.metallib
Normal file
Binary file not shown.
|
|
@ -0,0 +1,27 @@
|
|||
#include <metal_stdlib>
|
||||
using namespace metal;
|
||||
|
||||
struct VS_INPUT
|
||||
{
|
||||
float2 pos [[ attribute(0) ]];
|
||||
float2 uv [[ attribute(1) ]];
|
||||
float4 col [[ attribute(2) ]];
|
||||
};
|
||||
|
||||
struct PS_INPUT
|
||||
{
|
||||
float4 pos [[ position ]];
|
||||
float4 col;
|
||||
float2 uv;
|
||||
};
|
||||
|
||||
vertex PS_INPUT VS(
|
||||
VS_INPUT input [[ stage_in ]],
|
||||
constant float4x4 &ProjectionMatrix [[ buffer(1) ]])
|
||||
{
|
||||
PS_INPUT output;
|
||||
output.pos = ProjectionMatrix * float4(input.pos.xy, 0.f, 1.f);
|
||||
output.col = input.col;
|
||||
output.uv = input.uv;
|
||||
return output;
|
||||
}
|
||||
BIN
imgui/StandaloneImGuiTestbed/Shaders/Metal/imgui-vertex.metallib
Normal file
BIN
imgui/StandaloneImGuiTestbed/Shaders/Metal/imgui-vertex.metallib
Normal file
Binary file not shown.
|
|
@ -0,0 +1,2 @@
|
|||
glslangvalidator -V imgui-vertex.glsl -o imgui-vertex.spv -S vert
|
||||
glslangvalidator -V imgui-frag.glsl -o imgui-frag.spv -S frag
|
||||
16
imgui/StandaloneImGuiTestbed/Shaders/SPIR-V/imgui-frag.glsl
Normal file
16
imgui/StandaloneImGuiTestbed/Shaders/SPIR-V/imgui-frag.glsl
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#version 450
|
||||
|
||||
#extension GL_ARB_separate_shader_objects : enable
|
||||
#extension GL_ARB_shading_language_420pack : enable
|
||||
|
||||
layout(set = 1, binding = 0) uniform texture2D FontTexture;
|
||||
layout(set = 0, binding = 1) uniform sampler FontSampler;
|
||||
|
||||
layout (location = 0) in vec4 color;
|
||||
layout (location = 1) in vec2 texCoord;
|
||||
layout (location = 0) out vec4 outputColor;
|
||||
|
||||
void main()
|
||||
{
|
||||
outputColor = color * texture(sampler2D(FontTexture, FontSampler), texCoord);
|
||||
}
|
||||
BIN
imgui/StandaloneImGuiTestbed/Shaders/SPIR-V/imgui-frag.spv
Normal file
BIN
imgui/StandaloneImGuiTestbed/Shaders/SPIR-V/imgui-frag.spv
Normal file
Binary file not shown.
|
|
@ -0,0 +1,28 @@
|
|||
#version 450
|
||||
|
||||
#extension GL_ARB_separate_shader_objects : enable
|
||||
#extension GL_ARB_shading_language_420pack : enable
|
||||
|
||||
layout (location = 0) in vec2 in_position;
|
||||
layout (location = 1) in vec2 in_texCoord;
|
||||
layout (location = 2) in vec4 in_color;
|
||||
|
||||
layout (binding = 0) uniform ProjectionMatrixBuffer
|
||||
{
|
||||
mat4 projection_matrix;
|
||||
};
|
||||
|
||||
layout (location = 0) out vec4 color;
|
||||
layout (location = 1) out vec2 texCoord;
|
||||
|
||||
out gl_PerVertex
|
||||
{
|
||||
vec4 gl_Position;
|
||||
};
|
||||
|
||||
void main()
|
||||
{
|
||||
gl_Position = projection_matrix * vec4(in_position, 0, 1);
|
||||
color = in_color;
|
||||
texCoord = in_texCoord;
|
||||
}
|
||||
BIN
imgui/StandaloneImGuiTestbed/Shaders/SPIR-V/imgui-vertex.spv
Normal file
BIN
imgui/StandaloneImGuiTestbed/Shaders/SPIR-V/imgui-vertex.spv
Normal file
Binary file not shown.
40
imgui/StandaloneImGuiTestbed/StandaloneImGuiTestbed.csproj
Normal file
40
imgui/StandaloneImGuiTestbed/StandaloneImGuiTestbed.csproj
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Shaders/GLSL/imgui-vertex.glsl" LogicalName="imgui-vertex.glsl" />
|
||||
<EmbeddedResource Include="Shaders/GLSL/imgui-frag.glsl" LogicalName="imgui-frag.glsl" />
|
||||
<EmbeddedResource Include="Shaders/HLSL/imgui-vertex.hlsl.bytes"
|
||||
LogicalName="imgui-vertex.hlsl.bytes" />
|
||||
<EmbeddedResource Include="Shaders/HLSL/imgui-frag.hlsl.bytes"
|
||||
LogicalName="imgui-frag.hlsl.bytes" />
|
||||
<EmbeddedResource Include="Shaders/SPIR-V/imgui-vertex.spv" LogicalName="imgui-vertex.spv" />
|
||||
<EmbeddedResource Include="Shaders/SPIR-V/imgui-frag.spv" LogicalName="imgui-frag.spv" />
|
||||
<EmbeddedResource Include="Shaders/Metal/imgui-vertex.metallib"
|
||||
LogicalName="imgui-vertex.metallib" />
|
||||
<EmbeddedResource Include="Shaders/Metal/imgui-frag.metallib"
|
||||
LogicalName="imgui-frag.metallib" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Veldrid" Version="4.9.0" />
|
||||
<PackageReference Include="Veldrid.SDL2" Version="4.9.0" />
|
||||
<PackageReference Include="Veldrid.StartupUtilities" Version="4.9.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Dalamud.Bindings.ImGui\Dalamud.Bindings.ImGui.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyImGuiDLLs" AfterTargets="PostBuildEvent">
|
||||
<Copy SourceFiles="$(SolutionDir)bin\$(Configuration)\cimgui.dll" DestinationFolder="$(OutDir)" />
|
||||
<Copy SourceFiles="$(SolutionDir)bin\$(Configuration)\cimgui.pdb" DestinationFolder="$(OutDir)" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
36
imgui/StandaloneImGuiTestbed/Testbed.cs
Normal file
36
imgui/StandaloneImGuiTestbed/Testbed.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
using Dalamud.Bindings.ImGui;
|
||||
|
||||
namespace StandaloneImGuiTestbed;
|
||||
|
||||
public class Testbed
|
||||
{
|
||||
private bool showDemoWindow = false;
|
||||
|
||||
public unsafe void Draw()
|
||||
{
|
||||
if (ImGui.Begin("Testbed"))
|
||||
{
|
||||
ImGui.Text("Hello!");
|
||||
|
||||
if (ImGui.Button("Open demo"))
|
||||
{
|
||||
this.showDemoWindow = true;
|
||||
}
|
||||
|
||||
if (this.showDemoWindow)
|
||||
{
|
||||
ImGui.ShowDemoWindow(ref this.showDemoWindow);
|
||||
}
|
||||
|
||||
if (ImGui.Button("Access context"))
|
||||
{
|
||||
var context = ImGui.GetCurrentContext();
|
||||
var currentWindow = context.CurrentWindow;
|
||||
ref var dc = ref currentWindow.DC; // BREAKPOINT HERE, currentWindow will be invalid
|
||||
dc.CurrLineTextBaseOffset = 0;
|
||||
}
|
||||
|
||||
ImGui.End();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue