diff --git a/Dalamud/DalamudAsset.cs b/Dalamud/DalamudAsset.cs new file mode 100644 index 000000000..5b641c487 --- /dev/null +++ b/Dalamud/DalamudAsset.cs @@ -0,0 +1,146 @@ +using Dalamud.Storage.Assets; + +namespace Dalamud; + +/// +/// Specifies an asset that has been shipped as Dalamud Asset.
+/// Any asset can cease to exist at any point, even if the enum value exists.
+/// Either ship your own assets, or be prepared for errors. +///
+public enum DalamudAsset +{ + /// + /// Nothing. + /// + [DalamudAsset(DalamudAssetPurpose.Empty, data: new byte[0])] + Unspecified = 0, + + /// + /// : The fallback empty texture. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromRaw, data: new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 })] + [DalamudAssetRawTexture(4, 8, 4, SharpDX.DXGI.Format.BC1_UNorm)] + Empty4X4 = 1000, + + /// + /// : The Dalamud logo. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "logo.png")] + Logo = 1001, + + /// + /// : The Dalamud logo, but smaller. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "tsmLogo.png")] + LogoSmall = 1002, + + /// + /// : The default plugin icon. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "defaultIcon.png")] + DefaultIcon = 1003, + + /// + /// : The disabled plugin icon. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "disabledIcon.png")] + DisabledIcon = 1004, + + /// + /// : The outdated installable plugin icon. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "outdatedInstallableIcon.png")] + OutdatedInstallableIcon = 1005, + + /// + /// : The plugin trouble icon overlay. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "troubleIcon.png")] + TroubleIcon = 1006, + + /// + /// : The plugin update icon overlay. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "updateIcon.png")] + UpdateIcon = 1007, + + /// + /// : The plugin installed icon overlay. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "installedIcon.png")] + InstalledIcon = 1008, + + /// + /// : The third party plugin icon overlay. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "thirdIcon.png")] + ThirdIcon = 1009, + + /// + /// : The installed third party plugin icon overlay. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "thirdInstalledIcon.png")] + ThirdInstalledIcon = 1010, + + /// + /// : The API bump explainer icon. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "changelogApiBump.png")] + ChangelogApiBumpIcon = 1011, + + /// + /// : The background shade for + /// . + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "tsmShade.png")] + TitleScreenMenuShade = 1012, + + /// + /// : Noto Sans CJK JP Medium. + /// + [DalamudAsset(DalamudAssetPurpose.Font)] + [DalamudAssetPath("UIRes", "NotoSansCJKjp-Regular.otf")] + [DalamudAssetPath("UIRes", "NotoSansCJKjp-Medium.otf")] + NotoSansJpMedium = 2000, + + /// + /// : Noto Sans CJK KR Regular. + /// + [DalamudAsset(DalamudAssetPurpose.Font)] + [DalamudAssetPath("UIRes", "NotoSansCJKkr-Regular.otf")] + [DalamudAssetPath("UIRes", "NotoSansKR-Regular.otf")] + NotoSansKrRegular = 2001, + + /// + /// : Inconsolata Regular. + /// + [DalamudAsset(DalamudAssetPurpose.Font)] + [DalamudAssetPath("UIRes", "Inconsolata-Regular.ttf")] + InconsolataRegular = 2002, + + /// + /// : FontAwesome Free Solid. + /// + [DalamudAsset(DalamudAssetPurpose.Font)] + [DalamudAssetPath("UIRes", "FontAwesomeFreeSolid.otf")] + FontAwesomeFreeSolid = 2003, + + /// + /// : Game symbol fonts being used as webfonts at Lodestone. + /// + [DalamudAsset(DalamudAssetPurpose.Font, required: true)] + [DalamudAssetOnlineSource("https://img.finalfantasyxiv.com/lds/pc/global/fonts/FFXIV_Lodestone_SSF.ttf")] + LodestoneGameSymbol = 2004, +} diff --git a/Dalamud/Interface/Internal/Branding.cs b/Dalamud/Interface/Internal/Branding.cs deleted file mode 100644 index 4162cabeb..000000000 --- a/Dalamud/Interface/Internal/Branding.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.IO; - -using Dalamud.IoC.Internal; - -namespace Dalamud.Interface.Internal; - -/// -/// Class containing various textures used by Dalamud windows for branding purposes. -/// -[ServiceManager.EarlyLoadedService] -#pragma warning disable SA1015 -[InherentDependency] // Can't load textures before this -#pragma warning restore SA1015 -internal class Branding : IServiceType, IDisposable -{ - private readonly Dalamud dalamud; - private readonly TextureManager tm; - - /// - /// Initializes a new instance of the class. - /// - /// Dalamud instance. - /// TextureManager instance. - [ServiceManager.ServiceConstructor] - public Branding(Dalamud dalamud, TextureManager tm) - { - this.dalamud = dalamud; - this.tm = tm; - - this.LoadTextures(); - } - - /// - /// Gets a full-size Dalamud logo texture. - /// - public IDalamudTextureWrap Logo { get; private set; } = null!; - - /// - /// Gets a small Dalamud logo texture. - /// - public IDalamudTextureWrap LogoSmall { get; private set; } = null!; - - /// - public void Dispose() - { - this.Logo.Dispose(); - this.LogoSmall.Dispose(); - } - - private void LoadTextures() - { - this.Logo = this.tm.GetTextureFromFile(new FileInfo(Path.Combine(this.dalamud.AssetDirectory.FullName, "UIRes", "logo.png"))) - ?? throw new Exception("Could not load logo."); - - this.LogoSmall = this.tm.GetTextureFromFile(new FileInfo(Path.Combine(this.dalamud.AssetDirectory.FullName, "UIRes", "tsmLogo.png"))) - ?? throw new Exception("Could not load TSM logo."); - } -} diff --git a/Dalamud/Interface/Internal/DalamudInterface.cs b/Dalamud/Interface/Internal/DalamudInterface.cs index 18ab538c4..1a6e71194 100644 --- a/Dalamud/Interface/Internal/DalamudInterface.cs +++ b/Dalamud/Interface/Internal/DalamudInterface.cs @@ -26,6 +26,7 @@ using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; using Dalamud.Logging.Internal; using Dalamud.Plugin.Internal; +using Dalamud.Storage.Assets; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.System.Framework; @@ -93,7 +94,7 @@ internal class DalamudInterface : IDisposable, IServiceType DalamudConfiguration configuration, InterfaceManager.InterfaceManagerWithScene interfaceManagerWithScene, PluginImageCache pluginImageCache, - Branding branding, + DalamudAssetManager dalamudAssetManager, Game.Framework framework, ClientState clientState, TitleScreenMenu titleScreenMenu, @@ -118,11 +119,10 @@ internal class DalamudInterface : IDisposable, IServiceType this.styleEditorWindow = new StyleEditorWindow() { IsOpen = false }; this.titleScreenMenuWindow = new TitleScreenMenuWindow( clientState, - dalamud, configuration, + dalamudAssetManager, framework, gameGui, - this.interfaceManager, titleScreenMenu) { IsOpen = false }; this.changelogWindow = new ChangelogWindow(this.titleScreenMenuWindow) { IsOpen = false }; this.profilerWindow = new ProfilerWindow() { IsOpen = false }; @@ -152,12 +152,21 @@ internal class DalamudInterface : IDisposable, IServiceType this.interfaceManager.Draw += this.OnDraw; var tsm = Service.Get(); - tsm.AddEntryCore(Loc.Localize("TSMDalamudPlugins", "Plugin Installer"), branding.LogoSmall, () => this.OpenPluginInstaller()); - tsm.AddEntryCore(Loc.Localize("TSMDalamudSettings", "Dalamud Settings"), branding.LogoSmall, this.OpenSettings); + tsm.AddEntryCore( + Loc.Localize("TSMDalamudPlugins", "Plugin Installer"), + dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.LogoSmall), + this.OpenPluginInstaller); + tsm.AddEntryCore( + Loc.Localize("TSMDalamudSettings", "Dalamud Settings"), + dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.LogoSmall), + this.OpenSettings); if (!configuration.DalamudBetaKind.IsNullOrEmpty()) { - tsm.AddEntryCore(Loc.Localize("TSMDalamudDevMenu", "Developer Menu"), branding.LogoSmall, () => this.isImGuiDrawDevMenu = true); + tsm.AddEntryCore( + Loc.Localize("TSMDalamudDevMenu", "Developer Menu"), + dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.LogoSmall), + () => this.isImGuiDrawDevMenu = true); } this.creditsDarkeningAnimation.Point1 = Vector2.Zero; diff --git a/Dalamud/Interface/Internal/InterfaceManager.cs b/Dalamud/Interface/Internal/InterfaceManager.cs index c666a96a9..52e849c0e 100644 --- a/Dalamud/Interface/Internal/InterfaceManager.cs +++ b/Dalamud/Interface/Internal/InterfaceManager.cs @@ -21,6 +21,7 @@ using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Style; using Dalamud.Interface.Utility; using Dalamud.Interface.Windowing; +using Dalamud.Storage.Assets; using Dalamud.Utility; using Dalamud.Utility.Timing; using ImGuiNET; @@ -1063,10 +1064,15 @@ internal class InterfaceManager : IDisposable, IServiceType } [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction(TargetSigScanner sigScanner, Framework framework) + private void ContinueConstruction( + TargetSigScanner sigScanner, + DalamudAssetManager dalamudAssetManager, + DalamudConfiguration configuration) { + dalamudAssetManager.WaitForAllRequiredAssets().Wait(); + this.address.Setup(sigScanner); - framework.RunOnFrameworkThread(() => + this.framework.RunOnFrameworkThread(() => { while ((this.GameWindowHandle = NativeFunctions.FindWindowEx(IntPtr.Zero, this.GameWindowHandle, "FFXIVGAME", IntPtr.Zero)) != IntPtr.Zero) { @@ -1078,7 +1084,7 @@ internal class InterfaceManager : IDisposable, IServiceType try { - if (Service.Get().WindowIsImmersive) + if (configuration.WindowIsImmersive) this.SetImmersiveMode(true); } catch (Exception ex) diff --git a/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs b/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs index e3f318223..b9e7ab686 100644 --- a/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs +++ b/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs @@ -11,6 +11,7 @@ using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; using Dalamud.Plugin.Internal; +using Dalamud.Storage.Assets; using Dalamud.Utility; using ImGuiNET; @@ -32,7 +33,6 @@ internal sealed class ChangelogWindow : Window, IDisposable "; private readonly TitleScreenMenuWindow tsmWindow; - private readonly IDalamudTextureWrap logoTexture; private readonly InOutCubic windowFade = new(TimeSpan.FromSeconds(2.5f)) { @@ -47,6 +47,7 @@ internal sealed class ChangelogWindow : Window, IDisposable }; private IDalamudTextureWrap? apiBumpExplainerTexture; + private IDalamudTextureWrap? logoTexture; private GameFontHandle? bannerFont; private State state = State.WindowFadeIn; @@ -63,8 +64,6 @@ internal sealed class ChangelogWindow : Window, IDisposable this.tsmWindow = tsmWindow; this.Namespace = "DalamudChangelogWindow"; - this.logoTexture = Service.Get().Logo; - // If we are going to show a changelog, make sure we have the font ready, otherwise it will hitch if (WarrantsChangelog()) Service.GetAsync().ContinueWith(t => this.MakeFont(t.Result)); @@ -188,6 +187,7 @@ internal sealed class ChangelogWindow : Window, IDisposable using (ImRaii.PushStyle(ImGuiStyleVar.Alpha, Math.Clamp(this.windowFade.EasedPoint.X - 0.5f, 0f, 1f))) { + this.logoTexture ??= Service.Get().GetDalamudTextureWrap(DalamudAsset.Logo); ImGui.Image(this.logoTexture.ImGuiHandle, logoSize); } } @@ -376,7 +376,6 @@ internal sealed class ChangelogWindow : Window, IDisposable /// public void Dispose() { - this.logoTexture.Dispose(); } private void MakeFont(GameFontManager gfm) => diff --git a/Dalamud/Interface/Internal/Windows/PluginImageCache.cs b/Dalamud/Interface/Internal/Windows/PluginImageCache.cs index b721b08c3..528507229 100644 --- a/Dalamud/Interface/Internal/Windows/PluginImageCache.cs +++ b/Dalamud/Interface/Internal/Windows/PluginImageCache.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; @@ -12,8 +11,8 @@ using Dalamud.Networking.Http; using Dalamud.Plugin.Internal; using Dalamud.Plugin.Internal.Types; using Dalamud.Plugin.Internal.Types.Manifest; +using Dalamud.Storage.Assets; using Dalamud.Utility; -using ImGuiScene; using Serilog; namespace Dalamud.Interface.Internal.Windows; @@ -47,12 +46,6 @@ internal class PluginImageCache : IDisposable, IServiceType private const string MainRepoImageUrl = "https://raw.githubusercontent.com/goatcorp/DalamudPlugins/api6/{0}/{1}/images/{2}"; private const string MainRepoDip17ImageUrl = "https://raw.githubusercontent.com/goatcorp/PluginDistD17/main/{0}/{1}/images/{2}"; - [ServiceManager.ServiceDependency] - private readonly InterfaceManager.InterfaceManagerWithScene imWithScene = Service.Get(); - - [ServiceManager.ServiceDependency] - private readonly Branding branding = Service.Get(); - [ServiceManager.ServiceDependency] private readonly HappyHttpClient happyHttpClient = Service.Get(); @@ -64,35 +57,12 @@ internal class PluginImageCache : IDisposable, IServiceType private readonly ConcurrentDictionary pluginIconMap = new(); private readonly ConcurrentDictionary pluginImagesMap = new(); - - private readonly Task emptyTextureTask; - private readonly Task disabledIconTask; - private readonly Task outdatedInstallableIconTask; - private readonly Task defaultIconTask; - private readonly Task troubleIconTask; - private readonly Task updateIconTask; - private readonly Task installedIconTask; - private readonly Task thirdIconTask; - private readonly Task thirdInstalledIconTask; - private readonly Task corePluginIconTask; + private readonly DalamudAssetManager dalamudAssetManager; [ServiceManager.ServiceConstructor] - private PluginImageCache(Dalamud dalamud) + private PluginImageCache(Dalamud dalamud, DalamudAssetManager dalamudAssetManager) { - Task? TaskWrapIfNonNull(IDalamudTextureWrap? tw) => tw == null ? null : Task.FromResult(tw!); - var imwst = Task.Run(() => this.imWithScene); - - this.emptyTextureTask = imwst.ContinueWith(task => task.Result.Manager.LoadImageRaw(new byte[64], 8, 8, 4)!); - this.defaultIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(task.Result.Manager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "defaultIcon.png"))) ?? this.emptyTextureTask).Unwrap(); - this.disabledIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(task.Result.Manager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "disabledIcon.png"))) ?? this.emptyTextureTask).Unwrap(); - this.outdatedInstallableIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(task.Result.Manager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "outdatedInstallableIcon.png"))) ?? this.emptyTextureTask).Unwrap(); - this.troubleIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(task.Result.Manager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "troubleIcon.png"))) ?? this.emptyTextureTask).Unwrap(); - this.updateIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(task.Result.Manager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "updateIcon.png"))) ?? this.emptyTextureTask).Unwrap(); - this.installedIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(task.Result.Manager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "installedIcon.png"))) ?? this.emptyTextureTask).Unwrap(); - this.thirdIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(task.Result.Manager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "thirdIcon.png"))) ?? this.emptyTextureTask).Unwrap(); - this.thirdInstalledIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(task.Result.Manager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "thirdInstalledIcon.png"))) ?? this.emptyTextureTask).Unwrap(); - this.corePluginIconTask = imwst.ContinueWith(task => TaskWrapIfNonNull(this.branding.LogoSmall)).Unwrap(); - + this.dalamudAssetManager = dalamudAssetManager; this.downloadTask = Task.Factory.StartNew( () => this.DownloadTask(8), TaskCreationOptions.LongRunning); this.loadTask = Task.Factory.StartNew( @@ -102,72 +72,62 @@ internal class PluginImageCache : IDisposable, IServiceType /// /// Gets the fallback empty texture. /// - public IDalamudTextureWrap EmptyTexture => this.emptyTextureTask.IsCompleted - ? this.emptyTextureTask.Result - : this.emptyTextureTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap EmptyTexture => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.Empty4X4); /// /// Gets the disabled plugin icon. /// - public IDalamudTextureWrap DisabledIcon => this.disabledIconTask.IsCompleted - ? this.disabledIconTask.Result - : this.disabledIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap DisabledIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.DisabledIcon, this.EmptyTexture); /// /// Gets the outdated installable plugin icon. /// - public IDalamudTextureWrap OutdatedInstallableIcon => this.outdatedInstallableIconTask.IsCompleted - ? this.outdatedInstallableIconTask.Result - : this.outdatedInstallableIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap OutdatedInstallableIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.OutdatedInstallableIcon, this.EmptyTexture); /// /// Gets the default plugin icon. /// - public IDalamudTextureWrap DefaultIcon => this.defaultIconTask.IsCompleted - ? this.defaultIconTask.Result - : this.defaultIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap DefaultIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.DefaultIcon, this.EmptyTexture); /// /// Gets the plugin trouble icon overlay. /// - public IDalamudTextureWrap TroubleIcon => this.troubleIconTask.IsCompleted - ? this.troubleIconTask.Result - : this.troubleIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap TroubleIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.TroubleIcon, this.EmptyTexture); /// /// Gets the plugin update icon overlay. /// - public IDalamudTextureWrap UpdateIcon => this.updateIconTask.IsCompleted - ? this.updateIconTask.Result - : this.updateIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap UpdateIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.UpdateIcon, this.EmptyTexture); /// /// Gets the plugin installed icon overlay. /// - public IDalamudTextureWrap InstalledIcon => this.installedIconTask.IsCompleted - ? this.installedIconTask.Result - : this.installedIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap InstalledIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.InstalledIcon, this.EmptyTexture); /// /// Gets the third party plugin icon overlay. /// - public IDalamudTextureWrap ThirdIcon => this.thirdIconTask.IsCompleted - ? this.thirdIconTask.Result - : this.thirdIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap ThirdIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.ThirdIcon, this.EmptyTexture); /// /// Gets the installed third party plugin icon overlay. /// - public IDalamudTextureWrap ThirdInstalledIcon => this.thirdInstalledIconTask.IsCompleted - ? this.thirdInstalledIconTask.Result - : this.thirdInstalledIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap ThirdInstalledIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.ThirdInstalledIcon, this.EmptyTexture); /// /// Gets the core plugin icon. /// - public IDalamudTextureWrap CorePluginIcon => this.corePluginIconTask.IsCompleted - ? this.corePluginIconTask.Result - : this.corePluginIconTask.GetAwaiter().GetResult(); + public IDalamudTextureWrap CorePluginIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.LogoSmall, this.EmptyTexture); /// public void Dispose() @@ -185,22 +145,6 @@ internal class PluginImageCache : IDisposable, IServiceType this.downloadQueue.Dispose(); this.loadQueue.Dispose(); - foreach (var task in new[] - { - this.defaultIconTask, - this.troubleIconTask, - this.updateIconTask, - this.installedIconTask, - this.thirdIconTask, - this.thirdInstalledIconTask, - this.corePluginIconTask, - }) - { - task.Wait(); - if (task.IsCompletedSuccessfully) - task.Result.Dispose(); - } - foreach (var icon in this.pluginIconMap.Values) { icon?.Dispose(); @@ -319,7 +263,7 @@ internal class PluginImageCache : IDisposable, IServiceType if (bytes == null) return null; - var interfaceManager = this.imWithScene.Manager; + var interfaceManager = (await Service.GetAsync()).Manager; var framework = await Service.GetAsync(); IDalamudTextureWrap? image; diff --git a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabAbout.cs b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabAbout.cs index 9b6a32617..5b6f6b02f 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabAbout.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabAbout.cs @@ -11,6 +11,7 @@ using Dalamud.Interface.GameFonts; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Plugin.Internal; +using Dalamud.Storage.Assets; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.Game.UI; using ImGuiNET; @@ -171,19 +172,16 @@ Dalamud is licensed under AGPL v3 or later. Contribute at: https://github.com/goatcorp/Dalamud "; - private readonly IDalamudTextureWrap logoTexture; private readonly Stopwatch creditsThrottler; private string creditsText; private bool resetNow = false; + private IDalamudTextureWrap? logoTexture; private GameFontHandle? thankYouFont; public SettingsTabAbout() { - var branding = Service.Get(); - - this.logoTexture = branding.Logo; this.creditsThrottler = new(); } @@ -251,6 +249,7 @@ Contribute at: https://github.com/goatcorp/Dalamud const float imageSize = 190f; ImGui.SameLine((ImGui.GetWindowWidth() / 2) - (imageSize / 2)); + this.logoTexture ??= Service.Get().GetDalamudTextureWrap(DalamudAsset.Logo); ImGui.Image(this.logoTexture.ImGuiHandle, ImGuiHelpers.ScaledVector2(imageSize)); ImGuiHelpers.ScaledDummy(0, 20f); diff --git a/Dalamud/Interface/Internal/Windows/TitleScreenMenuWindow.cs b/Dalamud/Interface/Internal/Windows/TitleScreenMenuWindow.cs index 4034695e5..f60ebe4ef 100644 --- a/Dalamud/Interface/Internal/Windows/TitleScreenMenuWindow.cs +++ b/Dalamud/Interface/Internal/Windows/TitleScreenMenuWindow.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.IO; using System.Linq; using System.Numerics; @@ -12,6 +11,7 @@ using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; using Dalamud.Plugin.Services; +using Dalamud.Storage.Assets; using ImGuiNET; @@ -46,19 +46,17 @@ internal class TitleScreenMenuWindow : Window, IDisposable /// Initializes a new instance of the class. /// /// An instance of . - /// An instance of . /// An instance of . + /// An instance of . /// An instance of . - /// An instance of . /// An instance of . /// An instance of . public TitleScreenMenuWindow( ClientState clientState, - Dalamud dalamud, DalamudConfiguration configuration, + DalamudAssetManager dalamudAssetManager, Framework framework, GameGui gameGui, - InterfaceManager interfaceManager, TitleScreenMenu titleScreenMenu) : base( "TitleScreenMenuOverlay", @@ -79,9 +77,7 @@ internal class TitleScreenMenuWindow : Window, IDisposable this.PositionCondition = ImGuiCond.Always; this.RespectCloseHotkey = false; - var shadeTex = - interfaceManager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "tsmShade.png")); - this.shadeTexture = shadeTex ?? throw new Exception("Could not load TSM background texture."); + this.shadeTexture = dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.TitleScreenMenuShade); framework.Update += this.FrameworkOnUpdate; } @@ -116,7 +112,6 @@ internal class TitleScreenMenuWindow : Window, IDisposable /// public void Dispose() { - this.shadeTexture.Dispose(); this.framework.Update -= this.FrameworkOnUpdate; } @@ -386,7 +381,7 @@ internal class TitleScreenMenuWindow : Window, IDisposable return isHover; } - private void FrameworkOnUpdate(IFramework framework) + private void FrameworkOnUpdate(IFramework unused) { this.IsOpen = !this.clientState.IsLoggedIn; diff --git a/Dalamud/Storage/Assets/DalamudAssetAttribute.cs b/Dalamud/Storage/Assets/DalamudAssetAttribute.cs new file mode 100644 index 000000000..a3527cdbc --- /dev/null +++ b/Dalamud/Storage/Assets/DalamudAssetAttribute.cs @@ -0,0 +1,36 @@ +namespace Dalamud.Storage.Assets; + +/// +/// Stores the basic information of a Dalamud asset. +/// +[AttributeUsage(AttributeTargets.Field)] +internal class DalamudAssetAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The purpose. + /// The data. + /// Whether the asset is required. + public DalamudAssetAttribute(DalamudAssetPurpose purpose, byte[]? data = null, bool required = true) + { + this.Purpose = purpose; + this.Data = data; + this.Required = required; + } + + /// + /// Gets the purpose of the asset. + /// + public DalamudAssetPurpose Purpose { get; } + + /// + /// Gets the data, if available. + /// + public byte[]? Data { get; } + + /// + /// Gets a value indicating whether the asset is required. + /// + public bool Required { get; } +} diff --git a/Dalamud/Storage/Assets/DalamudAssetExtensions.cs b/Dalamud/Storage/Assets/DalamudAssetExtensions.cs new file mode 100644 index 000000000..9181f1a5d --- /dev/null +++ b/Dalamud/Storage/Assets/DalamudAssetExtensions.cs @@ -0,0 +1,17 @@ +using Dalamud.Utility; + +namespace Dalamud.Storage.Assets; + +/// +/// Extension methods for . +/// +public static class DalamudAssetExtensions +{ + /// + /// Gets the purpose. + /// + /// The asset. + /// The purpose. + public static DalamudAssetPurpose GetPurpose(this DalamudAsset asset) => + asset.GetAttribute()?.Purpose ?? DalamudAssetPurpose.Empty; +} diff --git a/Dalamud/Storage/Assets/DalamudAssetManager.cs b/Dalamud/Storage/Assets/DalamudAssetManager.cs new file mode 100644 index 000000000..bbfd60636 --- /dev/null +++ b/Dalamud/Storage/Assets/DalamudAssetManager.cs @@ -0,0 +1,365 @@ +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Dalamud.Interface.Internal; +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Networking.Http; +using Dalamud.Utility; +using Dalamud.Utility.Timing; + +using JetBrains.Annotations; + +using Serilog; + +namespace Dalamud.Storage.Assets; + +/// +/// A concrete class for . +/// +[PluginInterface] +[ServiceManager.BlockingEarlyLoadedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal sealed class DalamudAssetManager : IServiceType, IDisposable, IDalamudAssetManager +{ + private const int DownloadAttemptCount = 10; + private const int RenameAttemptCount = 10; + + private readonly object syncRoot = new(); + private readonly DisposeSafety.ScopedFinalizer scopedFinalizer = new(); + private readonly Dictionary?> fileStreams; + private readonly Dictionary?> textureWraps; + private readonly Dalamud dalamud; + private readonly HappyHttpClient httpClient; + private readonly string localSourceDirectory; + private readonly CancellationTokenSource cancellationTokenSource; + + private bool isDisposed; + + [ServiceManager.ServiceConstructor] + private DalamudAssetManager(Dalamud dalamud, HappyHttpClient httpClient) + { + this.dalamud = dalamud; + this.httpClient = httpClient; + this.localSourceDirectory = Path.Combine(this.dalamud.AssetDirectory.FullName, "..", "local"); + Directory.CreateDirectory(this.localSourceDirectory); + this.scopedFinalizer.Add(this.cancellationTokenSource = new()); + + this.fileStreams = Enum.GetValues().ToDictionary(x => x, _ => (Task?)null); + this.textureWraps = Enum.GetValues().ToDictionary(x => x, _ => (Task?)null); + + var loadTimings = Timings.Start("DAM LoadAll"); + this.WaitForAllRequiredAssets().ContinueWith(_ => loadTimings.Dispose()); + } + + /// + public IDalamudTextureWrap Empty4X4 => this.GetDalamudTextureWrap(DalamudAsset.Empty4X4); + + /// + public void Dispose() + { + lock (this.syncRoot) + { + if (this.isDisposed) + return; + + this.isDisposed = true; + } + + this.cancellationTokenSource.Cancel(); + Task.WaitAll( + Array.Empty() + .Concat(this.fileStreams.Values) + .Concat(this.textureWraps.Values) + .Where(x => x is not null) + .ToArray()); + this.scopedFinalizer.Dispose(); + } + + /// + /// Waits for all the required assets to be ready. Will result in a faulted task, if any of the required assets + /// has failed to load. + /// + /// The task. + [Pure] + public Task WaitForAllRequiredAssets() + { + lock (this.syncRoot) + { + return Task.WhenAll( + Enum.GetValues() + .Where(x => x is not DalamudAsset.Empty4X4) + .Select(this.CreateStreamAsync) + .Select(x => x.ToContentDisposedTask())); + } + } + + /// + [Pure] + public bool IsStreamImmediatelyAvailable(DalamudAsset asset) => + asset.GetAttribute()?.Data is not null + || this.fileStreams[asset]?.IsCompletedSuccessfully is true; + + /// + [Pure] + public Stream CreateStream(DalamudAsset asset) + { + var s = this.CreateStreamAsync(asset); + s.Wait(); + if (s.IsCompletedSuccessfully) + return s.Result; + if (s.Exception is not null) + throw new AggregateException(s.Exception.InnerExceptions); + throw new OperationCanceledException(); + } + + /// + [Pure] + public Task CreateStreamAsync(DalamudAsset asset) + { + if (asset.GetAttribute() is { Data: { } rawData }) + return Task.FromResult(new MemoryStream(rawData, false)); + + Task task; + lock (this.syncRoot) + { + if (this.isDisposed) + throw new ObjectDisposedException(nameof(DalamudAssetManager)); + + task = this.fileStreams[asset] ??= CreateInnerAsync(); + } + + return this.TransformImmediate( + task, + x => (Stream)new FileStream( + x.Name, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 4096, + FileOptions.Asynchronous | FileOptions.SequentialScan)); + + async Task CreateInnerAsync() + { + string path; + List exceptions = null; + foreach (var name in asset.GetAttributes().Select(x => x.FileName)) + { + if (!File.Exists(path = Path.Combine(this.dalamud.AssetDirectory.FullName, name))) + continue; + + try + { + return File.OpenRead(path); + } + catch (Exception e) when (e is not OperationCanceledException) + { + exceptions ??= new(); + exceptions.Add(e); + } + } + + if (File.Exists(path = Path.Combine(this.localSourceDirectory, asset.ToString()))) + { + try + { + return File.OpenRead(path); + } + catch (Exception e) when (e is not OperationCanceledException) + { + exceptions ??= new(); + exceptions.Add(e); + } + } + + var tempPath = $"{path}.{Environment.ProcessId:x}.{Environment.CurrentManagedThreadId:x}"; + try + { + for (var i = 0; i < DownloadAttemptCount; i++) + { + var attemptedAny = false; + foreach (var url in asset.GetAttributes()) + { + Log.Information("[{who}] {asset}: Trying {url}", nameof(DalamudAssetManager), asset, url); + attemptedAny = true; + + try + { + await using var tempPathStream = File.Open(tempPath, FileMode.Create, FileAccess.Write); + await url.DownloadAsync( + this.httpClient.SharedHttpClient, + tempPathStream, + this.cancellationTokenSource.Token); + tempPathStream.Dispose(); + for (var j = RenameAttemptCount; ; j--) + { + try + { + File.Move(tempPath, path); + } + catch (IOException ioe) + { + if (j == 0) + throw; + Log.Warning( + ioe, + "[{who}] {asset}: Renaming failed; trying again {n} more times", + nameof(DalamudAssetManager), + asset, + j); + await Task.Delay(1000, this.cancellationTokenSource.Token); + continue; + } + + return File.OpenRead(path); + } + } + catch (Exception e) when (e is not OperationCanceledException) + { + Log.Error(e, "[{who}] {asset}: Failed {url}", nameof(DalamudAssetManager), asset, url); + } + } + + if (!attemptedAny) + throw new FileNotFoundException($"Failed to find the asset {asset}.", asset.ToString()); + + // Wait up to 5 minutes + var delay = Math.Min(300, (1 << i) * 1000); + Log.Error( + "[{who}] {asset}: Failed to download. Trying again in {sec} seconds...", + nameof(DalamudAssetManager), + asset, + delay); + await Task.Delay(delay * 1000, this.cancellationTokenSource.Token); + } + + throw new FileNotFoundException($"Failed to load the asset {asset}.", asset.ToString()); + } + catch (Exception e) when (e is not OperationCanceledException) + { + exceptions ??= new(); + exceptions.Add(e); + try + { + File.Delete(tempPath); + } + catch + { + // don't care + } + } + + throw new AggregateException(exceptions); + } + } + + /// + [Pure] + public IDalamudTextureWrap GetDalamudTextureWrap(DalamudAsset asset) => + ExtractResult(this.GetDalamudTextureWrapAsync(asset)); + + /// + [Pure] + [return: NotNullIfNotNull(nameof(defaultWrap))] + public IDalamudTextureWrap? GetDalamudTextureWrap(DalamudAsset asset, IDalamudTextureWrap? defaultWrap) + { + var task = this.GetDalamudTextureWrapAsync(asset); + return task.IsCompletedSuccessfully ? task.Result : defaultWrap; + } + + /// + [Pure] + public Task GetDalamudTextureWrapAsync(DalamudAsset asset) + { + var purpose = asset.GetPurpose(); + if (purpose is not DalamudAssetPurpose.TextureFromPng and not DalamudAssetPurpose.TextureFromRaw) + throw new ArgumentOutOfRangeException(nameof(asset), asset, "The asset cannot be taken as a Texture2D."); + + Task task; + lock (this.syncRoot) + { + if (this.isDisposed) + throw new ObjectDisposedException(nameof(DalamudAssetManager)); + + task = this.textureWraps[asset] ??= CreateInnerAsync(); + } + + return task; + + async Task CreateInnerAsync() + { + var buf = Array.Empty(); + try + { + var im = (await Service.GetAsync()).Manager; + await using var stream = await this.CreateStreamAsync(asset); + var length = checked((int)stream.Length); + buf = ArrayPool.Shared.Rent(length); + stream.ReadExactly(buf, 0, length); + var image = purpose switch + { + DalamudAssetPurpose.TextureFromPng => im.LoadImage(buf), + DalamudAssetPurpose.TextureFromRaw => + asset.GetAttribute() is { } raw + ? im.LoadImageFromDxgiFormat(buf, raw.Pitch, raw.Width, raw.Height, raw.Format) + : throw new InvalidOperationException( + "TextureFromRaw must accompany a DalamudAssetRawTextureAttribute."), + _ => null, + }; + var disposeDeferred = + this.scopedFinalizer.Add(image) + ?? throw new InvalidOperationException("Something went wrong very badly"); + return new DisposeSuppressingDalamudTextureWrap(disposeDeferred); + } + catch (Exception e) + { + Log.Error(e, "[{name}] Failed to load {asset}.", nameof(DalamudAssetManager), asset); + throw; + } + finally + { + ArrayPool.Shared.Return(buf); + } + } + } + + private static T ExtractResult(Task t) => t.IsCompleted ? t.Result : t.GetAwaiter().GetResult(); + + private Task TransformImmediate(Task task, Func transformer) + { + if (task.IsCompletedSuccessfully) + return Task.FromResult(transformer(task.Result)); + if (task.Exception is { } exc) + return Task.FromException(exc); + return task.ContinueWith(_ => this.TransformImmediate(task, transformer)).Unwrap(); + } + + private class DisposeSuppressingDalamudTextureWrap : IDalamudTextureWrap + { + private readonly IDalamudTextureWrap innerWrap; + + public DisposeSuppressingDalamudTextureWrap(IDalamudTextureWrap wrap) => this.innerWrap = wrap; + + /// + public IntPtr ImGuiHandle => this.innerWrap.ImGuiHandle; + + /// + public int Width => this.innerWrap.Width; + + /// + public int Height => this.innerWrap.Height; + + /// + public void Dispose() + { + // suppressed + } + } +} diff --git a/Dalamud/Storage/Assets/DalamudAssetOnlineSourceAttribute.cs b/Dalamud/Storage/Assets/DalamudAssetOnlineSourceAttribute.cs new file mode 100644 index 000000000..25ed995d7 --- /dev/null +++ b/Dalamud/Storage/Assets/DalamudAssetOnlineSourceAttribute.cs @@ -0,0 +1,48 @@ +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Dalamud.Storage.Assets; + +/// +/// Marks that an asset can be download from online. +/// +[AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] +internal class DalamudAssetOnlineSourceAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The URL. + public DalamudAssetOnlineSourceAttribute(string url) + { + this.Url = url; + } + + /// + /// Gets the source URL of the file. + /// + public string Url { get; } + + /// + /// Downloads to the given stream. + /// + /// The client. + /// The stream. + /// The cancellation token. + /// The task. + public async Task DownloadAsync(HttpClient client, Stream stream, CancellationToken cancellationToken) + { + using var resp = await client.GetAsync(this.Url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + resp.EnsureSuccessStatusCode(); + if (resp.StatusCode != HttpStatusCode.OK) + throw new NotSupportedException($"Only 200 OK is supported; got {resp.StatusCode}"); + + await using var readStream = await resp.Content.ReadAsStreamAsync(cancellationToken); + await readStream.CopyToAsync(stream, cancellationToken); + if (resp.Content.Headers.ContentLength is { } length && stream.Length != length) + throw new IOException($"Expected {length} bytes; got {stream.Length} bytes."); + } +} diff --git a/Dalamud/Storage/Assets/DalamudAssetPathAttribute.cs b/Dalamud/Storage/Assets/DalamudAssetPathAttribute.cs new file mode 100644 index 000000000..1df52aa39 --- /dev/null +++ b/Dalamud/Storage/Assets/DalamudAssetPathAttribute.cs @@ -0,0 +1,21 @@ +using System.IO; + +namespace Dalamud.Storage.Assets; + +/// +/// File names to look up in Dalamud assets. +/// +[AttributeUsage(AttributeTargets.Field, AllowMultiple = true)] +internal class DalamudAssetPathAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The path components. + public DalamudAssetPathAttribute(params string[] pathComponents) => this.FileName = Path.Join(pathComponents); + + /// + /// Gets the file name. + /// + public string FileName { get; } +} diff --git a/Dalamud/Storage/Assets/DalamudAssetPurpose.cs b/Dalamud/Storage/Assets/DalamudAssetPurpose.cs new file mode 100644 index 000000000..b059cb3d6 --- /dev/null +++ b/Dalamud/Storage/Assets/DalamudAssetPurpose.cs @@ -0,0 +1,27 @@ +namespace Dalamud.Storage.Assets; + +/// +/// Purposes of a Dalamud asset. +/// +public enum DalamudAssetPurpose +{ + /// + /// The asset has no purpose. + /// + Empty = 0, + + /// + /// The asset is a .png file, and can be purposed as a . + /// + TextureFromPng = 10, + + /// + /// The asset is a raw texture, and can be purposed as a . + /// + TextureFromRaw = 1001, + + /// + /// The asset is a font file. + /// + Font = 2000, +} diff --git a/Dalamud/Storage/Assets/DalamudAssetRawTextureAttribute.cs b/Dalamud/Storage/Assets/DalamudAssetRawTextureAttribute.cs new file mode 100644 index 000000000..b79abb7d7 --- /dev/null +++ b/Dalamud/Storage/Assets/DalamudAssetRawTextureAttribute.cs @@ -0,0 +1,45 @@ +using SharpDX.DXGI; + +namespace Dalamud.Storage.Assets; + +/// +/// Provide raw texture data directly. +/// +[AttributeUsage(AttributeTargets.Field)] +internal class DalamudAssetRawTextureAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The width. + /// The pitch. + /// The height. + /// The format. + public DalamudAssetRawTextureAttribute(int width, int pitch, int height, Format format) + { + this.Width = width; + this.Pitch = pitch; + this.Height = height; + this.Format = format; + } + + /// + /// Gets the width. + /// + public int Width { get; } + + /// + /// Gets the pitch. + /// + public int Pitch { get; } + + /// + /// Gets the height. + /// + public int Height { get; } + + /// + /// Gets the format. + /// + public Format Format { get; } +} diff --git a/Dalamud/Storage/Assets/IDalamudAssetManager.cs b/Dalamud/Storage/Assets/IDalamudAssetManager.cs new file mode 100644 index 000000000..4fb83df80 --- /dev/null +++ b/Dalamud/Storage/Assets/IDalamudAssetManager.cs @@ -0,0 +1,79 @@ +using System.Diagnostics.Contracts; +using System.IO; +using System.Threading.Tasks; + +using Dalamud.Interface.Internal; + +namespace Dalamud.Storage.Assets; + +/// +/// Holds Dalamud Assets' handles hostage, so that they do not get closed while Dalamud is running.
+/// Also, attempts to load optional assets.
+///
+/// Note on
+/// It will help you get notified if you discard the result of functions, mostly likely because of a mistake. +/// Think of C++ [[nodiscard]]. Also, like the intended meaning of the attribute, such methods will not have +/// externally visible state changes. +///
+internal interface IDalamudAssetManager +{ + /// + /// Gets the shared texture wrap for . + /// + IDalamudTextureWrap Empty4X4 { get; } + + /// + /// Gets whether the stream for the asset is instantly available. + /// + /// The asset. + /// Whether the stream of an asset is immediately available. + [Pure] + bool IsStreamImmediatelyAvailable(DalamudAsset asset); + + /// + /// Creates a stream backed by the specified asset, waiting as necessary.
+ /// Call after use. + ///
+ /// The asset. + /// The stream. + [Pure] + Stream CreateStream(DalamudAsset asset); + + /// + /// Creates a stream backed by the specified asset.
+ /// Call after use. + ///
+ /// The asset. + /// The stream, wrapped inside a . + [Pure] + Task CreateStreamAsync(DalamudAsset asset); + + /// + /// Gets a shared instance of , after waiting as necessary.
+ /// Calls to is unnecessary; they will be ignored. + ///
+ /// The texture asset. + /// The texture wrap. + [Pure] + IDalamudTextureWrap GetDalamudTextureWrap(DalamudAsset asset); + + /// + /// Gets a shared instance of if it is available instantly; + /// if it is not ready, returns .
+ /// Calls to is unnecessary; they will be ignored. + ///
+ /// The texture asset. + /// The default return value, if the asset is not ready for whatever reason. + /// The texture wrap. + [Pure] + IDalamudTextureWrap? GetDalamudTextureWrap(DalamudAsset asset, IDalamudTextureWrap? defaultWrap); + + /// + /// Gets a shared instance of in a .
+ /// Calls to is unnecessary; they will be ignored. + ///
+ /// The texture asset. + /// The new texture wrap, wrapped inside a . + [Pure] + Task GetDalamudTextureWrapAsync(DalamudAsset asset); +} diff --git a/Dalamud/Utility/EnumExtensions.cs b/Dalamud/Utility/EnumExtensions.cs index 0bb60962e..493e6be1f 100644 --- a/Dalamud/Utility/EnumExtensions.cs +++ b/Dalamud/Utility/EnumExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System.Collections.Generic; using System.Linq; namespace Dalamud.Utility; @@ -8,6 +8,26 @@ namespace Dalamud.Utility; /// public static class EnumExtensions { + /// + /// Gets attributes on an enum. + /// + /// The type of attribute to get. + /// The enum value that has an attached attribute. + /// The enumerable of the attached attributes. + public static IEnumerable GetAttributes(this Enum value) + where TAttribute : Attribute + { + var type = value.GetType(); + var name = Enum.GetName(type, value); + if (name.IsNullOrEmpty()) + return Array.Empty(); + + return type.GetField(name)? + .GetCustomAttributes(false) + .OfType() + ?? Array.Empty(); + } + /// /// Gets an attribute on an enum. /// @@ -15,18 +35,8 @@ public static class EnumExtensions /// The enum value that has an attached attribute. /// The attached attribute, if any. public static TAttribute? GetAttribute(this Enum value) - where TAttribute : Attribute - { - var type = value.GetType(); - var name = Enum.GetName(type, value); - if (name.IsNullOrEmpty()) - return null; - - return type.GetField(name)? - .GetCustomAttributes(false) - .OfType() - .SingleOrDefault(); - } + where TAttribute : Attribute => + value.GetAttributes().SingleOrDefault(); /// /// Gets an indicator if enum has been flagged as obsolete (deprecated).