Merge pull request #1567 from Soreepeong/fix/imgui-clipboard

Fix ImGui clipboard copy/paste normalization
This commit is contained in:
goat 2023-12-09 18:59:39 +01:00 committed by GitHub
commit 3611785357
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 279 additions and 101 deletions

View file

@ -104,13 +104,14 @@ resharper_can_use_global_alias = false
resharper_csharp_align_multiline_parameter = true
resharper_csharp_align_multiple_declaration = true
resharper_csharp_empty_block_style = multiline
resharper_csharp_int_align_comments = true
resharper_csharp_int_align_comments = false
resharper_csharp_new_line_before_while = true
resharper_csharp_wrap_after_declaration_lpar = true
resharper_csharp_wrap_after_invocation_lpar = true
resharper_csharp_wrap_arguments_style = chop_if_long
resharper_enforce_line_ending_style = true
resharper_instance_members_qualify_declared_in = this_class, base_class
resharper_int_align = false
resharper_member_can_be_private_global_highlighting = none
resharper_member_can_be_private_local_highlighting = none
resharper_new_line_before_finally = true

View file

@ -90,6 +90,7 @@
<PackageReference Include="System.Reactive" Version="5.0.0" />
<PackageReference Include="System.Reflection.MetadataLoadContext" Version="7.0.0" />
<PackageReference Include="System.Resources.Extensions" Version="7.0.0" />
<PackageReference Include="TerraFX.Interop.Windows" Version="10.0.22621.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Dalamud.Common\Dalamud.Common.csproj" />

View file

@ -1,80 +0,0 @@
using System.Runtime.InteropServices;
using ImGuiNET;
namespace Dalamud.Interface.Internal;
/// <summary>
/// Configures the ImGui clipboard behaviour to work nicely with XIV.
/// </summary>
/// <remarks>
/// <para>
/// XIV uses '\r' for line endings and will truncate all text after a '\n' character.
/// This means that copy/pasting multi-line text from ImGui to XIV will only copy the first line.
/// </para>
/// <para>
/// ImGui uses '\n' for line endings and will ignore '\r' entirely.
/// This means that copy/pasting multi-line text from XIV to ImGui will copy all the text
/// without line breaks.
/// </para>
/// <para>
/// To fix this we normalize all clipboard line endings entering/exiting ImGui to '\r\n' which
/// works for both ImGui and XIV.
/// </para>
/// </remarks>
internal static class ImGuiClipboardConfig
{
private delegate void SetClipboardTextDelegate(IntPtr userData, string text);
private delegate string GetClipboardTextDelegate();
private static SetClipboardTextDelegate? _setTextOriginal = null;
private static GetClipboardTextDelegate? _getTextOriginal = null;
// These must exist as variables to prevent them from being GC'd
private static SetClipboardTextDelegate? _setText = null;
private static GetClipboardTextDelegate? _getText = null;
public static void Apply()
{
var io = ImGui.GetIO();
if (_setTextOriginal == null)
{
_setTextOriginal =
Marshal.GetDelegateForFunctionPointer<SetClipboardTextDelegate>(io.SetClipboardTextFn);
}
if (_getTextOriginal == null)
{
_getTextOriginal =
Marshal.GetDelegateForFunctionPointer<GetClipboardTextDelegate>(io.GetClipboardTextFn);
}
_setText = new SetClipboardTextDelegate(SetClipboardText);
_getText = new GetClipboardTextDelegate(GetClipboardText);
io.SetClipboardTextFn = Marshal.GetFunctionPointerForDelegate(_setText);
io.GetClipboardTextFn = Marshal.GetFunctionPointerForDelegate(_getText);
}
public static void Unapply()
{
var io = ImGui.GetIO();
if (_setTextOriginal != null)
{
io.SetClipboardTextFn = Marshal.GetFunctionPointerForDelegate(_setTextOriginal);
}
if (_getTextOriginal != null)
{
io.GetClipboardTextFn = Marshal.GetFunctionPointerForDelegate(_getTextOriginal);
}
}
private static void SetClipboardText(IntPtr userData, string text)
{
_setTextOriginal!(userData, text.ReplaceLineEndings("\r\n"));
}
private static string GetClipboardText()
{
return _getTextOriginal!().ReplaceLineEndings("\r\n");
}
}

View file

