mirror of
https://github.com/goatcorp/Dalamud.git
synced 2026-02-12 11:04:38 +01:00
Merge branch 'master' into dragdropb
This commit is contained in:
commit
bd8da4bebf
72 changed files with 2683 additions and 637 deletions
|
|
@ -29,13 +29,19 @@ internal class DalamudCommands : IServiceType
|
|||
HelpMessage = Loc.Localize("DalamudUnloadHelp", "Unloads XIVLauncher in-game addon."),
|
||||
ShowInHelp = false,
|
||||
});
|
||||
|
||||
|
||||
commandManager.AddHandler("/xlkill", new CommandInfo(this.OnKillCommand)
|
||||
{
|
||||
HelpMessage = "Kill the game.",
|
||||
ShowInHelp = false,
|
||||
});
|
||||
|
||||
commandManager.AddHandler("/xlrestart", new CommandInfo(this.OnRestartCommand)
|
||||
{
|
||||
HelpMessage = "Restart the game.",
|
||||
ShowInHelp = false,
|
||||
});
|
||||
|
||||
commandManager.AddHandler("/xlhelp", new CommandInfo(this.OnHelpCommand)
|
||||
{
|
||||
HelpMessage = Loc.Localize("DalamudCmdInfoHelp", "Shows list of commands available. If an argument is provided, shows help for that command."),
|
||||
|
|
@ -147,12 +153,17 @@ internal class DalamudCommands : IServiceType
|
|||
Service<ChatGui>.Get().Print("Unloading...");
|
||||
Service<Dalamud>.Get().Unload();
|
||||
}
|
||||
|
||||
|
||||
private void OnKillCommand(string command, string arguments)
|
||||
{
|
||||
Process.GetCurrentProcess().Kill();
|
||||
}
|
||||
|
||||
private void OnRestartCommand(string command, string arguments)
|
||||
{
|
||||
Dalamud.RestartGame();
|
||||
}
|
||||
|
||||
private void OnHelpCommand(string command, string arguments)
|
||||
{
|
||||
var chatGui = Service<ChatGui>.Get();
|
||||
|
|
|
|||
|
|
@ -717,12 +717,7 @@ internal class DalamudInterface : IDisposable, IServiceType
|
|||
|
||||
if (ImGui.MenuItem("Restart game"))
|
||||
{
|
||||
[DllImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
static extern void RaiseException(uint dwExceptionCode, uint dwExceptionFlags, uint nNumberOfArguments, IntPtr lpArguments);
|
||||
|
||||
RaiseException(0x12345678, 0, 0, IntPtr.Zero);
|
||||
Process.GetCurrentProcess().Kill();
|
||||
Dalamud.RestartGame();
|
||||
}
|
||||
|
||||
if (ImGui.MenuItem("Kill game"))
|
||||
|
|
@ -802,6 +797,11 @@ internal class DalamudInterface : IDisposable, IServiceType
|
|||
ImGui.SetWindowFocus(null);
|
||||
}
|
||||
|
||||
if (ImGui.MenuItem("Clear stacks"))
|
||||
{
|
||||
Service<InterfaceManager>.Get().ClearStacks();
|
||||
}
|
||||
|
||||
if (ImGui.MenuItem("Dump style"))
|
||||
{
|
||||
var info = string.Empty;
|
||||
|
|
|
|||
|
|
@ -435,6 +435,15 @@ internal class InterfaceManager : IDisposable, IServiceType
|
|||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear font, style, and color stack. Dangerous, only use when you know
|
||||
/// no one else has something pushed they may try to pop.
|
||||
/// </summary>
|
||||
public void ClearStacks()
|
||||
{
|
||||
this.scene?.ClearStacksOnContext();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggle Windows 11 immersive mode on the game window.
|
||||
/// </summary>
|
||||
|
|
@ -892,6 +901,7 @@ internal class InterfaceManager : IDisposable, IServiceType
|
|||
Log.Verbose("[FONT] ImGui.IO.Build will be called.");
|
||||
ioFonts.Build();
|
||||
gameFontManager.AfterIoFontsBuild();
|
||||
this.ClearStacks();
|
||||
Log.Verbose("[FONT] ImGui.IO.Build OK!");
|
||||
|
||||
gameFontManager.AfterBuildFonts();
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ internal class PluginCategoryManager
|
|||
new(11, "special.devIconTester", () => Locs.Category_IconTester),
|
||||
new(12, "special.dalamud", () => Locs.Category_Dalamud),
|
||||
new(13, "special.plugins", () => Locs.Category_Plugins),
|
||||
new(14, "special.profiles", () => Locs.Category_PluginProfiles, CategoryInfo.AppearCondition.ProfilesEnabled),
|
||||
new(FirstTagBasedCategoryId + 0, "other", () => Locs.Category_Other),
|
||||
new(FirstTagBasedCategoryId + 1, "jobs", () => Locs.Category_Jobs),
|
||||
new(FirstTagBasedCategoryId + 2, "ui", () => Locs.Category_UI),
|
||||
|
|
@ -43,7 +44,7 @@ internal class PluginCategoryManager
|
|||
private GroupInfo[] groupList =
|
||||
{
|
||||
new(GroupKind.DevTools, () => Locs.Group_DevTools, 10, 11),
|
||||
new(GroupKind.Installed, () => Locs.Group_Installed, 0, 1),
|
||||
new(GroupKind.Installed, () => Locs.Group_Installed, 0, 1, 14),
|
||||
new(GroupKind.Available, () => Locs.Group_Available, 0),
|
||||
new(GroupKind.Changelog, () => Locs.Group_Changelog, 0, 12, 13),
|
||||
|
||||
|
|
@ -352,6 +353,11 @@ internal class PluginCategoryManager
|
|||
/// Check if plugin testing is enabled.
|
||||
/// </summary>
|
||||
DoPluginTest,
|
||||
|
||||
/// <summary>
|
||||
/// Check if plugin profiles are enabled.
|
||||
/// </summary>
|
||||
ProfilesEnabled,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -430,6 +436,8 @@ internal class PluginCategoryManager
|
|||
|
||||
public static string Category_IconTester => "Image/Icon Tester";
|
||||
|
||||
public static string Category_PluginProfiles => Loc.Localize("InstallerCategoryPluginProfiles", "Plugin Collections");
|
||||
|
||||
public static string Category_Other => Loc.Localize("InstallerCategoryOther", "Other");
|
||||
|
||||
public static string Category_Jobs => Loc.Localize("InstallerCategoryJobs", "Jobs");
|
||||
|
|
|
|||
|
|
@ -1792,7 +1792,8 @@ internal class DataWindow : Window
|
|||
ImGui.TableNextColumn();
|
||||
ImGui.TextUnformatted(string.Join(", ", share.Users));
|
||||
}
|
||||
} finally
|
||||
}
|
||||
finally
|
||||
{
|
||||
ImGui.EndTable();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||
using System.Linq;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Dalamud.Networking.Http;
|
||||
using Dalamud.Plugin.Internal;
|
||||
using Dalamud.Utility;
|
||||
|
|
|
|||
|
|
@ -15,12 +15,14 @@ using Dalamud.Game.Command;
|
|||
using Dalamud.Interface.Colors;
|
||||
using Dalamud.Interface.Components;
|
||||
using Dalamud.Interface.Internal.Notifications;
|
||||
using Dalamud.Interface.Raii;
|
||||
using Dalamud.Interface.Style;
|
||||
using Dalamud.Interface.Windowing;
|
||||
using Dalamud.Logging.Internal;
|
||||
using Dalamud.Plugin;
|
||||
using Dalamud.Plugin.Internal;
|
||||
using Dalamud.Plugin.Internal.Exceptions;
|
||||
using Dalamud.Plugin.Internal.Profiles;
|
||||
using Dalamud.Plugin.Internal.Types;
|
||||
using Dalamud.Support;
|
||||
using Dalamud.Utility;
|
||||
|
|
@ -48,6 +50,8 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
|
||||
private readonly object listLock = new();
|
||||
|
||||
private readonly ProfileManagerWidget profileManagerWidget;
|
||||
|
||||
private DalamudChangelogManager? dalamudChangelogManager;
|
||||
private Task? dalamudChangelogRefreshTask;
|
||||
private CancellationTokenSource? dalamudChangelogRefreshTaskCts;
|
||||
|
|
@ -148,6 +152,8 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
});
|
||||
|
||||
this.timeLoaded = DateTime.Now;
|
||||
|
||||
this.profileManagerWidget = new(this);
|
||||
}
|
||||
|
||||
private enum OperationStatus
|
||||
|
|
@ -166,6 +172,7 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
UpdatingAll,
|
||||
Installing,
|
||||
Manager,
|
||||
ProfilesLoading,
|
||||
}
|
||||
|
||||
private enum PluginSortKind
|
||||
|
|
@ -212,6 +219,8 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
this.updatePluginCount = 0;
|
||||
this.updatedPlugins = null;
|
||||
}
|
||||
|
||||
this.profileManagerWidget.Reset();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
|
@ -284,17 +293,96 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
this.searchText = text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start a plugin install and handle errors visually.
|
||||
/// </summary>
|
||||
/// <param name="manifest">The manifest to install.</param>
|
||||
/// <param name="useTesting">Install the testing version.</param>
|
||||
public void StartInstall(RemotePluginManifest manifest, bool useTesting)
|
||||
{
|
||||
var pluginManager = Service<PluginManager>.Get();
|
||||
var notifications = Service<NotificationManager>.Get();
|
||||
|
||||
this.installStatus = OperationStatus.InProgress;
|
||||
this.loadingIndicatorKind = LoadingIndicatorKind.Installing;
|
||||
|
||||
Task.Run(() => pluginManager.InstallPluginAsync(manifest, useTesting || manifest.IsTestingExclusive, PluginLoadReason.Installer))
|
||||
.ContinueWith(task =>
|
||||
{
|
||||
// There is no need to set as Complete for an individual plugin installation
|
||||
this.installStatus = OperationStatus.Idle;
|
||||
if (this.DisplayErrorContinuation(task, Locs.ErrorModal_InstallFail(manifest.Name)))
|
||||
{
|
||||
// Fine as long as we aren't in an error state
|
||||
if (task.Result.State is PluginState.Loaded or PluginState.Unloaded)
|
||||
{
|
||||
notifications.AddNotification(Locs.Notifications_PluginInstalled(manifest.Name), Locs.Notifications_PluginInstalledTitle, NotificationType.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
notifications.AddNotification(Locs.Notifications_PluginNotInstalled(manifest.Name), Locs.Notifications_PluginNotInstalledTitle, NotificationType.Error);
|
||||
this.ShowErrorModal(Locs.ErrorModal_InstallFail(manifest.Name));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A continuation task that displays any errors received into the error modal.
|
||||
/// </summary>
|
||||
/// <param name="task">The previous task.</param>
|
||||
/// <param name="state">An error message to be displayed.</param>
|
||||
/// <returns>A value indicating whether to continue with the next task.</returns>
|
||||
public bool DisplayErrorContinuation(Task task, object state)
|
||||
{
|
||||
if (task.IsFaulted)
|
||||
{
|
||||
var errorModalMessage = state as string;
|
||||
|
||||
foreach (var ex in task.Exception.InnerExceptions)
|
||||
{
|
||||
if (ex is PluginException)
|
||||
{
|
||||
Log.Error(ex, "Plugin installer threw an error");
|
||||
#if DEBUG
|
||||
if (!string.IsNullOrEmpty(ex.Message))
|
||||
errorModalMessage += $"\n\n{ex.Message}";
|
||||
#endif
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Error(ex, "Plugin installer threw an unexpected error");
|
||||
#if DEBUG
|
||||
if (!string.IsNullOrEmpty(ex.Message))
|
||||
errorModalMessage += $"\n\n{ex.Message}";
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
this.ShowErrorModal(errorModalMessage);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void DrawProgressOverlay()
|
||||
{
|
||||
var pluginManager = Service<PluginManager>.Get();
|
||||
var profileManager = Service<ProfileManager>.Get();
|
||||
|
||||
var isWaitingManager = !pluginManager.PluginsReady ||
|
||||
!pluginManager.ReposReady;
|
||||
var isWaitingProfiles = profileManager.IsBusy;
|
||||
|
||||
var isLoading = this.AnyOperationInProgress ||
|
||||
isWaitingManager;
|
||||
isWaitingManager || isWaitingProfiles;
|
||||
|
||||
if (isWaitingManager)
|
||||
this.loadingIndicatorKind = LoadingIndicatorKind.Manager;
|
||||
else if (isWaitingProfiles)
|
||||
this.loadingIndicatorKind = LoadingIndicatorKind.ProfilesLoading;
|
||||
|
||||
if (!isLoading)
|
||||
return;
|
||||
|
|
@ -378,6 +466,9 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case LoadingIndicatorKind.ProfilesLoading:
|
||||
ImGuiHelpers.CenteredText("Collections are being applied...");
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
|
|
@ -386,9 +477,7 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
if (DateTime.Now - this.timeLoaded > TimeSpan.FromSeconds(90) && !pluginManager.PluginsReady)
|
||||
{
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudRed);
|
||||
ImGuiHelpers.CenteredText("This is embarrassing, but...");
|
||||
ImGuiHelpers.CenteredText("one of your plugins may be blocking the installer.");
|
||||
ImGuiHelpers.CenteredText("You should tell us about this, please keep this window open.");
|
||||
ImGuiHelpers.CenteredText("One of your plugins may be blocking the installer.");
|
||||
ImGui.PopStyleColor();
|
||||
}
|
||||
|
||||
|
|
@ -433,54 +522,57 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
|
||||
ImGui.SetCursorPosX(windowSize.X - sortSelectWidth - (style.ItemSpacing.X * 2) - searchInputWidth - searchClearButtonWidth);
|
||||
|
||||
var searchTextChanged = false;
|
||||
ImGui.SetNextItemWidth(searchInputWidth);
|
||||
searchTextChanged |= ImGui.InputTextWithHint(
|
||||
"###XlPluginInstaller_Search",
|
||||
Locs.Header_SearchPlaceholder,
|
||||
ref this.searchText,
|
||||
100);
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosY(downShift);
|
||||
|
||||
ImGui.SetNextItemWidth(searchClearButtonWidth);
|
||||
if (ImGuiComponents.IconButton(FontAwesomeIcon.Times))
|
||||
var isProfileManager =
|
||||
this.categoryManager.CurrentGroupIdx == 1 && this.categoryManager.CurrentCategoryIdx == 2;
|
||||
|
||||
// Disable search if profile editor
|
||||
using (ImRaii.Disabled(isProfileManager))
|
||||
{
|
||||
this.searchText = string.Empty;
|
||||
searchTextChanged = true;
|
||||
}
|
||||
var searchTextChanged = false;
|
||||
ImGui.SetNextItemWidth(searchInputWidth);
|
||||
searchTextChanged |= ImGui.InputTextWithHint(
|
||||
"###XlPluginInstaller_Search",
|
||||
Locs.Header_SearchPlaceholder,
|
||||
ref this.searchText,
|
||||
100);
|
||||
|
||||
if (searchTextChanged)
|
||||
this.UpdateCategoriesOnSearchChange();
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosY(downShift);
|
||||
|
||||
// Changelog group
|
||||
var isSortDisabled = this.categoryManager.CurrentGroupIdx == 3;
|
||||
if (isSortDisabled)
|
||||
ImGui.BeginDisabled();
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosY(downShift);
|
||||
ImGui.SetNextItemWidth(selectableWidth);
|
||||
if (ImGui.BeginCombo(sortByText, this.filterText, ImGuiComboFlags.NoArrowButton))
|
||||
{
|
||||
foreach (var selectable in sortSelectables)
|
||||
ImGui.SetNextItemWidth(searchClearButtonWidth);
|
||||
if (ImGuiComponents.IconButton(FontAwesomeIcon.Times))
|
||||
{
|
||||
if (ImGui.Selectable(selectable.Localization))
|
||||
{
|
||||
this.sortKind = selectable.SortKind;
|
||||
this.filterText = selectable.Localization;
|
||||
|
||||
lock (this.listLock)
|
||||
this.ResortPlugins();
|
||||
}
|
||||
this.searchText = string.Empty;
|
||||
searchTextChanged = true;
|
||||
}
|
||||
|
||||
ImGui.EndCombo();
|
||||
if (searchTextChanged)
|
||||
this.UpdateCategoriesOnSearchChange();
|
||||
}
|
||||
|
||||
if (isSortDisabled)
|
||||
ImGui.EndDisabled();
|
||||
// Disable sort if changelogs or profile editor
|
||||
using (ImRaii.Disabled(this.categoryManager.CurrentGroupIdx == 3 || isProfileManager))
|
||||
{
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosY(downShift);
|
||||
ImGui.SetNextItemWidth(selectableWidth);
|
||||
if (ImGui.BeginCombo(sortByText, this.filterText, ImGuiComboFlags.NoArrowButton))
|
||||
{
|
||||
foreach (var selectable in sortSelectables)
|
||||
{
|
||||
if (ImGui.Selectable(selectable.Localization))
|
||||
{
|
||||
this.sortKind = selectable.SortKind;
|
||||
this.filterText = selectable.Localization;
|
||||
|
||||
lock (this.listLock)
|
||||
this.ResortPlugins();
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.EndCombo();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawFooter()
|
||||
|
|
@ -1089,6 +1181,10 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
if (!Service<DalamudConfiguration>.Get().DoPluginTest)
|
||||
continue;
|
||||
break;
|
||||
case PluginCategoryManager.CategoryInfo.AppearCondition.ProfilesEnabled:
|
||||
if (!Service<DalamudConfiguration>.Get().ProfilesEnabled)
|
||||
continue;
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
|
|
@ -1194,6 +1290,10 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
case 1:
|
||||
this.DrawInstalledPluginList(true);
|
||||
break;
|
||||
|
||||
case 2:
|
||||
this.profileManagerWidget.Draw();
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
|
|
@ -1533,7 +1633,7 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
|
||||
ImGui.SetCursorPos(startCursor);
|
||||
|
||||
var pluginDisabled = plugin is { IsDisabled: true };
|
||||
var pluginDisabled = plugin is { IsWantedByAnyProfile: false };
|
||||
|
||||
var iconSize = ImGuiHelpers.ScaledVector2(64, 64);
|
||||
var cursorBeforeImage = ImGui.GetCursorPos();
|
||||
|
|
@ -1831,27 +1931,7 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
var buttonText = Locs.PluginButton_InstallVersion(versionString);
|
||||
if (ImGui.Button($"{buttonText}##{buttonText}{index}"))
|
||||
{
|
||||
this.installStatus = OperationStatus.InProgress;
|
||||
this.loadingIndicatorKind = LoadingIndicatorKind.Installing;
|
||||
|
||||
Task.Run(() => pluginManager.InstallPluginAsync(manifest, useTesting || manifest.IsTestingExclusive, PluginLoadReason.Installer))
|
||||
.ContinueWith(task =>
|
||||
{
|
||||
// There is no need to set as Complete for an individual plugin installation
|
||||
this.installStatus = OperationStatus.Idle;
|
||||
if (this.DisplayErrorContinuation(task, Locs.ErrorModal_InstallFail(manifest.Name)))
|
||||
{
|
||||
if (task.Result.State == PluginState.Loaded)
|
||||
{
|
||||
notifications.AddNotification(Locs.Notifications_PluginInstalled(manifest.Name), Locs.Notifications_PluginInstalledTitle, NotificationType.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
notifications.AddNotification(Locs.Notifications_PluginNotInstalled(manifest.Name), Locs.Notifications_PluginNotInstalledTitle, NotificationType.Error);
|
||||
this.ShowErrorModal(Locs.ErrorModal_InstallFail(manifest.Name));
|
||||
}
|
||||
}
|
||||
});
|
||||
this.StartInstall(manifest, useTesting);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1958,7 +2038,7 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
}
|
||||
|
||||
// Disabled
|
||||
if (plugin.IsDisabled || !plugin.CheckPolicy())
|
||||
if (!plugin.IsWantedByAnyProfile || !plugin.CheckPolicy())
|
||||
{
|
||||
label += Locs.PluginTitleMod_Disabled;
|
||||
trouble = true;
|
||||
|
|
@ -2118,7 +2198,7 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
this.DrawSendFeedbackButton(plugin.Manifest, plugin.IsTesting);
|
||||
}
|
||||
|
||||
if (availablePluginUpdate != default)
|
||||
if (availablePluginUpdate != default && !plugin.IsDev)
|
||||
this.DrawUpdateSinglePluginButton(availablePluginUpdate);
|
||||
|
||||
ImGui.SameLine();
|
||||
|
|
@ -2240,6 +2320,11 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
{
|
||||
var notifications = Service<NotificationManager>.Get();
|
||||
var pluginManager = Service<PluginManager>.Get();
|
||||
var profileManager = Service<ProfileManager>.Get();
|
||||
var config = Service<DalamudConfiguration>.Get();
|
||||
|
||||
var applicableForProfiles = plugin.Manifest.SupportsProfiles;
|
||||
var isDefaultPlugin = profileManager.IsInDefaultProfile(plugin.Manifest.InternalName);
|
||||
|
||||
// Disable everything if the updater is running or another plugin is operating
|
||||
var disabled = this.updateStatus == OperationStatus.InProgress || this.installStatus == OperationStatus.InProgress;
|
||||
|
|
@ -2255,15 +2340,70 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
// Now handled by the first case below
|
||||
// disabled = disabled || plugin.State == PluginState.LoadError || plugin.State == PluginState.DependencyResolutionFailed;
|
||||
|
||||
// Disable everything if we're working
|
||||
// Disable everything if we're loading plugins
|
||||
disabled = disabled || plugin.State == PluginState.Loading || plugin.State == PluginState.Unloading;
|
||||
|
||||
// Disable everything if we're applying profiles
|
||||
disabled = disabled || profileManager.IsBusy;
|
||||
|
||||
var toggleId = plugin.Manifest.InternalName;
|
||||
var isLoadedAndUnloadable = plugin.State == PluginState.Loaded ||
|
||||
plugin.State == PluginState.DependencyResolutionFailed;
|
||||
|
||||
StyleModelV1.DalamudStandard.Push();
|
||||
|
||||
var profileChooserPopupName = $"###pluginProfileChooser{plugin.Manifest.InternalName}";
|
||||
if (ImGui.BeginPopup(profileChooserPopupName))
|
||||
{
|
||||
var didAny = false;
|
||||
|
||||
foreach (var profile in profileManager.Profiles.Where(x => !x.IsDefaultProfile))
|
||||
{
|
||||
var inProfile = profile.WantsPlugin(plugin.Manifest.InternalName) != null;
|
||||
if (ImGui.Checkbox($"###profilePick{profile.Guid}{plugin.Manifest.InternalName}", ref inProfile))
|
||||
{
|
||||
if (inProfile)
|
||||
{
|
||||
Task.Run(() => profile.AddOrUpdate(plugin.Manifest.InternalName, true))
|
||||
.ContinueWith(this.DisplayErrorContinuation, Locs.Profiles_CouldNotAdd);
|
||||
}
|
||||
else
|
||||
{
|
||||
Task.Run(() => profile.Remove(plugin.Manifest.InternalName))
|
||||
.ContinueWith(this.DisplayErrorContinuation, Locs.Profiles_CouldNotRemove);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
ImGui.TextUnformatted(profile.Name);
|
||||
|
||||
didAny = true;
|
||||
}
|
||||
|
||||
if (!didAny)
|
||||
ImGui.TextColored(ImGuiColors.DalamudGrey, Locs.Profiles_None);
|
||||
|
||||
ImGui.Separator();
|
||||
|
||||
if (ImGuiComponents.IconButton(FontAwesomeIcon.Times))
|
||||
{
|
||||
profileManager.DefaultProfile.AddOrUpdate(plugin.Manifest.InternalName, plugin.IsLoaded, false);
|
||||
foreach (var profile in profileManager.Profiles.Where(x => !x.IsDefaultProfile && x.Plugins.Any(y => y.InternalName == plugin.Manifest.InternalName)))
|
||||
{
|
||||
profile.Remove(plugin.Manifest.InternalName, false);
|
||||
}
|
||||
|
||||
// TODO error handling
|
||||
Task.Run(() => profileManager.ApplyAllWantStates());
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGui.Text(Locs.Profiles_RemoveFromAll);
|
||||
|
||||
ImGui.EndPopup();
|
||||
}
|
||||
|
||||
if (plugin.State is PluginState.UnloadError or PluginState.LoadError or PluginState.DependencyResolutionFailed && !plugin.IsDev)
|
||||
{
|
||||
ImGuiComponents.DisabledButton(FontAwesomeIcon.Frown);
|
||||
|
|
@ -2271,14 +2411,18 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip(Locs.PluginButtonToolTip_UnloadFailed);
|
||||
}
|
||||
else if (disabled)
|
||||
else if (disabled || !isDefaultPlugin)
|
||||
{
|
||||
ImGuiComponents.DisabledToggleButton(toggleId, isLoadedAndUnloadable);
|
||||
|
||||
if (!isDefaultPlugin && ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip(Locs.PluginButtonToolTip_NeedsToBeInDefault);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ImGuiComponents.ToggleButton(toggleId, ref isLoadedAndUnloadable))
|
||||
{
|
||||
// TODO: We can technically let profile manager take care of unloading/loading the plugin, but we should figure out error handling first.
|
||||
if (!isLoadedAndUnloadable)
|
||||
{
|
||||
this.enableDisableStatus = OperationStatus.InProgress;
|
||||
|
|
@ -2301,15 +2445,9 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
var disableTask = Task.Run(() => plugin.Disable())
|
||||
.ContinueWith(this.DisplayErrorContinuation, Locs.ErrorModal_DisableFail(plugin.Name));
|
||||
|
||||
disableTask.Wait();
|
||||
profileManager.DefaultProfile.AddOrUpdate(plugin.Manifest.InternalName, false, false);
|
||||
this.enableDisableStatus = OperationStatus.Complete;
|
||||
|
||||
if (!disableTask.Result)
|
||||
return;
|
||||
|
||||
notifications.AddNotification(Locs.Notifications_PluginDisabled(plugin.Manifest.Name), Locs.Notifications_PluginDisabledTitle, NotificationType.Success);
|
||||
});
|
||||
}
|
||||
|
|
@ -2325,17 +2463,7 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
plugin.ReloadManifest();
|
||||
}
|
||||
|
||||
var enableTask = Task.Run(plugin.Enable)
|
||||
.ContinueWith(
|
||||
this.DisplayErrorContinuation,
|
||||
Locs.ErrorModal_EnableFail(plugin.Name));
|
||||
|
||||
enableTask.Wait();
|
||||
if (!enableTask.Result)
|
||||
{
|
||||
this.enableDisableStatus = OperationStatus.Complete;
|
||||
return;
|
||||
}
|
||||
profileManager.DefaultProfile.AddOrUpdate(plugin.Manifest.InternalName, true, false);
|
||||
|
||||
var loadTask = Task.Run(() => plugin.LoadAsync(PluginLoadReason.Installer))
|
||||
.ContinueWith(
|
||||
|
|
@ -2388,6 +2516,29 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
// Only if the plugin isn't broken.
|
||||
this.DrawOpenPluginSettingsButton(plugin);
|
||||
}
|
||||
|
||||
if (applicableForProfiles && config.ProfilesEnabled)
|
||||
{
|
||||
ImGui.SameLine();
|
||||
if (ImGuiComponents.IconButton(FontAwesomeIcon.Toolbox))
|
||||
{
|
||||
ImGui.OpenPopup(profileChooserPopupName);
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip(Locs.PluginButtonToolTip_PickProfiles);
|
||||
}
|
||||
else if (!applicableForProfiles && config.ProfilesEnabled)
|
||||
{
|
||||
ImGui.SameLine();
|
||||
|
||||
ImGui.BeginDisabled();
|
||||
ImGuiComponents.IconButton(FontAwesomeIcon.Toolbox);
|
||||
ImGui.EndDisabled();
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip(Locs.PluginButtonToolTip_ProfilesNotSupported);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> UpdateSinglePlugin(AvailablePluginUpdate update)
|
||||
|
|
@ -2474,26 +2625,32 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
|
||||
if (localPlugin is LocalDevPlugin plugin)
|
||||
{
|
||||
var isInDefaultProfile =
|
||||
Service<ProfileManager>.Get().IsInDefaultProfile(localPlugin.Manifest.InternalName);
|
||||
|
||||
// https://colorswall.com/palette/2868/
|
||||
var greenColor = new Vector4(0x5C, 0xB8, 0x5C, 0xFF) / 0xFF;
|
||||
var redColor = new Vector4(0xD9, 0x53, 0x4F, 0xFF) / 0xFF;
|
||||
|
||||
// Load on boot
|
||||
ImGui.PushStyleColor(ImGuiCol.Button, plugin.StartOnBoot ? greenColor : redColor);
|
||||
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, plugin.StartOnBoot ? greenColor : redColor);
|
||||
|
||||
ImGui.SameLine();
|
||||
if (ImGuiComponents.IconButton(FontAwesomeIcon.PowerOff))
|
||||
using (ImRaii.Disabled(!isInDefaultProfile))
|
||||
{
|
||||
plugin.StartOnBoot ^= true;
|
||||
configuration.QueueSave();
|
||||
}
|
||||
ImGui.PushStyleColor(ImGuiCol.Button, plugin.StartOnBoot ? greenColor : redColor);
|
||||
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, plugin.StartOnBoot ? greenColor : redColor);
|
||||
|
||||
ImGui.PopStyleColor(2);
|
||||
ImGui.SameLine();
|
||||
if (ImGuiComponents.IconButton(FontAwesomeIcon.PowerOff))
|
||||
{
|
||||
plugin.StartOnBoot ^= true;
|
||||
configuration.QueueSave();
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.SetTooltip(Locs.PluginButtonToolTip_StartOnBoot);
|
||||
ImGui.PopStyleColor(2);
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.SetTooltip(isInDefaultProfile ? Locs.PluginButtonToolTip_StartOnBoot : Locs.PluginButtonToolTip_NeedsToBeInDefault);
|
||||
}
|
||||
}
|
||||
|
||||
// Automatic reload
|
||||
|
|
@ -2800,46 +2957,6 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
private bool WasPluginSeen(string internalName) =>
|
||||
Service<DalamudConfiguration>.Get().SeenPluginInternalName.Contains(internalName);
|
||||
|
||||
/// <summary>
|
||||
/// A continuation task that displays any errors received into the error modal.
|
||||
/// </summary>
|
||||
/// <param name="task">The previous task.</param>
|
||||
/// <param name="state">An error message to be displayed.</param>
|
||||
/// <returns>A value indicating whether to continue with the next task.</returns>
|
||||
private bool DisplayErrorContinuation(Task task, object state)
|
||||
{
|
||||
if (task.IsFaulted)
|
||||
{
|
||||
var errorModalMessage = state as string;
|
||||
|
||||
foreach (var ex in task.Exception.InnerExceptions)
|
||||
{
|
||||
if (ex is PluginException)
|
||||
{
|
||||
Log.Error(ex, "Plugin installer threw an error");
|
||||
#if DEBUG
|
||||
if (!string.IsNullOrEmpty(ex.Message))
|
||||
errorModalMessage += $"\n\n{ex.Message}";
|
||||
#endif
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Error(ex, "Plugin installer threw an unexpected error");
|
||||
#if DEBUG
|
||||
if (!string.IsNullOrEmpty(ex.Message))
|
||||
errorModalMessage += $"\n\n{ex.Message}";
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
this.ShowErrorModal(errorModalMessage);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private Task ShowErrorModal(string message)
|
||||
{
|
||||
this.errorModalMessage = message;
|
||||
|
|
@ -2890,7 +3007,7 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
|
||||
#region Header
|
||||
|
||||
public static string Header_Hint => Loc.Localize("InstallerHint", "This window allows you to install and remove in-game plugins.\nThey are made by third-party developers.");
|
||||
public static string Header_Hint => Loc.Localize("InstallerHint", "This window allows you to install and remove Dalamud plugins.\nThey are made by the community.");
|
||||
|
||||
public static string Header_SearchPlaceholder => Loc.Localize("InstallerSearch", "Search");
|
||||
|
||||
|
|
@ -3047,6 +3164,10 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
|
||||
public static string PluginButtonToolTip_OpenConfiguration => Loc.Localize("InstallerOpenConfig", "Open Configuration");
|
||||
|
||||
public static string PluginButtonToolTip_PickProfiles => Loc.Localize("InstallerPickProfiles", "Pick collections for this plugin");
|
||||
|
||||
public static string PluginButtonToolTip_ProfilesNotSupported => Loc.Localize("InstallerProfilesNotSupported", "This plugin does not support collections");
|
||||
|
||||
public static string PluginButtonToolTip_StartOnBoot => Loc.Localize("InstallerStartOnBoot", "Start on boot");
|
||||
|
||||
public static string PluginButtonToolTip_AutomaticReloading => Loc.Localize("InstallerAutomaticReloading", "Automatic reloading");
|
||||
|
|
@ -3067,6 +3188,8 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
|
||||
public static string PluginButtonToolTip_UnloadFailed => Loc.Localize("InstallerLoadUnloadFailedTooltip", "Plugin load/unload failed, please restart your game and try again.");
|
||||
|
||||
public static string PluginButtonToolTip_NeedsToBeInDefault => Loc.Localize("InstallerUnloadNeedsToBeInDefault", "This plugin is in one or more collections. If you want to enable or disable it, please do so by enabling or disabling the collections it is in.\nIf you want to manage it manually, remove it from all collections.");
|
||||
|
||||
#endregion
|
||||
|
||||
#region Notifications
|
||||
|
|
@ -3226,5 +3349,20 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
public static string SafeModeDisclaimer => Loc.Localize("SafeModeDisclaimer", "You enabled safe mode, no plugins will be loaded.\nYou may delete plugins from the \"Installed plugins\" tab.\nSimply restart your game to disable safe mode.");
|
||||
|
||||
#endregion
|
||||
|
||||
#region Profiles
|
||||
|
||||
public static string Profiles_CouldNotAdd =>
|
||||
Loc.Localize("InstallerProfilesCouldNotAdd", "Couldn't add plugin to this collection.");
|
||||
|
||||
public static string Profiles_CouldNotRemove =>
|
||||
Loc.Localize("InstallerProfilesCouldNotRemove", "Couldn't remove plugin from this collection.");
|
||||
|
||||
public static string Profiles_None => Loc.Localize("InstallerProfilesNone", "No collections! Go add some in \"Plugin Collections\"!");
|
||||
|
||||
public static string Profiles_RemoveFromAll =>
|
||||
Loc.Localize("InstallerProfilesRemoveFromAll", "Remove from all collections");
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,506 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using CheapLoc;
|
||||
using Dalamud.Interface.Colors;
|
||||
using Dalamud.Interface.Components;
|
||||
using Dalamud.Interface.Internal.Notifications;
|
||||
using Dalamud.Interface.Raii;
|
||||
using Dalamud.Plugin.Internal;
|
||||
using Dalamud.Plugin.Internal.Profiles;
|
||||
using Dalamud.Utility;
|
||||
using ImGuiNET;
|
||||
using Serilog;
|
||||
|
||||
namespace Dalamud.Interface.Internal.Windows.PluginInstaller;
|
||||
|
||||
/// <summary>
|
||||
/// ImGui widget used to manage profiles.
|
||||
/// </summary>
|
||||
internal class ProfileManagerWidget
|
||||
{
|
||||
private readonly PluginInstallerWindow installer;
|
||||
private Mode mode = Mode.Overview;
|
||||
private Guid? editingProfileGuid;
|
||||
|
||||
private string pickerSearch = string.Empty;
|
||||
private string profileNameEdit = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProfileManagerWidget"/> class.
|
||||
/// </summary>
|
||||
/// <param name="installer">The plugin installer.</param>
|
||||
public ProfileManagerWidget(PluginInstallerWindow installer)
|
||||
{
|
||||
this.installer = installer;
|
||||
}
|
||||
|
||||
private enum Mode
|
||||
{
|
||||
Overview,
|
||||
EditSingleProfile,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw this widget's contents.
|
||||
/// </summary>
|
||||
public void Draw()
|
||||
{
|
||||
switch (this.mode)
|
||||
{
|
||||
case Mode.Overview:
|
||||
this.DrawOverview();
|
||||
break;
|
||||
|
||||
case Mode.EditSingleProfile:
|
||||
this.DrawEdit();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset the widget.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
this.mode = Mode.Overview;
|
||||
this.editingProfileGuid = null;
|
||||
this.pickerSearch = string.Empty;
|
||||
}
|
||||
|
||||
private void DrawOverview()
|
||||
{
|
||||
var didAny = false;
|
||||
var profman = Service<ProfileManager>.Get();
|
||||
|
||||
if (ImGuiComponents.IconButton(FontAwesomeIcon.Plus))
|
||||
profman.AddNewProfile();
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip(Locs.AddProfile);
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
ImGui.SameLine();
|
||||
|
||||
if (ImGuiComponents.IconButton(FontAwesomeIcon.FileImport))
|
||||
{
|
||||
try
|
||||
{
|
||||
profman.ImportProfile(ImGui.GetClipboardText());
|
||||
Service<NotificationManager>.Get().AddNotification(Locs.NotificationImportSuccess, type: NotificationType.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Could not import profile");
|
||||
Service<NotificationManager>.Get().AddNotification(Locs.NotificationImportError, type: NotificationType.Error);
|
||||
}
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip(Locs.ImportProfileHint);
|
||||
|
||||
ImGui.Separator();
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
|
||||
var windowSize = ImGui.GetWindowSize();
|
||||
|
||||
if (ImGui.BeginChild("###profileChooserScrolling"))
|
||||
{
|
||||
Guid? toCloneGuid = null;
|
||||
|
||||
foreach (var profile in profman.Profiles)
|
||||
{
|
||||
if (profile.IsDefaultProfile)
|
||||
continue;
|
||||
|
||||
var isEnabled = profile.IsEnabled;
|
||||
if (ImGuiComponents.ToggleButton($"###toggleButton{profile.Guid}", ref isEnabled))
|
||||
{
|
||||
Task.Run(() => profile.SetState(isEnabled))
|
||||
.ContinueWith(this.installer.DisplayErrorContinuation, Locs.ErrorCouldNotChangeState);
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGuiHelpers.ScaledDummy(3);
|
||||
ImGui.SameLine();
|
||||
|
||||
ImGui.Text(profile.Name);
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 30));
|
||||
|
||||
if (ImGuiComponents.IconButton($"###editButton{profile.Guid}", FontAwesomeIcon.PencilAlt))
|
||||
{
|
||||
this.mode = Mode.EditSingleProfile;
|
||||
this.editingProfileGuid = profile.Guid;
|
||||
this.profileNameEdit = profile.Name;
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip(Locs.EditProfileHint);
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 30 * 2) - 5);
|
||||
|
||||
if (ImGuiComponents.IconButton($"###cloneButton{profile.Guid}", FontAwesomeIcon.Copy))
|
||||
toCloneGuid = profile.Guid;
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip(Locs.CloneProfileHint);
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 30 * 3) - 5);
|
||||
|
||||
if (ImGuiComponents.IconButton($"###exportButton{profile.Guid}", FontAwesomeIcon.FileExport))
|
||||
{
|
||||
ImGui.SetClipboardText(profile.Model.Serialize());
|
||||
Service<NotificationManager>.Get().AddNotification(Locs.CopyToClipboardNotification, type: NotificationType.Success);
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip(Locs.CopyToClipboardHint);
|
||||
|
||||
didAny = true;
|
||||
|
||||
ImGuiHelpers.ScaledDummy(2);
|
||||
}
|
||||
|
||||
if (toCloneGuid != null)
|
||||
{
|
||||
profman.CloneProfile(profman.Profiles.First(x => x.Guid == toCloneGuid));
|
||||
}
|
||||
|
||||
if (!didAny)
|
||||
{
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey);
|
||||
ImGuiHelpers.CenteredText(Locs.AddProfileHint);
|
||||
ImGui.PopStyleColor();
|
||||
}
|
||||
|
||||
ImGui.EndChild();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawEdit()
|
||||
{
|
||||
if (this.editingProfileGuid == null)
|
||||
{
|
||||
Log.Error("Editing profile guid was null");
|
||||
this.Reset();
|
||||
return;
|
||||
}
|
||||
|
||||
var profman = Service<ProfileManager>.Get();
|
||||
var pm = Service<PluginManager>.Get();
|
||||
var pic = Service<PluginImageCache>.Get();
|
||||
var profile = profman.Profiles.FirstOrDefault(x => x.Guid == this.editingProfileGuid);
|
||||
|
||||
if (profile == null)
|
||||
{
|
||||
Log.Error("Could not find profile {Guid} for edit", this.editingProfileGuid);
|
||||
this.Reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const string addPluginToProfilePopup = "###addPluginToProfile";
|
||||
using (var popup = ImRaii.Popup(addPluginToProfilePopup))
|
||||
{
|
||||
if (popup.Success)
|
||||
{
|
||||
var width = ImGuiHelpers.GlobalScale * 300;
|
||||
|
||||
using var disabled = ImRaii.Disabled(profman.IsBusy);
|
||||
|
||||
ImGui.SetNextItemWidth(width);
|
||||
ImGui.InputTextWithHint("###pluginPickerSearch", Locs.SearchHint, ref this.pickerSearch, 255);
|
||||
|
||||
if (ImGui.BeginListBox("###pluginPicker", new Vector2(width, width - 80)))
|
||||
{
|
||||
// TODO: Plugin searching should be abstracted... installer and this should use the same search
|
||||
foreach (var plugin in pm.InstalledPlugins.Where(x => x.Manifest.SupportsProfiles &&
|
||||
(this.pickerSearch.IsNullOrWhitespace() || x.Manifest.Name.ToLowerInvariant().Contains(this.pickerSearch.ToLowerInvariant()))))
|
||||
{
|
||||
using var disabled2 =
|
||||
ImRaii.Disabled(profile.Plugins.Any(y => y.InternalName == plugin.Manifest.InternalName));
|
||||
|
||||
if (ImGui.Selectable($"{plugin.Manifest.Name}###selector{plugin.Manifest.InternalName}"))
|
||||
{
|
||||
// TODO this sucks
|
||||
profile.AddOrUpdate(plugin.Manifest.InternalName, true, false);
|
||||
Task.Run(() => profman.ApplyAllWantStates())
|
||||
.ContinueWith(this.installer.DisplayErrorContinuation, Locs.ErrorCouldNotChangeState);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.EndListBox();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var didAny = false;
|
||||
|
||||
// ======== Top bar ========
|
||||
var windowSize = ImGui.GetWindowSize();
|
||||
|
||||
if (ImGuiComponents.IconButton(FontAwesomeIcon.ArrowLeft))
|
||||
this.Reset();
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip(Locs.BackToOverview);
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
ImGui.SameLine();
|
||||
|
||||
if (ImGuiComponents.IconButton(FontAwesomeIcon.FileExport))
|
||||
{
|
||||
ImGui.SetClipboardText(profile.Model.Serialize());
|
||||
Service<NotificationManager>.Get().AddNotification(Locs.CopyToClipboardNotification, type: NotificationType.Success);
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip(Locs.CopyToClipboardHint);
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
ImGui.SameLine();
|
||||
|
||||
if (ImGuiComponents.IconButton(FontAwesomeIcon.Trash))
|
||||
{
|
||||
this.Reset();
|
||||
|
||||
// DeleteProfile() is sync, it doesn't apply and we are modifying the plugins collection. Will throw below when iterating
|
||||
profman.DeleteProfile(profile);
|
||||
Task.Run(() => profman.ApplyAllWantStates())
|
||||
.ContinueWith(t =>
|
||||
{
|
||||
this.installer.DisplayErrorContinuation(t, Locs.ErrorCouldNotChangeState);
|
||||
});
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip(Locs.DeleteProfileHint);
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
ImGui.SameLine();
|
||||
|
||||
ImGui.SetNextItemWidth(windowSize.X / 3);
|
||||
if (ImGui.InputText("###profileNameInput", ref this.profileNameEdit, 255))
|
||||
{
|
||||
profile.Name = this.profileNameEdit;
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosX(windowSize.X - (ImGui.GetFrameHeight() * 1.55f * ImGuiHelpers.GlobalScale));
|
||||
|
||||
var isEnabled = profile.IsEnabled;
|
||||
if (ImGuiComponents.ToggleButton($"###toggleButton{profile.Guid}", ref isEnabled))
|
||||
{
|
||||
Task.Run(() => profile.SetState(isEnabled))
|
||||
.ContinueWith(this.installer.DisplayErrorContinuation, Locs.ErrorCouldNotChangeState);
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip(Locs.TooltipEnableDisable);
|
||||
|
||||
ImGui.Separator();
|
||||
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
|
||||
var enableAtBoot = profile.AlwaysEnableAtBoot;
|
||||
if (ImGui.Checkbox(Locs.AlwaysEnableAtBoot, ref enableAtBoot))
|
||||
{
|
||||
profile.AlwaysEnableAtBoot = enableAtBoot;
|
||||
}
|
||||
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
|
||||
ImGui.Separator();
|
||||
var wantPluginAddPopup = false;
|
||||
|
||||
if (ImGui.BeginChild("###profileEditorPluginList"))
|
||||
{
|
||||
var pluginLineHeight = 32 * ImGuiHelpers.GlobalScale;
|
||||
string? wantRemovePluginInternalName = null;
|
||||
|
||||
foreach (var plugin in profile.Plugins)
|
||||
{
|
||||
didAny = true;
|
||||
var pmPlugin = pm.InstalledPlugins.FirstOrDefault(x => x.Manifest.InternalName == plugin.InternalName);
|
||||
var btnOffset = 2;
|
||||
|
||||
if (pmPlugin != null)
|
||||
{
|
||||
pic.TryGetIcon(pmPlugin, pmPlugin.Manifest, pmPlugin.Manifest.IsThirdParty, out var icon);
|
||||
icon ??= pic.DefaultIcon;
|
||||
|
||||
ImGui.Image(icon.ImGuiHandle, new Vector2(pluginLineHeight));
|
||||
ImGui.SameLine();
|
||||
|
||||
var text = $"{pmPlugin.Name}";
|
||||
var textHeight = ImGui.CalcTextSize(text);
|
||||
var before = ImGui.GetCursorPos();
|
||||
|
||||
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (textHeight.Y / 2));
|
||||
ImGui.TextUnformatted(text);
|
||||
|
||||
ImGui.SetCursorPos(before);
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.Image(pic.DefaultIcon.ImGuiHandle, new Vector2(pluginLineHeight));
|
||||
ImGui.SameLine();
|
||||
|
||||
var text = Locs.NotInstalled(plugin.InternalName);
|
||||
var textHeight = ImGui.CalcTextSize(text);
|
||||
var before = ImGui.GetCursorPos();
|
||||
|
||||
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (textHeight.Y / 2));
|
||||
ImGui.TextUnformatted(text);
|
||||
|
||||
var available =
|
||||
pm.AvailablePlugins.FirstOrDefault(
|
||||
x => x.InternalName == plugin.InternalName && !x.SourceRepo.IsThirdParty);
|
||||
if (available != null)
|
||||
{
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 30 * 2) - 2);
|
||||
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (ImGui.GetFrameHeight() / 2));
|
||||
btnOffset = 3;
|
||||
|
||||
if (ImGuiComponents.IconButton($"###installMissingPlugin{available.InternalName}", FontAwesomeIcon.Download))
|
||||
{
|
||||
this.installer.StartInstall(available, false);
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip(Locs.InstallPlugin);
|
||||
}
|
||||
|
||||
ImGui.SetCursorPos(before);
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 30));
|
||||
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (ImGui.GetFrameHeight() / 2));
|
||||
|
||||
var enabled = plugin.IsEnabled;
|
||||
if (ImGui.Checkbox($"###{this.editingProfileGuid}-{plugin.InternalName}", ref enabled))
|
||||
{
|
||||
Task.Run(() => profile.AddOrUpdate(plugin.InternalName, enabled))
|
||||
.ContinueWith(this.installer.DisplayErrorContinuation, Locs.ErrorCouldNotChangeState);
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 30 * btnOffset) - 5);
|
||||
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (ImGui.GetFrameHeight() / 2));
|
||||
|
||||
if (ImGuiComponents.IconButton($"###removePlugin{plugin.InternalName}", FontAwesomeIcon.Trash))
|
||||
{
|
||||
wantRemovePluginInternalName = plugin.InternalName;
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
ImGui.SetTooltip(Locs.RemovePlugin);
|
||||
}
|
||||
|
||||
if (wantRemovePluginInternalName != null)
|
||||
{
|
||||
// TODO: handle error
|
||||
profile.Remove(wantRemovePluginInternalName, false);
|
||||
Task.Run(() => profman.ApplyAllWantStates())
|
||||
.ContinueWith(this.installer.DisplayErrorContinuation, Locs.ErrorCouldNotRemove);
|
||||
}
|
||||
|
||||
if (!didAny)
|
||||
{
|
||||
ImGui.TextColored(ImGuiColors.DalamudGrey, Locs.NoPluginsInProfile);
|
||||
}
|
||||
|
||||
ImGuiHelpers.ScaledDummy(10);
|
||||
|
||||
var addPluginsText = Locs.AddPlugin;
|
||||
ImGuiHelpers.CenterCursorFor((int)(ImGui.CalcTextSize(addPluginsText).X + 30 + (ImGuiHelpers.GlobalScale * 5)));
|
||||
|
||||
if (ImGuiComponents.IconButton(FontAwesomeIcon.Plus))
|
||||
wantPluginAddPopup = true;
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
ImGui.SameLine();
|
||||
|
||||
ImGui.TextUnformatted(addPluginsText);
|
||||
|
||||
ImGuiHelpers.ScaledDummy(10);
|
||||
|
||||
ImGui.EndChild();
|
||||
}
|
||||
|
||||
if (wantPluginAddPopup)
|
||||
{
|
||||
this.pickerSearch = string.Empty;
|
||||
ImGui.OpenPopup(addPluginToProfilePopup);
|
||||
}
|
||||
}
|
||||
|
||||
private static class Locs
|
||||
{
|
||||
public static string TooltipEnableDisable =>
|
||||
Loc.Localize("ProfileManagerEnableDisableHint", "Enable/Disable this collection");
|
||||
|
||||
public static string InstallPlugin => Loc.Localize("ProfileManagerInstall", "Install this plugin");
|
||||
|
||||
public static string RemovePlugin =>
|
||||
Loc.Localize("ProfileManagerRemoveFromProfile", "Remove plugin from this collection");
|
||||
|
||||
public static string AddPlugin => Loc.Localize("ProfileManagerAddPlugin", "Add a plugin!");
|
||||
|
||||
public static string NoPluginsInProfile =>
|
||||
Loc.Localize("ProfileManagerNoPluginsInProfile", "Collection has no plugins!");
|
||||
|
||||
public static string AlwaysEnableAtBoot =>
|
||||
Loc.Localize("ProfileManagerAlwaysEnableAtBoot", "Always enable when game starts");
|
||||
|
||||
public static string DeleteProfileHint => Loc.Localize("ProfileManagerDeleteProfile", "Delete this collection");
|
||||
|
||||
public static string CopyToClipboardHint =>
|
||||
Loc.Localize("ProfileManagerCopyToClipboard", "Copy collection to clipboard for sharing");
|
||||
|
||||
public static string CopyToClipboardNotification =>
|
||||
Loc.Localize("ProfileManagerCopyToClipboardHint", "Copied to clipboard!");
|
||||
|
||||
public static string BackToOverview => Loc.Localize("ProfileManagerBackToOverview", "Back to overview");
|
||||
|
||||
public static string SearchHint => Loc.Localize("ProfileManagerSearchHint", "Search...");
|
||||
|
||||
public static string AddProfileHint => Loc.Localize("ProfileManagerAddProfileHint", "No collections! Add one!");
|
||||
|
||||
public static string CloneProfileHint => Loc.Localize("ProfileManagerCloneProfile", "Clone this collection");
|
||||
|
||||
public static string EditProfileHint => Loc.Localize("ProfileManagerEditProfile", "Edit this collection");
|
||||
|
||||
public static string ImportProfileHint =>
|
||||
Loc.Localize("ProfileManagerImportProfile", "Import a shared collection from your clipboard");
|
||||
|
||||
public static string AddProfile => Loc.Localize("ProfileManagerAddProfile", "Add a new collection");
|
||||
|
||||
public static string NotificationImportSuccess =>
|
||||
Loc.Localize("ProfileManagerNotificationImportSuccess", "Collection successfully imported!");
|
||||
|
||||
public static string NotificationImportError =>
|
||||
Loc.Localize("ProfileManagerNotificationImportError", "Could not import collection.");
|
||||
|
||||
public static string ErrorCouldNotRemove =>
|
||||
Loc.Localize("ProfileManagerCouldNotRemove", "Could not remove plugin.");
|
||||
|
||||
public static string ErrorCouldNotChangeState =>
|
||||
Loc.Localize("ProfileManagerCouldNotChangeState", "Could not change plugin state.");
|
||||
|
||||
public static string NotInstalled(string name) =>
|
||||
Loc.Localize("ProfileManagerNotInstalled", "{0} (Not Installed)").Format(name);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,8 @@ namespace Dalamud.Interface.Internal.Windows;
|
|||
internal class PluginStatWindow : Window
|
||||
{
|
||||
private bool showDalamudHooks;
|
||||
private string drawSearchText = string.Empty;
|
||||
private string frameworkSearchText = string.Empty;
|
||||
private string hookSearchText = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -79,6 +81,12 @@ internal class PluginStatWindow : Window
|
|||
ImGui.SameLine();
|
||||
ImGuiComponents.TextWithLabel("Collective Average", $"{(loadedPlugins.Any() ? totalAverage / loadedPlugins.Count() / 10000f : 0):F4}ms", "Average of all average draw times");
|
||||
|
||||
ImGui.InputTextWithHint(
|
||||
"###PluginStatWindow_DrawSearch",
|
||||
"Search",
|
||||
ref this.drawSearchText,
|
||||
500);
|
||||
|
||||
if (ImGui.BeginTable(
|
||||
"##PluginStatsDrawTimes",
|
||||
4,
|
||||
|
|
@ -104,16 +112,22 @@ internal class PluginStatWindow : Window
|
|||
? loadedPlugins.OrderBy(plugin => plugin.Name)
|
||||
: loadedPlugins.OrderByDescending(plugin => plugin.Name),
|
||||
2 => sortSpecs.Specs.SortDirection == ImGuiSortDirection.Ascending
|
||||
? loadedPlugins.OrderBy(plugin => plugin.DalamudInterface?.UiBuilder.MaxDrawTime)
|
||||
: loadedPlugins.OrderByDescending(plugin => plugin.DalamudInterface?.UiBuilder.MaxDrawTime),
|
||||
? loadedPlugins.OrderBy(plugin => plugin.DalamudInterface?.UiBuilder.MaxDrawTime ?? 0)
|
||||
: loadedPlugins.OrderByDescending(plugin => plugin.DalamudInterface?.UiBuilder.MaxDrawTime ?? 0),
|
||||
3 => sortSpecs.Specs.SortDirection == ImGuiSortDirection.Ascending
|
||||
? loadedPlugins.OrderBy(plugin => plugin.DalamudInterface?.UiBuilder.DrawTimeHistory.Average())
|
||||
: loadedPlugins.OrderByDescending(plugin => plugin.DalamudInterface?.UiBuilder.DrawTimeHistory.Average()),
|
||||
? loadedPlugins.OrderBy(plugin => plugin.DalamudInterface?.UiBuilder.DrawTimeHistory.DefaultIfEmpty().Average() ?? 0)
|
||||
: loadedPlugins.OrderByDescending(plugin => plugin.DalamudInterface?.UiBuilder.DrawTimeHistory.DefaultIfEmpty().Average() ?? 0),
|
||||
_ => loadedPlugins,
|
||||
};
|
||||
|
||||
foreach (var plugin in loadedPlugins)
|
||||
{
|
||||
if (!this.drawSearchText.IsNullOrEmpty()
|
||||
&& !plugin.Manifest.Name.Contains(this.drawSearchText, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ImGui.TableNextRow();
|
||||
|
||||
ImGui.TableNextColumn();
|
||||
|
|
@ -168,6 +182,12 @@ internal class PluginStatWindow : Window
|
|||
ImGui.SameLine();
|
||||
ImGuiComponents.TextWithLabel("Collective Average", $"{(statsHistory.Any() ? totalAverage / statsHistory.Length : 0):F4}ms", "Average of all average update times");
|
||||
|
||||
ImGui.InputTextWithHint(
|
||||
"###PluginStatWindow_FrameworkSearch",
|
||||
"Search",
|
||||
ref this.frameworkSearchText,
|
||||
500);
|
||||
|
||||
if (ImGui.BeginTable(
|
||||
"##PluginStatsFrameworkTimes",
|
||||
4,
|
||||
|
|
@ -208,6 +228,13 @@ internal class PluginStatWindow : Window
|
|||
continue;
|
||||
}
|
||||
|
||||
if (!this.frameworkSearchText.IsNullOrEmpty()
|
||||
&& handlerHistory.Key != null
|
||||
&& !handlerHistory.Key.Contains(this.frameworkSearchText, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ImGui.TableNextRow();
|
||||
|
||||
ImGui.TableNextColumn();
|
||||
|
|
|
|||
|
|
@ -46,6 +46,14 @@ public class SettingsTabExperimental : SettingsTab
|
|||
new GapSettingsEntry(5, true),
|
||||
|
||||
new ThirdRepoSettingsEntry(),
|
||||
|
||||
new GapSettingsEntry(5, true),
|
||||
|
||||
new SettingsEntry<bool>(
|
||||
Loc.Localize("DalamudSettingsEnableProfiles", "Enable plugin collections"),
|
||||
Loc.Localize("DalamudSettingsEnableProfilesHint", "Enables plugin collections, which lets you create toggleable lists of plugins."),
|
||||
c => c.ProfilesEnabled,
|
||||
(v, c) => c.ProfilesEnabled = v),
|
||||
};
|
||||
|
||||
public override string Title => Loc.Localize("DalamudSettingsExperimental", "Experimental");
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ public class DevPluginsSettingsEntry : SettingsEntry
|
|||
}
|
||||
}
|
||||
|
||||
ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudGrey, Loc.Localize("DalamudSettingsDevPluginLocationsHint", "Add additional dev plugin load locations.\nThese can be either the directory or DLL path."));
|
||||
ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudGrey, Loc.Localize("DalamudSettingsDevPluginLocationsHint", "Add dev plugin load locations.\nThese can be either the directory or DLL path."));
|
||||
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
|
||||
|
|
|
|||
|
|
@ -23,8 +23,16 @@ internal sealed class SettingsEntry<T> : SettingsEntry
|
|||
private object? valueBacking;
|
||||
private object? fallbackValue;
|
||||
|
||||
public SettingsEntry(string name, string description, LoadSettingDelegate load, SaveSettingDelegate save, Action<T?>? change = null, Func<T?, string?>? warning = null, Func<T?, string?>? validity = null, Func<bool>? visibility = null,
|
||||
object? fallbackValue = null)
|
||||
public SettingsEntry(
|
||||
string name,
|
||||
string description,
|
||||
LoadSettingDelegate load,
|
||||
SaveSettingDelegate save,
|
||||
Action<T?>? change = null,
|
||||
Func<T?, string?>? warning = null,
|
||||
Func<T?, string?>? validity = null,
|
||||
Func<bool>? visibility = null,
|
||||
object? fallbackValue = null)
|
||||
{
|
||||
this.load = load;
|
||||
this.save = save;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ using Dalamud.Interface.Colors;
|
|||
using Dalamud.Interface.Components;
|
||||
using Dalamud.Interface.Style;
|
||||
using Dalamud.Interface.Windowing;
|
||||
using Dalamud.Utility;
|
||||
using ImGuiNET;
|
||||
using Lumina.Excel.GeneratedSheets;
|
||||
using Serilog;
|
||||
|
|
@ -97,7 +98,7 @@ public class StyleEditorWindow : Window
|
|||
this.SaveStyle();
|
||||
|
||||
var newStyle = StyleModelV1.DalamudStandard;
|
||||
newStyle.Name = GetRandomName();
|
||||
newStyle.Name = Util.GetRandomName();
|
||||
config.SavedStyles.Add(newStyle);
|
||||
|
||||
this.currentSel = config.SavedStyles.Count - 1;
|
||||
|
|
@ -167,11 +168,11 @@ public class StyleEditorWindow : Window
|
|||
{
|
||||
var newStyle = StyleModel.Deserialize(styleJson);
|
||||
|
||||
newStyle.Name ??= GetRandomName();
|
||||
newStyle.Name ??= Util.GetRandomName();
|
||||
|
||||
if (config.SavedStyles.Any(x => x.Name == newStyle.Name))
|
||||
{
|
||||
newStyle.Name = $"{newStyle.Name} ({GetRandomName()} Mix)";
|
||||
newStyle.Name = $"{newStyle.Name} ({Util.GetRandomName()} Mix)";
|
||||
}
|
||||
|
||||
config.SavedStyles.Add(newStyle);
|
||||
|
|
@ -375,15 +376,6 @@ public class StyleEditorWindow : Window
|
|||
}
|
||||
}
|
||||
|
||||
private static string GetRandomName()
|
||||
{
|
||||
var data = Service<DataManager>.Get();
|
||||
var names = data.GetExcelSheet<BNpcName>(ClientLanguage.English);
|
||||
var rng = new Random();
|
||||
|
||||
return names.ElementAt(rng.Next(0, names.Count() - 1)).Singular.RawString;
|
||||
}
|
||||
|
||||
private void SaveStyle()
|
||||
{
|
||||
if (this.currentSel < 2)
|
||||
|
|
|
|||
|
|
@ -254,7 +254,6 @@ internal class TitleScreenMenuWindow : Window, IDisposable
|
|||
|
||||
var initialCursor = ImGui.GetCursorPos();
|
||||
|
||||
|
||||
using (ImRaii.PushStyle(ImGuiStyleVar.Alpha, (float)shadeEasing.Value))
|
||||
{
|
||||
ImGui.Image(this.shadeTexture.ImGuiHandle, new Vector2(this.shadeTexture.Width * scale, this.shadeTexture.Height * scale));
|
||||
|
|
|
|||
|
|
@ -33,12 +33,12 @@ public sealed class UiBuilder : IDisposable
|
|||
private readonly GameFontManager gameFontManager = Service<GameFontManager>.Get();
|
||||
private readonly DragDropManager dragDropManager = Service<DragDropManager>.Get();
|
||||
|
||||
private bool hasErrorWindow = false;
|
||||
private bool lastFrameUiHideState = false;
|
||||
|
||||
[ServiceManager.ServiceDependency]
|
||||
private readonly DalamudConfiguration configuration = Service<DalamudConfiguration>.Get();
|
||||
|
||||
private bool hasErrorWindow = false;
|
||||
private bool lastFrameUiHideState = false;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UiBuilder"/> class and registers it.
|
||||
/// You do not have to call this manually.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue