Implement some todos from #27

This commit is contained in:
Infi
2024-04-21 23:16:49 +02:00
parent 926f125cfa
commit 9bdfa58deb
6 changed files with 314 additions and 235 deletions
+54 -19
View File
@@ -84,27 +84,41 @@ internal class LegacyMessageImporterEligibility
}
}
internal LegacyMessageImporter StartImport(MessageStore targetStore, bool noLog = false)
internal LegacyMessageImporter StartImport(MessageStore targetStore, bool noLog = false, Plugin? plugin = null)
{
if (Status != LegacyMessageImporterEligibilityStatus.Eligible)
throw new InvalidOperationException($"Migration not eligible: status is {Status}");
return new LegacyMessageImporter(targetStore, originalDbPath: OriginalDbPath, migrationDbPath: MigrationDbPath, noLog: noLog);
return new LegacyMessageImporter(targetStore, originalDbPath: OriginalDbPath, migrationDbPath: MigrationDbPath, noLog: noLog, plugin);
}
/// <summary>
/// Makes the migration ineligible so the user won't be asked again.
/// </summary>
internal void RenameOldDatabase()
internal bool RenameOldDatabase()
{
File.Move(OriginalDbPath, MigrationDbPath);
Status = LegacyMessageImporterEligibilityStatus.IneligibleMigrationDbExists;
AdditionalIneligibilityInfo = "User chose to rename the old database file";
try
{
File.Move(OriginalDbPath, MigrationDbPath);
Status = LegacyMessageImporterEligibilityStatus.IneligibleMigrationDbExists;
AdditionalIneligibilityInfo = "User chose to rename the old database file";
return true;
}
catch (Exception ex)
{
Plugin.Log.Error(ex, "Unable to move the old database");
return false;
}
}
}
internal class LegacyMessageImporter : IDisposable
internal class LegacyMessageImporter : IAsyncDisposable
{
private readonly Plugin? Plugin;
private readonly CancellationTokenSource CancellationToken = new();
private Thread? WorkingThread = null;
internal const string MessagesCollection = "messages";
private const int MaxFailedMessageLogs = 10;
@@ -130,16 +144,17 @@ internal class LegacyMessageImporter : IDisposable
// This can be set by the user to limit the rate at which messages are
// imported. If the rate exceeds this value, the importer will sleep for the
// remainder of the second.
internal int MaxMessageRate { get; set; } = 250; // start low
internal int MaxMessageRate = 250; // start low
// Do not call this directly, use
// LegacyMessageImporterEligibility.StartImport instead.
internal LegacyMessageImporter(MessageStore targetStore, string? originalDbPath = null, string? migrationDbPath = null, bool noLog = false)
internal LegacyMessageImporter(MessageStore targetStore, string? originalDbPath = null, string? migrationDbPath = null, bool noLog = false, Plugin? plugin = null)
{
_targetStore = targetStore;
originalDbPath ??= Path.Join(Plugin.Interface.ConfigDirectory.FullName, "chat.db");
migrationDbPath ??= migrationDbPath ?? Path.Join(Plugin.Interface.ConfigDirectory.FullName, "chat-litedb.db");
_log = noLog ? null : Plugin.Log;
Plugin = plugin;
_log?.Info($"[Migration] Moving '{originalDbPath}' to '{migrationDbPath}'");
File.Move(originalDbPath, migrationDbPath);
@@ -147,12 +162,30 @@ internal class LegacyMessageImporter : IDisposable
_database = Connect(migrationDbPath);
ImportStart = Environment.TickCount64;
new Thread(DoImport).Start();
WorkingThread = new Thread(() => DoImport(CancellationToken.Token));
WorkingThread.Start();
}
public void Dispose()
{
// TODO: cancel thread and wait for it to close
_database?.Dispose();
}
public async ValueTask DisposeAsync()
{
await CancellationToken.CancelAsync();
var timeout = 10_000; // 10s
while (WorkingThread != null && timeout > 0)
{
if (!WorkingThread.IsAlive)
break;
timeout -= 100;
await Task.Delay(100);
Plugin.Log.Information("Sleeping because thread still alive");
}
_database?.Dispose();
}
@@ -248,7 +281,7 @@ internal class LegacyMessageImporter : IDisposable
return conn;
}
private void DoImport()
private void DoImport(CancellationToken token)
{
var importRateTimer = Stopwatch.StartNew();
var messagesInLastSecond = 0;
@@ -261,6 +294,9 @@ internal class LegacyMessageImporter : IDisposable
var messages = messagesCollection.Query().OrderBy(msg => msg.Date).ToDocuments();
foreach (var messageDoc in messages)
{
if (token.IsCancellationRequested)
return;
try
{
var message = BsonDocumentToMessage(messageDoc);
@@ -271,8 +307,7 @@ internal class LegacyMessageImporter : IDisposable
{
FailedMessages++;
if (FailedMessages <= MaxFailedMessageLogs)
_log?.Error(
$"[Migration] Failed to import message '{messageDoc["_id"].AsObjectId}' (usually due to corruption): {e}");
_log?.Error($"[Migration] Failed to import message '{messageDoc["_id"].AsObjectId}' (usually due to corruption): {e}");
if (FailedMessages == MaxFailedMessageLogs)
_log?.Error("[Migration] Further failed message logs will be suppressed");
}
@@ -293,19 +328,19 @@ internal class LegacyMessageImporter : IDisposable
// Log every 1,000 messages
if ((SuccessfulMessages + FailedMessages) % 1000 == 0)
_log?.Information(
$"[Migration] Progress: successfully imported {SuccessfulMessages}/{totalMessages} messages ({FailedMessages} failures)");
_log?.Information($"[Migration] Progress: successfully imported {SuccessfulMessages}/{totalMessages} messages ({FailedMessages} failures)");
}
_log?.Information($"[Migration] Imported {SuccessfulMessages}/{FailedMessages} messages, {FailedMessages} failed");
if (ProcessedMessages != totalMessages)
_log?.Warning(
$"[Migration] Total message count mismatch: expected {totalMessages}, got {SuccessfulMessages + FailedMessages}");
_log?.Warning($"[Migration] Total message count mismatch: expected {totalMessages}, got {SuccessfulMessages + FailedMessages}");
ImportComplete = Environment.TickCount64;
_database.Dispose();
_database = null;
if (Plugin != null)
Plugin.Framework.Run(() => Plugin.MessageManager.FilterAllTabs(false), token);
}
private static Message BsonDocumentToMessage(BsonDocument doc)
+4 -4
View File
@@ -49,7 +49,7 @@ public sealed class Plugin : IDalamudPlugin
public ChatLogWindow ChatLogWindow { get; }
public CommandHelpWindow CommandHelpWindow { get; }
public SeStringDebugger SeStringDebugger { get; }
internal LegacyMesasgeImporterWindow LegacyMesasgeImporterWindow { get; }
internal LegacyMessageImporterWindow LegacyMessageImporterWindow { get; }
internal Configuration Config { get; }
internal Commands Commands { get; }
@@ -106,8 +106,8 @@ public sealed class Plugin : IDalamudPlugin
MessageManager = new MessageManager(this); // requires Ui
// Requires MessageManager
LegacyMesasgeImporterWindow = new LegacyMesasgeImporterWindow(MessageManager.Store);
WindowSystem.AddWindow(LegacyMesasgeImporterWindow);
LegacyMessageImporterWindow = new LegacyMessageImporterWindow(this);
WindowSystem.AddWindow(LegacyMessageImporterWindow);
// let all the other components register, then initialise commands
Commands.Initialise();
@@ -143,7 +143,7 @@ public sealed class Plugin : IDalamudPlugin
ChatLogWindow?.Dispose();
SettingsWindow?.Dispose();
SeStringDebugger?.Dispose();
LegacyMesasgeImporterWindow?.Dispose();
LegacyMessageImporterWindow?.Dispose();
ExtraChat?.Dispose();
Ipc?.Dispose();
-201
View File
@@ -1,201 +0,0 @@
using System.Numerics;
using ChatTwo.Resources;
using ChatTwo.Util;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Windowing;
using ImGuiNET;
namespace ChatTwo.Ui;
internal class LegacyMesasgeImporterWindow : Window
{
private readonly MessageStore _store;
private LegacyMessageImporterEligibility Eligibility { get; set; }
private LegacyMessageImporter? Importer { get; set; }
internal LegacyMesasgeImporterWindow(MessageStore store) : base("Chat 2 Legacy Importer###chat2-legacy-importer")
{
_store = store;
Eligibility = LegacyMessageImporterEligibility.CheckEligibility();
LogAndNotify();
}
public void Dispose()
{
Importer?.Dispose();
}
private void LogAndNotify()
{
Plugin.Log.Info(
$"[Migration] Checked migration eligibility: {Eligibility.Status} - '{Eligibility.AdditionalIneligibilityInfo}'");
switch (Eligibility.Status)
{
case LegacyMessageImporterEligibilityStatus.Eligible:
{
var notification = Plugin.Notification.AddNotification(new Notification
{
Type = NotificationType.Info,
// The user needs to dismiss this for it to go away.
InitialDuration = TimeSpan.FromHours(6),
Title = "Chat 2 Migration",
Content = "Import messages from old database into new database? Click for more information.",
});
// TODO: clicking does not dismiss
notification.Click += _ => IsOpen = true;
break;
}
case LegacyMessageImporterEligibilityStatus.IneligibleLiteDbFailed:
{
var notification = Plugin.Notification.AddNotification(new Notification
{
Type = NotificationType.Warning,
InitialDuration = TimeSpan.FromMinutes(1),
Title = "Chat Two Migration",
Content =
"Migration is not possible because the old database could not be opened. Click for more information."
});
// TODO: clicking does not dismiss
notification.Click += _ => IsOpen = true;
break;
}
}
}
public override void Draw()
{
if (Importer != null)
{
DrawImportStatus();
return;
}
if (Eligibility.Status == LegacyMessageImporterEligibilityStatus.Eligible)
DrawEligible();
else
DrawIneligible();
}
private void DrawEligible()
{
// TODO: pretty
ImGui.Text("Import database messages from legacy LiteDB database to Sqlite database?");
ImGui.Text($"Message count: {Eligibility.MessageCount}");
ImGui.Text($"Database size: {Eligibility.DatabaseSizeBytes}");
if (ImGui.Button("Yes, import messages"))
{
// Next draw call will run DrawImportStatus().
Importer = Eligibility.StartImport(_store);
return;
}
ImGui.SameLine();
if (ImGuiUtil.CtrlShiftButton("No, do not import messages",
"Ctrl+Shift: renames old database to avoid prompting again"))
{
Eligibility.RenameOldDatabase();
IsOpen = false;
}
}
private void DrawIneligible()
{
// TODO: pretty
ImGui.Text("Your legacy LiteDB database is not eligible for import:");
switch (Eligibility.Status)
{
case LegacyMessageImporterEligibilityStatus.IneligibleOriginalDbNotExists:
ImGui.Text("The old database could not be found.");
break;
case LegacyMessageImporterEligibilityStatus.IneligibleMigrationDbExists:
ImGui.Text("The migration process was already started.");
break;
case LegacyMessageImporterEligibilityStatus.IneligibleLiteDbFailed:
ImGui.Text("The old database could not be opened.");
break;
case LegacyMessageImporterEligibilityStatus.IneligibleNoMessages:
ImGui.Text("The old database contains no messages.");
break;
case LegacyMessageImporterEligibilityStatus.Eligible:
default:
throw new ArgumentOutOfRangeException();
}
if (!string.IsNullOrWhiteSpace(Eligibility.AdditionalIneligibilityInfo))
ImGui.Text(Eligibility.AdditionalIneligibilityInfo);
// LiteDB failures notify the user, so give them a chance to rename the
// database to avoid prompting again.
if (Eligibility.Status == LegacyMessageImporterEligibilityStatus.IneligibleLiteDbFailed)
{
if (ImGuiUtil.CtrlShiftButton("Rename old database",
"Ctrl+Shift: rename old database to avoid import prompt in the future"))
{
Eligibility.RenameOldDatabase();
// TODO: notify success as this changes the status
}
}
}
private void DrawImportStatus()
{
// TODO: pretty
if (Importer == null)
return;
var importStart = Importer.ImportStart;
var importEnd = Importer.ImportComplete;
var total = Importer.ImportCount;
var successful = Importer!.SuccessfulMessages;
var failed = Importer.FailedMessages;
var remaining = Importer.RemainingMessages;
if (importEnd != null)
{
ImGui.Text($"Completed migration in {Duration(importStart, importEnd.Value)}");
ImGui.Text($"Successfully imported: {successful} messages");
ImGui.Text($"Failed to import: {failed} messages");
ImGui.Text($"Unaccounted for: {remaining}");
ImGui.Text("See logs for more details: /xllog");
return;
}
// TODO: implement Importer.MaxMessageRate slider in UI, values 0 (infinity) => 10000
ImGui.Text($"Importing messages... {Importer.Progress:P}%");
ImGui.Text($"Duration: {Duration(importStart, Environment.TickCount64)}");
ImGui.Text($"Successfully imported: {successful} messages");
ImGui.Text($"Failed to import: {failed} messages");
ImGui.Text($"Progress: {Importer.ProcessedMessages}/{total} messages");
ImGui.Text($"Remaining: {remaining} messages");
ImGui.Text($"Messages per second: {Importer.CurrentMessageRate}");
ImGui.Text($"Estimated time remaining: {Importer.EstimatedTimeRemaining}");
ImGui.Text("See logs for more details: /xllog");
// TODO: this doesn't render properly
ImGui.ProgressBar(Importer.Progress, new Vector2(0.0f, 0.0f), $"{Importer.Progress:P}%");
if (ImGuiUtil.CtrlShiftButton("Cancel import", "Ctrl+Shift: cancel import and close window"))
{
// TODO: This currently crashes the whole game because we don't ask
// the importer thread to stop and wait for it to stop before
// disposing it.
// See LegacyMessageImporter.Dispose() for more details.
/*
Importer.Dispose();
Importer = null;
Eligibility = LegacyMessageImporterEligibility.CheckEligibility();
*/
}
}
private static TimeSpan Duration(long startTicks, long endTicks)
{
return endTicks < startTicks ? TimeSpan.Zero : TimeSpan.FromTicks(endTicks - startTicks);
}
}
+225
View File
@@ -0,0 +1,225 @@
using System.Numerics;
using ChatTwo.Util;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Components;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.ImGuiNotification.EventArgs;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing;
using ImGuiNET;
namespace ChatTwo.Ui;
internal class LegacyMessageImporterWindow : Window
{
private readonly Plugin Plugin;
private readonly MessageStore _store;
private LegacyMessageImporterEligibility Eligibility { get; set; }
private LegacyMessageImporter? Importer { get; set; }
internal LegacyMessageImporterWindow(Plugin plugin) : base("Chat 2 Legacy Importer###chat2-legacy-importer")
{
Plugin = plugin;
Flags = ImGuiWindowFlags.NoResize;
Size = new Vector2(500, 400);
_store = plugin.MessageManager.Store;
Eligibility = LegacyMessageImporterEligibility.CheckEligibility();
LogAndNotify();
}
public void Dispose()
{
Importer?.Dispose();
}
private void NotificationClicked(INotificationClickArgs args)
{
IsOpen = true;
args.Notification.DismissNow();
}
private void LogAndNotify()
{
Plugin.Log.Info($"[Migration] Checked migration eligibility: {Eligibility.Status} - '{Eligibility.AdditionalIneligibilityInfo}'");
switch (Eligibility.Status)
{
case LegacyMessageImporterEligibilityStatus.Eligible:
{
var notification = Plugin.Notification.AddNotification(new Notification
{
// The user needs to dismiss this for it to go away.
Type = NotificationType.Info,
InitialDuration = TimeSpan.FromHours(24),
Title = "Chat2 Migration",
Content = "Import messages from old database into new database?\nClick for more information.",
Minimized = false,
});
notification.Click += NotificationClicked;
break;
}
case LegacyMessageImporterEligibilityStatus.IneligibleLiteDbFailed:
{
var notification = Plugin.Notification.AddNotification(new Notification
{
Type = NotificationType.Warning,
InitialDuration = TimeSpan.FromMinutes(1),
Title = "Chat2 Migration",
Content = "Migration is not possible because the old database could not be opened.\nClick for more information.",
Minimized = false,
});
notification.Click += NotificationClicked;
break;
}
}
}
public override void Draw()
{
if (Importer != null)
{
DrawImportStatus();
return;
}
if (Eligibility.Status == LegacyMessageImporterEligibilityStatus.Eligible)
DrawEligible();
else
DrawIneligible();
}
private void DrawEligible()
{
ImGui.TextWrapped("Import database messages from legacy LiteDB database to SQLite database?");
ImGui.Text($"Message count: {Eligibility.MessageCount:N0}");
ImGui.Text($"Database size: {StringUtil.BytesToString(Eligibility.DatabaseSizeBytes)}");
ImGui.Spacing();
var colorNormal = new Vector4(0.0f, 0.70f, 0.0f, 1.0f);
var colorHovered = new Vector4(0.059f, 0.49f, 0.0f, 1.0f);
using (ImRaii.PushColor(ImGuiCol.Button, colorNormal))
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, colorHovered))
{
if (ImGui.Button("Yes, import messages"))
{
// Next draw call will run DrawImportStatus().
Importer = Eligibility.StartImport(_store, plugin: Plugin);
return;
}
}
ImGui.SameLine();
if (ImGuiUtil.CtrlShiftButtonColored("No, do not import messages", "Ctrl+Shift: renames old database to avoid prompting again"))
{
Eligibility.RenameOldDatabase();
IsOpen = false;
}
}
private void DrawIneligible()
{
ImGui.Text("Your legacy LiteDB database is not eligible for import:");
switch (Eligibility.Status)
{
case LegacyMessageImporterEligibilityStatus.IneligibleOriginalDbNotExists:
ImGui.Text("The old database could not be found.");
break;
case LegacyMessageImporterEligibilityStatus.IneligibleMigrationDbExists:
ImGui.Text("The migration process was already started.");
break;
case LegacyMessageImporterEligibilityStatus.IneligibleLiteDbFailed:
ImGui.Text("The old database could not be opened.");
break;
case LegacyMessageImporterEligibilityStatus.IneligibleNoMessages:
ImGui.Text("The old database contains no messages.");
break;
case LegacyMessageImporterEligibilityStatus.Eligible:
default:
throw new ArgumentOutOfRangeException();
}
if (!string.IsNullOrWhiteSpace(Eligibility.AdditionalIneligibilityInfo))
ImGui.Text(Eligibility.AdditionalIneligibilityInfo);
// LiteDB failures notify the user, so give them a chance to rename the
// database to avoid prompting again.
if (Eligibility.Status == LegacyMessageImporterEligibilityStatus.IneligibleLiteDbFailed)
{
if (ImGuiUtil.CtrlShiftButton("Rename old database", "Ctrl+Shift: rename old database to avoid import prompt in the future"))
{
if (Eligibility.RenameOldDatabase())
WrapperUtil.AddNotification("Successfully renamed the old database.", NotificationType.Success);
else
WrapperUtil.AddNotification("Rename failed, please check /xllog for more information.", NotificationType.Error);
}
}
}
private void DrawImportStatus()
{
if (Importer == null)
return;
if (Importer.ImportComplete != null)
{
ImGui.TextUnformatted($"Completed migration in {Duration(Importer.ImportStart, Importer.ImportComplete.Value):g}");
ImGui.TextUnformatted($"Successfully imported: {Importer.SuccessfulMessages} messages");
ImGui.TextUnformatted($"Failed to import: {Importer.FailedMessages} messages");
ImGui.TextUnformatted($"Unaccounted for: {Importer.RemainingMessages}");
ImGui.TextUnformatted("See logs for more details: /xllog");
ImGui.Spacing();
if (ImGui.Button("Finish"))
IsOpen = false;
return;
}
ImGui.TextUnformatted($"Importing messages ... {Importer.Progress:P}");
ImGuiHelpers.ScaledDummy(10.0f);
ImGui.TextUnformatted($"Duration: {Duration(Importer.ImportStart, Environment.TickCount64):g}");
ImGui.TextUnformatted($"Progress: {Importer.ProcessedMessages}/{Importer.ImportCount} messages ({Importer.FailedMessages} failed)");
ImGuiHelpers.ScaledDummy(10.0f);
var width = ImGui.GetContentRegionAvail().X / 2;
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Import speed:");
ImGui.SameLine();
ImGui.SetNextItemWidth(width);
ImGui.SliderInt("##speedSlider", ref Importer.MaxMessageRate, 1, 10000, "%d m/sec", ImGuiSliderFlags.AlwaysClamp);
ImGui.TextUnformatted($"Current speed: {Importer.CurrentMessageRate:N0} m/sec");
ImGui.TextUnformatted($"Estimated time remaining: {Importer.EstimatedTimeRemaining:g}");
ImGui.TextUnformatted("See logs for more details: /xllog");
ImGuiHelpers.ScaledDummy(10.0f);
ImGui.ProgressBar(Importer.Progress, new Vector2(-1, 0), $"{Importer.Progress:P}");
ImGui.Spacing();
if (ImGuiUtil.CtrlShiftButton("Cancel import", "Ctrl+Shift: cancel import and close window"))
{
Task.Run(async () =>
{
await Importer.DisposeAsync();
Importer = null;
Eligibility = LegacyMessageImporterEligibility.CheckEligibility();
});
}
}
private static TimeSpan Duration(long startTicks, long endTicks)
{
return endTicks < startTicks ? TimeSpan.Zero : TimeSpan.FromMilliseconds(endTicks - startTicks);
}
}
+2 -2
View File
@@ -108,11 +108,11 @@ internal sealed class Database : ISettingsTab
ImGuiUtil.HelpText(string.Format(Language.Options_Database_Metadata_Size, StringUtil.BytesToString(DatabaseSize)));
if (ImGui.IsItemHovered())
ImGui.SetTooltip(DatabaseSize.ToString("N0") + "B");
ImGui.SetTooltip(StringUtil.BytesToString(DatabaseSize));
ImGuiUtil.HelpText(string.Format(Language.Options_Database_Metadata_LogSize, StringUtil.BytesToString(DatabaseLogSize)));
if (ImGui.IsItemHovered())
ImGui.SetTooltip(DatabaseLogSize.ToString("N0") + "B");
ImGui.SetTooltip(StringUtil.BytesToString(DatabaseLogSize));
ImGuiUtil.HelpText(string.Format(Language.Options_Database_Metadata_MessageCount, DatabaseMessageCount));
+29 -9
View File
@@ -5,6 +5,7 @@ using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Interface;
using Dalamud.Interface.Style;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using ImGuiNET;
namespace ChatTwo.Util;
@@ -262,20 +263,39 @@ internal static class ImGuiUtil
internal static bool CtrlShiftButton(string label, string tooltip = "")
{
var io = ImGui.GetIO();
var ctrlShiftHeld = io.KeyCtrl && io.KeyShift;
if (!ctrlShiftHeld) ImGui.BeginDisabled();
var ctrlShiftHeld = ImGui.GetIO() is { KeyCtrl: true, KeyShift: true };
if (!ctrlShiftHeld)
ImGui.BeginDisabled();
var ret = ImGui.Button(label) && ctrlShiftHeld;
if (!ctrlShiftHeld) ImGui.EndDisabled();
if (!string.IsNullOrEmpty(tooltip) && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) {
ImGui.BeginTooltip();
ImGui.TextUnformatted(tooltip);
ImGui.EndTooltip();
}
if (!ctrlShiftHeld)
ImGui.EndDisabled();
if (!string.IsNullOrEmpty(tooltip) && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
ImGui.SetTooltip(tooltip);
return ret;
}
internal static bool CtrlShiftButtonColored(string label, string tooltip = "")
{
var ctrlShiftHeld = ImGui.GetIO() is { KeyCtrl: true, KeyShift: true };
var colorNormal = new Vector4(0.780f, 0.245f, 0.245f, 1.0f);
var colorHovered = new Vector4(0.7f, 0.0f, 0.0f, 1.0f);
using (ImRaii.PushColor(ImGuiCol.Button, colorNormal))
using (ImRaii.PushColor(ImGuiCol.ButtonHovered, colorHovered))
{
var ret = ImGui.Button(label) && ctrlShiftHeld;
if (!string.IsNullOrEmpty(tooltip) && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
ImGui.SetTooltip(tooltip);
return ret;
}
}
internal static bool TryToImGui(this VirtualKey key, out ImGuiKey result)
{
result = key switch {