@ -0,0 +1,199 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using CheapLoc;
using Dalamud.Game.Gui.Toast;
using Dalamud.Interface.Utility;
using Dalamud.Logging.Internal;
using ImGuiNET;
using TerraFX.Interop.Windows;
using static TerraFX.Interop.Windows.Windows;
namespace Dalamud.Interface.Internal;
/// <summary>
/// Configures the ImGui clipboard behaviour to work nicely with XIV.
/// </summary>
/// <remarks>
/// <para>
/// XIV uses '\r' for line endings and will truncate all text after a '\n' character.
/// This means that copy/pasting multi-line text from ImGui to XIV will only copy the first line.
/// </para>
/// <para>
/// ImGui uses '\n' for line endings and will ignore '\r' entirely.
/// This means that copy/pasting multi-line text from XIV to ImGui will copy all the text
/// without line breaks.
/// </para>
/// <para>
/// To fix this we normalize all clipboard line endings entering/exiting ImGui to '\r\n' which
/// works for both ImGui and XIV.
/// </para>
/// </remarks>
[ServiceManager.EarlyLoadedService]
internal sealed unsafe class ImGuiClipboardFunctionProvider : IServiceType, IDisposable
{
private static readonly ModuleLog Log = new(nameof(ImGuiClipboardFunctionProvider));
private readonly nint clipboardUserDataOriginal;
private readonly nint setTextOriginal;
private readonly nint getTextOriginal;
[ServiceManager.ServiceDependency]
private readonly ToastGui toastGui = Service<ToastGui>.Get();
private ImVectorWrapper<byte> clipboardData;
private GCHandle clipboardUserData;
[ServiceManager.ServiceConstructor]
private ImGuiClipboardFunctionProvider(InterfaceManager.InterfaceManagerWithScene imws)
{
// Effectively waiting for ImGui to become available.
_ = imws;
Debug.Assert(ImGuiHelpers.IsImGuiInitialized, "IMWS initialized but IsImGuiInitialized is false?");
var io = ImGui.GetIO();
this.clipboardUserDataOriginal = io.ClipboardUserData;
this.setTextOriginal = io.SetClipboardTextFn;
this.getTextOriginal = io.GetClipboardTextFn;
io.ClipboardUserData = GCHandle.ToIntPtr(this.clipboardUserData = GCHandle.Alloc(this));
io.SetClipboardTextFn = (nint)(delegate* unmanaged<nint, byte*, void>)&StaticSetClipboardTextImpl;
io.GetClipboardTextFn = (nint)(delegate* unmanaged<nint, byte*>)&StaticGetClipboardTextImpl;
this.clipboardData = new(0);
return;
[UnmanagedCallersOnly]
static void StaticSetClipboardTextImpl(nint userData, byte* text) =>
((ImGuiClipboardFunctionProvider)GCHandle.FromIntPtr(userData).Target)!.SetClipboardTextImpl(text);
[UnmanagedCallersOnly]
static byte* StaticGetClipboardTextImpl(nint userData) =>
((ImGuiClipboardFunctionProvider)GCHandle.FromIntPtr(userData).Target)!.GetClipboardTextImpl();
}
/// <inheritdoc/>
public void Dispose()
{
if (!this.clipboardUserData.IsAllocated)
return;
var io = ImGui.GetIO();
io.SetClipboardTextFn = this.setTextOriginal;
io.GetClipboardTextFn = this.getTextOriginal;
io.ClipboardUserData = this.clipboardUserDataOriginal;
this.clipboardUserData.Free();
this.clipboardData.Dispose();
}
private bool OpenClipboardOrShowError()
{
if (!OpenClipboard(default))
{
this.toastGui.ShowError(
Loc.Localize(
"ImGuiClipboardFunctionProviderClipboardInUse",
"Some other application is using the clipboard. Try again later."));
return false;
}
return true;
}
private void SetClipboardTextImpl(byte* text)
{
if (!this.OpenClipboardOrShowError())
return;
try
{
var len = 0;
while (text[len] != 0)
len++;
var str = Encoding.UTF8.GetString(text, len);
str = str.ReplaceLineEndings("\r\n");
var hMem = GlobalAlloc(GMEM.GMEM_MOVEABLE, (nuint)((str.Length + 1) * 2));
if (hMem == 0)
throw new OutOfMemoryException();
var ptr = (char*)GlobalLock(hMem);
if (ptr == null)
{
throw Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error())
?? throw new InvalidOperationException($"{nameof(GlobalLock)} failed.");
}
str.AsSpan().CopyTo(new(ptr, str.Length));
ptr[str.Length] = default;
GlobalUnlock(hMem);
SetClipboardData(CF.CF_UNICODETEXT, hMem);
}
catch (Exception e)
{
Log.Error(e, $"Error in {nameof(this.SetClipboardTextImpl)}");
this.toastGui.ShowError(
Loc.Localize(
"ImGuiClipboardFunctionProviderErrorCopy",
"Failed to copy. See logs for details."));
}
finally
{
CloseClipboard();
}
}
private byte* GetClipboardTextImpl()
{
this.clipboardData.Clear();
var formats = stackalloc uint[] { CF.CF_UNICODETEXT, CF.CF_TEXT };
if (GetPriorityClipboardFormat(formats, 2) < 1 || !this.OpenClipboardOrShowError())
{
this.clipboardData.Add(0);
return this.clipboardData.Data;
}
try
{
var hMem = (HGLOBAL)GetClipboardData(CF.CF_UNICODETEXT);
if (hMem != default)
{
var ptr = (char*)GlobalLock(hMem);
if (ptr == null)
{
throw Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error())
?? throw new InvalidOperationException($"{nameof(GlobalLock)} failed.");
}
var str = new string(ptr);
str = str.ReplaceLineEndings("\r\n");
this.clipboardData.Resize(Encoding.UTF8.GetByteCount(str) + 1);
Encoding.UTF8.GetBytes(str, this.clipboardData.DataSpan);
this.clipboardData[^1] = 0;
}
else
{
this.clipboardData.Add(0);
}
}
catch (Exception e)
{
Log.Error(e, $"Error in {nameof(this.GetClipboardTextImpl)}");
this.toastGui.ShowError(
Loc.Localize(
"ImGuiClipboardFunctionProviderErrorPaste",
"Failed to paste. See logs for details."));
}
finally
{
CloseClipboard();
}
return this.clipboardData.Data;
}
}

View file

@ -240,7 +240,6 @@ internal class InterfaceManager : IDisposable, IServiceType
this.processMessageHook?.Dispose();
}).Wait();
ImGuiClipboardConfig.Unapply();
this.scene?.Dispose();
}
@ -629,7 +628,6 @@ internal class InterfaceManager : IDisposable, IServiceType
ImGui.GetIO().FontGlobalScale = configuration.GlobalUiScale;
this.SetupFonts();
ImGuiClipboardConfig.Apply();
if (!configuration.IsDocking)
{

View file

@ -208,7 +208,7 @@ public unsafe struct ImVectorWrapper<T> : IList<T>, IList, IReadOnlyList<T>, IDi
/// </summary>
/// <param name="initialCapacity">The initial capacity.</param>
/// <param name="destroyer">The destroyer function to call on item removal.</param>
public ImVectorWrapper(int initialCapacity = 0, ImGuiNativeDestroyDelegate? destroyer = null)
public ImVectorWrapper(int initialCapacity, ImGuiNativeDestroyDelegate? destroyer = null)
{
if (initialCapacity < 0)
{
@ -394,7 +394,7 @@ public unsafe struct ImVectorWrapper<T> : IList<T>, IList, IReadOnlyList<T>, IDi
}
/// <inheritdoc cref="List{T}.AddRange"/>
public void AddRange(Span<T> items)
public void AddRange(ReadOnlySpan<T> items)
{
this.EnsureCapacityExponential(this.LengthUnsafe + items.Length);
foreach (var item in items)
@ -466,7 +466,7 @@ public unsafe struct ImVectorWrapper<T> : IList<T>, IList, IReadOnlyList<T>, IDi
/// <param name="capacity">The minimum capacity to ensure.</param>
/// <returns>Whether the capacity has been changed.</returns>
public bool EnsureCapacityExponential(int capacity)
=> this.EnsureCapacity(1 << ((sizeof(int) * 8) - BitOperations.LeadingZeroCount((uint)this.LengthUnsafe)));
=> this.EnsureCapacity(1 << ((sizeof(int) * 8) - BitOperations.LeadingZeroCount((uint)capacity)));
/// <summary>
/// Resizes the underlying array and fills with zeroes if grown.
@ -519,10 +519,11 @@ public unsafe struct ImVectorWrapper<T> : IList<T>, IList, IReadOnlyList<T>, IDi
if (index < 0 || index > this.LengthUnsafe)
throw new IndexOutOfRangeException();
this.EnsureCapacityExponential(this.CapacityUnsafe + 1);
this.EnsureCapacityExponential(this.LengthUnsafe + 1);
var num = this.LengthUnsafe - index;
Buffer.MemoryCopy(this.DataUnsafe + index, this.DataUnsafe + index + 1, num * sizeof(T), num * sizeof(T));
this.DataUnsafe[index] = item;
this.LengthUnsafe += 1;
}
/// <inheritdoc cref="List{T}.InsertRange"/>
@ -535,6 +536,7 @@ public unsafe struct ImVectorWrapper<T> : IList<T>, IList, IReadOnlyList<T>, IDi
Buffer.MemoryCopy(this.DataUnsafe + index, this.DataUnsafe + index + count, num * sizeof(T), num * sizeof(T));
foreach (var item in items)
this.DataUnsafe[index++] = item;
this.LengthUnsafe += count;
}
else
{
@ -543,14 +545,15 @@ public unsafe struct ImVectorWrapper<T> : IList<T>, IList, IReadOnlyList<T>, IDi
}
}
/// <inheritdoc cref="List{T}.AddRange"/>
public void InsertRange(int index, Span<T> items)
/// <inheritdoc cref="List{T}.InsertRange"/>
public void InsertRange(int index, ReadOnlySpan<T> items)
{
this.EnsureCapacityExponential(this.LengthUnsafe + items.Length);
var num = this.LengthUnsafe - index;
Buffer.MemoryCopy(this.DataUnsafe + index, this.DataUnsafe + index + items.Length, num * sizeof(T), num * sizeof(T));
foreach (var item in items)
this.DataUnsafe[index++] = item;
this.LengthUnsafe += items.Length;
}
/// <summary>
@ -558,15 +561,7 @@ public unsafe struct ImVectorWrapper<T> : IList<T>, IList, IReadOnlyList<T>, IDi
/// </summary>
/// <param name="index">The index.</param>
/// <param name="skipDestroyer">Whether to skip calling the destroyer function.</param>
public void RemoveAt(int index, bool skipDestroyer = false)
{
this.EnsureIndex(index);
var num = this.LengthUnsafe - index - 1;
if (!skipDestroyer)
this.destroyer?.Invoke(&this.DataUnsafe[index]);
Buffer.MemoryCopy(this.DataUnsafe + index + 1, this.DataUnsafe + index, num * sizeof(T), num * sizeof(T));
}
public void RemoveAt(int index, bool skipDestroyer = false) => this.RemoveRange(index, 1, skipDestroyer);
/// <inheritdoc/>
void IList<T>.RemoveAt(int index) => this.RemoveAt(index);
@ -574,6 +569,73 @@ public unsafe struct ImVectorWrapper<T> : IList<T>, IList, IReadOnlyList<T>, IDi
/// <inheritdoc/>
void IList.RemoveAt(int index) => this.RemoveAt(index);
/// <summary>
/// Removes <paramref name="count"/> elements at the given index.
/// </summary>
/// <param name="index">The index of the first item to remove.</param>
/// <param name="count">Number of items to remove.</param>
/// <param name="skipDestroyer">Whether to skip calling the destroyer function.</param>
public void RemoveRange(int index, int count, bool skipDestroyer = false)
{
this.EnsureIndex(index);
if (count < 0)
throw new ArgumentOutOfRangeException(nameof(count), count, "Must be positive.");
if (count == 0)
return;
if (!skipDestroyer && this.destroyer is { } d)
{
for (var i = 0; i < count; i++)
d(this.DataUnsafe + index + i);
}
var numItemsToMove = this.LengthUnsafe - index - count;
var numBytesToMove = numItemsToMove * sizeof(T);
Buffer.MemoryCopy(this.DataUnsafe + index + count, this.DataUnsafe + index, numBytesToMove, numBytesToMove);
this.LengthUnsafe -= count;
}
/// <summary>
/// Replaces a sequence at given offset <paramref name="index"/> of <paramref name="count"/> items with
/// <paramref name="replacement"/>.
/// </summary>
/// <param name="index">The index of the first item to be replaced.</param>
/// <param name="count">The number of items to be replaced.</param>
/// <param name="replacement">The replacement.</param>
/// <param name="skipDestroyer">Whether to skip calling the destroyer function.</param>
public void ReplaceRange(int index, int count, ReadOnlySpan<T> replacement, bool skipDestroyer = false)
{
this.EnsureIndex(index);
if (count < 0)
throw new ArgumentOutOfRangeException(nameof(count), count, "Must be positive.");
if (count == 0)
return;
// Ensure the capacity first, so that we can safely destroy the items first.
this.EnsureCapacityExponential((this.LengthUnsafe + replacement.Length) - count);
if (!skipDestroyer && this.destroyer is { } d)
{
for (var i = 0; i < count; i++)
d(this.DataUnsafe + index + i);
}
if (count == replacement.Length)
{
replacement.CopyTo(this.DataSpan[index..]);
}
else if (count > replacement.Length)
{
replacement.CopyTo(this.DataSpan[index..]);
this.RemoveRange(index + replacement.Length, count - replacement.Length);
}
else
{
replacement[..count].CopyTo(this.DataSpan[index..]);
this.InsertRange(index + count, replacement[count..]);
}
}
/// <summary>
/// Sets the capacity exactly as requested.
/// </summary>
@ -611,9 +673,6 @@ public unsafe struct ImVectorWrapper<T> : IList<T>, IList, IReadOnlyList<T>, IDi
if (!oldSpan.IsEmpty && !newSpan.IsEmpty)
oldSpan[..this.LengthUnsafe].CopyTo(newSpan);
// #if DEBUG
// new Span<byte>(newAlloc + this.LengthUnsafe, sizeof(T) * (capacity - this.LengthUnsafe)).Fill(0xCC);
// #endif
if (oldAlloc != null)
ImGuiNative.igMemFree(oldAlloc);