diff --git a/Dalamud/Interface/Internal/SharableTextures/FileSystemSharableTexture.cs b/Dalamud/Interface/Internal/SharableTextures/FileSystemSharableTexture.cs index 06184b5ec..ff4a6adbf 100644 --- a/Dalamud/Interface/Internal/SharableTextures/FileSystemSharableTexture.cs +++ b/Dalamud/Interface/Internal/SharableTextures/FileSystemSharableTexture.cs @@ -1,3 +1,4 @@ +using System.Threading; using System.Threading.Tasks; using Dalamud.Utility; @@ -15,15 +16,40 @@ internal sealed class FileSystemSharableTexture : SharableTexture /// Initializes a new instance of the class. /// /// The path. - public FileSystemSharableTexture(string path) + /// If set to true, this class will hold a reference to self. + /// Otherwise, it is expected that the caller to hold the reference. + private FileSystemSharableTexture(string path, bool holdSelfReference) + : base(holdSelfReference) { this.path = path; - this.UnderlyingWrap = this.CreateTextureAsync(); + if (holdSelfReference) + { + this.UnderlyingWrap = Service.Get().CreateLoader( + this, + this.CreateTextureAsync, + this.LoadCancellationToken); + } } /// public override string SourcePathForDebug => this.path; + /// + /// Creates a new instance of . + /// The new instance will hold a reference to itself. + /// + /// The path. + /// The new instance. + public static SharableTexture CreateImmediate(string path) => new FileSystemSharableTexture(path, true); + + /// + /// Creates a new instance of . + /// The caller is expected to manage ownership of the new instance. + /// + /// The path. + /// The new instance. + public static SharableTexture CreateAsync(string path) => new FileSystemSharableTexture(path, false); + /// public override string ToString() => $"{nameof(FileSystemSharableTexture)}#{this.InstanceIdForDebug}({this.path})"; @@ -38,15 +64,16 @@ internal sealed class FileSystemSharableTexture : SharableTexture /// protected override void ReviveResources() => - this.UnderlyingWrap = this.CreateTextureAsync(); + this.UnderlyingWrap = Service.Get().CreateLoader( + this, + this.CreateTextureAsync, + this.LoadCancellationToken); - private Task CreateTextureAsync() => - Task.Run( - () => - { - var w = (IDalamudTextureWrap)Service.Get().LoadImage(this.path) - ?? throw new("Failed to load image because of an unknown reason."); - this.DisposeSuppressingWrap = new(w); - return w; - }); + private Task CreateTextureAsync(CancellationToken cancellationToken) + { + var w = (IDalamudTextureWrap)Service.Get().LoadImage(this.path) + ?? throw new("Failed to load image because of an unknown reason."); + this.DisposeSuppressingWrap = new(w); + return Task.FromResult(w); + } } diff --git a/Dalamud/Interface/Internal/SharableTextures/GamePathSharableTexture.cs b/Dalamud/Interface/Internal/SharableTextures/GamePathSharableTexture.cs index e58f21c26..ad026aff7 100644 --- a/Dalamud/Interface/Internal/SharableTextures/GamePathSharableTexture.cs +++ b/Dalamud/Interface/Internal/SharableTextures/GamePathSharableTexture.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Threading; using System.Threading.Tasks; using Dalamud.Data; @@ -19,15 +20,40 @@ internal sealed class GamePathSharableTexture : SharableTexture /// Initializes a new instance of the class. /// /// The path. - public GamePathSharableTexture(string path) + /// If set to true, this class will hold a reference to self. + /// Otherwise, it is expected that the caller to hold the reference. + private GamePathSharableTexture(string path, bool holdSelfReference) + : base(holdSelfReference) { this.path = path; - this.UnderlyingWrap = this.CreateTextureAsync(); + if (holdSelfReference) + { + this.UnderlyingWrap = Service.Get().CreateLoader( + this, + this.CreateTextureAsync, + this.LoadCancellationToken); + } } /// public override string SourcePathForDebug => this.path; + /// + /// Creates a new instance of . + /// The new instance will hold a reference to itself. + /// + /// The path. + /// The new instance. + public static SharableTexture CreateImmediate(string path) => new GamePathSharableTexture(path, true); + + /// + /// Creates a new instance of . + /// The caller is expected to manage ownership of the new instance. + /// + /// The path. + /// The new instance. + public static SharableTexture CreateAsync(string path) => new GamePathSharableTexture(path, false); + /// public override string ToString() => $"{nameof(GamePathSharableTexture)}#{this.InstanceIdForDebug}({this.path})"; @@ -41,17 +67,19 @@ internal sealed class GamePathSharableTexture : SharableTexture /// protected override void ReviveResources() => - this.UnderlyingWrap = this.CreateTextureAsync(); + this.UnderlyingWrap = Service.Get().CreateLoader( + this, + this.CreateTextureAsync, + this.LoadCancellationToken); - private Task CreateTextureAsync() => - Task.Run( - async () => - { - var dm = await Service.GetAsync(); - var im = await Service.GetAsync(); - var file = dm.GetFile(this.path); - var t = (IDalamudTextureWrap)im.LoadImageFromTexFile(file ?? throw new FileNotFoundException()); - this.DisposeSuppressingWrap = new(t); - return t; - }); + private async Task CreateTextureAsync(CancellationToken cancellationToken) + { + var dm = await Service.GetAsync(); + var im = await Service.GetAsync(); + var file = dm.GetFile(this.path); + cancellationToken.ThrowIfCancellationRequested(); + var t = (IDalamudTextureWrap)im.LoadImageFromTexFile(file ?? throw new FileNotFoundException()); + this.DisposeSuppressingWrap = new(t); + return t; + } } diff --git a/Dalamud/Interface/Internal/SharableTextures/SharableTexture.cs b/Dalamud/Interface/Internal/SharableTextures/SharableTexture.cs index c08cdb7e9..057589ee7 100644 --- a/Dalamud/Interface/Internal/SharableTextures/SharableTexture.cs +++ b/Dalamud/Interface/Internal/SharableTextures/SharableTexture.cs @@ -9,9 +9,9 @@ namespace Dalamud.Interface.Internal.SharableTextures; /// /// Represents a texture that may have multiple reference holders (owners). /// -internal abstract class SharableTexture : IRefCountable +internal abstract class SharableTexture : IRefCountable, TextureLoadThrottler.IThrottleBasisProvider { - private const int SelfReferenceDurationTicks = 5000; + private const int SelfReferenceDurationTicks = 2000; private const long SelfReferenceExpiryExpired = long.MaxValue; private static long instanceCounter; @@ -22,15 +22,24 @@ internal abstract class SharableTexture : IRefCountable private int refCount; private long selfReferenceExpiry; private IDalamudTextureWrap? availableOnAccessWrapForApi9; + private CancellationTokenSource? cancellationTokenSource; /// /// Initializes a new instance of the class. /// - protected SharableTexture() + /// If set to true, this class will hold a reference to self. + /// Otherwise, it is expected that the caller to hold the reference. + protected SharableTexture(bool holdSelfReference) { this.InstanceIdForDebug = Interlocked.Increment(ref instanceCounter); this.refCount = 1; - this.selfReferenceExpiry = Environment.TickCount64 + SelfReferenceDurationTicks; + this.selfReferenceExpiry = + holdSelfReference + ? Environment.TickCount64 + SelfReferenceDurationTicks + : SelfReferenceExpiryExpired; + this.IsOpportunistic = true; + this.FirstRequestedTick = this.LatestRequestedTick = Environment.TickCount64; + this.cancellationTokenSource = new(); } /// @@ -66,11 +75,26 @@ internal abstract class SharableTexture : IRefCountable /// public Task? UnderlyingWrap { get; set; } + /// + public bool IsOpportunistic { get; private set; } + + /// + public long FirstRequestedTick { get; private set; } + + /// + public long LatestRequestedTick { get; private set; } + /// /// Gets or sets the dispose-suppressing wrap for . /// protected DisposeSuppressingTextureWrap? DisposeSuppressingWrap { get; set; } + /// + /// Gets a cancellation token for cancelling load. + /// Intended to be called from implementors' constructors and . + /// + protected CancellationToken LoadCancellationToken => this.cancellationTokenSource?.Token ?? default; + /// /// Gets or sets a weak reference to an object that demands this objects to be alive. /// @@ -124,6 +148,7 @@ internal abstract class SharableTexture : IRefCountable continue; } + this.cancellationTokenSource = null; this.ReleaseResources(); this.resourceReleased = true; @@ -175,6 +200,7 @@ internal abstract class SharableTexture : IRefCountable if (this.TryAddRef(out _) != IRefCountable.RefCountResult.StillAlive) return null; + this.LatestRequestedTick = Environment.TickCount64; var nexp = Environment.TickCount64 + SelfReferenceDurationTicks; while (true) { @@ -197,22 +223,45 @@ internal abstract class SharableTexture : IRefCountable /// Creates a new reference to this texture. The texture is guaranteed to be available until /// is called. /// + /// The cancellation token. /// The task containing the texture. - public Task CreateNewReference() + public async Task CreateNewReference(CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + this.AddRef(); if (this.UnderlyingWrap is null) throw new InvalidOperationException("AddRef returned but UnderlyingWrap is null?"); - return this.UnderlyingWrap.ContinueWith( - r => + this.IsOpportunistic = false; + this.LatestRequestedTick = Environment.TickCount64; + var uw = this.UnderlyingWrap; + if (cancellationToken != default) + { + while (!uw.IsCompleted) { - if (r.IsCompletedSuccessfully) - return Task.FromResult((IDalamudTextureWrap)new RefCountableWrappingTextureWrap(r.Result, this)); + if (cancellationToken.IsCancellationRequested) + { + this.Release(); + throw new OperationCanceledException(cancellationToken); + } - this.Release(); - return r; - }).Unwrap(); + await Task.WhenAny(uw, Task.Delay(1000000, cancellationToken)); + } + } + + IDalamudTextureWrap dtw; + try + { + dtw = await uw; + } + catch + { + this.Release(); + throw; + } + + return new RefCountableWrappingTextureWrap(dtw, this); } /// @@ -233,7 +282,7 @@ internal abstract class SharableTexture : IRefCountable if (this.RevivalPossibility?.TryGetTarget(out this.availableOnAccessWrapForApi9) is true) return this.availableOnAccessWrapForApi9; - var newRefTask = this.CreateNewReference(); + var newRefTask = this.CreateNewReference(default); newRefTask.Wait(); if (!newRefTask.IsCompletedSuccessfully) return null; @@ -276,7 +325,17 @@ internal abstract class SharableTexture : IRefCountable if (alterResult != IRefCountable.RefCountResult.AlreadyDisposed) return alterResult; - this.ReviveResources(); + this.cancellationTokenSource = new(); + try + { + this.ReviveResources(); + } + catch + { + this.cancellationTokenSource = null; + throw; + } + if (this.RevivalPossibility?.TryGetTarget(out var target) is true) this.availableOnAccessWrapForApi9 = target; diff --git a/Dalamud/Interface/Internal/SharableTextures/TextureLoadThrottler.cs b/Dalamud/Interface/Internal/SharableTextures/TextureLoadThrottler.cs new file mode 100644 index 000000000..65fee34d6 --- /dev/null +++ b/Dalamud/Interface/Internal/SharableTextures/TextureLoadThrottler.cs @@ -0,0 +1,178 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace Dalamud.Interface.Internal.SharableTextures; + +/// +/// Service for managing texture loads. +/// +[ServiceManager.EarlyLoadedService] +internal class TextureLoadThrottler : IServiceType +{ + private readonly List workList = new(); + private readonly List activeWorkList = new(); + + [ServiceManager.ServiceConstructor] + private TextureLoadThrottler() => + this.MaxActiveWorkItems = Math.Min(64, Environment.ProcessorCount); + + /// + /// Basis for throttling. + /// + internal interface IThrottleBasisProvider + { + /// + /// Gets a value indicating whether the resource is requested in an opportunistic way. + /// + bool IsOpportunistic { get; } + + /// + /// Gets the first requested tick count from . + /// + long FirstRequestedTick { get; } + + /// + /// Gets the latest requested tick count from . + /// + long LatestRequestedTick { get; } + } + + private int MaxActiveWorkItems { get; } + + /// + /// Creates a texture loader. + /// + /// The throttle basis. + /// The immediate load function. + /// The cancellation token. + /// The task. + public Task CreateLoader( + IThrottleBasisProvider basis, + Func> immediateLoadFunction, + CancellationToken cancellationToken) + { + var work = new WorkItem + { + TaskCompletionSource = new(), + Basis = basis, + CancellationToken = cancellationToken, + ImmediateLoadFunction = immediateLoadFunction, + }; + + _ = Task.Run( + () => + { + lock (this.workList) + { + this.workList.Add(work); + if (this.activeWorkList.Count >= this.MaxActiveWorkItems) + return; + } + + this.ContinueWork(); + }, + default); + + return work.TaskCompletionSource.Task; + } + + private void ContinueWork() + { + WorkItem minWork; + lock (this.workList) + { + if (this.workList.Count == 0) + return; + + if (this.activeWorkList.Count >= this.MaxActiveWorkItems) + return; + + var minIndex = 0; + for (var i = 1; i < this.workList.Count; i++) + { + if (this.workList[i].CompareTo(this.workList[minIndex]) < 0) + minIndex = i; + } + + minWork = this.workList[minIndex]; + // Avoid shifting; relocate the element to remove to the last + if (minIndex != this.workList.Count - 1) + (this.workList[^1], this.workList[minIndex]) = (this.workList[minIndex], this.workList[^1]); + this.workList.RemoveAt(this.workList.Count - 1); + + this.activeWorkList.Add(minWork); + } + + try + { + minWork.CancellationToken.ThrowIfCancellationRequested(); + minWork.InnerTask = minWork.ImmediateLoadFunction(minWork.CancellationToken); + } + catch (Exception e) + { + minWork.InnerTask = Task.FromException(e); + } + + minWork.InnerTask.ContinueWith( + r => + { + // Swallow exception, if any + _ = r.Exception; + + lock (this.workList) + this.activeWorkList.Remove(minWork); + if (r.IsCompletedSuccessfully) + minWork.TaskCompletionSource.SetResult(r.Result); + else if (r.Exception is not null) + minWork.TaskCompletionSource.SetException(r.Exception); + else if (r.IsCanceled) + minWork.TaskCompletionSource.SetCanceled(); + else + minWork.TaskCompletionSource.SetException(new Exception("??")); + this.ContinueWork(); + }); + } + + /// + /// A read-only implementation of . + /// + public class ReadOnlyThrottleBasisProvider : IThrottleBasisProvider + { + /// + public bool IsOpportunistic { get; init; } = false; + + /// + public long FirstRequestedTick { get; init; } = Environment.TickCount64; + + /// + public long LatestRequestedTick { get; init; } = Environment.TickCount64; + } + + [SuppressMessage( + "StyleCop.CSharp.OrderingRules", + "SA1206:Declaration keywords should follow order", + Justification = "no")] + private record WorkItem : IComparable + { + public required TaskCompletionSource TaskCompletionSource { get; init; } + + public required IThrottleBasisProvider Basis { get; init; } + + public required CancellationToken CancellationToken { get; init; } + + public required Func> ImmediateLoadFunction { get; init; } + + public Task? InnerTask { get; set; } + + public int CompareTo(WorkItem other) + { + if (this.Basis.IsOpportunistic != other.Basis.IsOpportunistic) + return this.Basis.IsOpportunistic ? 1 : -1; + if (this.Basis.IsOpportunistic) + return -this.Basis.LatestRequestedTick.CompareTo(other.Basis.LatestRequestedTick); + return this.Basis.FirstRequestedTick.CompareTo(other.Basis.FirstRequestedTick); + } + } +} diff --git a/Dalamud/Interface/Internal/TextureManager.cs b/Dalamud/Interface/Internal/TextureManager.cs index d1ab16a1d..0e6686025 100644 --- a/Dalamud/Interface/Internal/TextureManager.cs +++ b/Dalamud/Interface/Internal/TextureManager.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Threading; using System.Threading.Tasks; using BitFaster.Caching.Lru; @@ -56,6 +57,9 @@ internal sealed class TextureManager : IServiceType, IDisposable, ITextureProvid [ServiceManager.ServiceDependency] private readonly InterfaceManager interfaceManager = Service.Get(); + [ServiceManager.ServiceDependency] + private readonly TextureLoadThrottler textureLoadThrottler = Service.Get(); + private readonly ConcurrentLru lookupToPath = new(PathLookupLruCount); private readonly ConcurrentDictionary gamePathTextures = new(); private readonly ConcurrentDictionary fileSystemTextures = new(); @@ -148,13 +152,14 @@ internal sealed class TextureManager : IServiceType, IDisposable, ITextureProvid [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] [Obsolete("See interface definition.")] public IDalamudTextureWrap? GetTextureFromGame(string path, bool keepAlive = false) => - this.gamePathTextures.GetOrAdd(path, CreateGamePathSharableTexture).GetAvailableOnAccessWrapForApi9(); + this.gamePathTextures.GetOrAdd(path, GamePathSharableTexture.CreateImmediate) + .GetAvailableOnAccessWrapForApi9(); /// [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] [Obsolete("See interface definition.")] public IDalamudTextureWrap? GetTextureFromFile(FileInfo file, bool keepAlive = false) => - this.fileSystemTextures.GetOrAdd(file.FullName, CreateFileSystemSharableTexture) + this.fileSystemTextures.GetOrAdd(file.FullName, FileSystemSharableTexture.CreateImmediate) .GetAvailableOnAccessWrapForApi9(); #pragma warning restore CS0618 // Type or member is obsolete @@ -191,7 +196,7 @@ internal sealed class TextureManager : IServiceType, IDisposable, ITextureProvid out Exception? exception) { ThreadSafety.AssertMainThread(); - var t = this.gamePathTextures.GetOrAdd(path, CreateGamePathSharableTexture); + var t = this.gamePathTextures.GetOrAdd(path, GamePathSharableTexture.CreateImmediate); texture = t.GetImmediate(); exception = t.UnderlyingWrap?.Exception; return texture is not null; @@ -204,41 +209,64 @@ internal sealed class TextureManager : IServiceType, IDisposable, ITextureProvid out Exception? exception) { ThreadSafety.AssertMainThread(); - var t = this.fileSystemTextures.GetOrAdd(file, CreateFileSystemSharableTexture); + var t = this.fileSystemTextures.GetOrAdd(file, FileSystemSharableTexture.CreateImmediate); texture = t.GetImmediate(); exception = t.UnderlyingWrap?.Exception; return texture is not null; } /// - public Task GetFromGameIconAsync(in GameIconLookup lookup) => - this.GetFromGameAsync(this.lookupToPath.GetOrAdd(lookup, this.GetIconPathByValue)); + public Task GetFromGameIconAsync( + in GameIconLookup lookup, + CancellationToken cancellationToken = default) => + this.GetFromGameAsync(this.lookupToPath.GetOrAdd(lookup, this.GetIconPathByValue), cancellationToken); /// - public Task GetFromGameAsync(string path) => - this.gamePathTextures.GetOrAdd(path, CreateGamePathSharableTexture).CreateNewReference(); + public Task GetFromGameAsync( + string path, + CancellationToken cancellationToken = default) => + this.gamePathTextures.GetOrAdd(path, GamePathSharableTexture.CreateAsync) + .CreateNewReference(cancellationToken); /// - public Task GetFromFileAsync(string file) => - this.fileSystemTextures.GetOrAdd(file, CreateFileSystemSharableTexture).CreateNewReference(); + public Task GetFromFileAsync( + string file, + CancellationToken cancellationToken = default) => + this.fileSystemTextures.GetOrAdd(file, FileSystemSharableTexture.CreateAsync) + .CreateNewReference(cancellationToken); /// - public Task GetFromImageAsync(ReadOnlyMemory bytes) => - Task.Run( - () => this.interfaceManager.LoadImage(bytes.ToArray()) - ?? throw new("Failed to load image because of an unknown reason.")); + public Task GetFromImageAsync( + ReadOnlyMemory bytes, + CancellationToken cancellationToken = default) => + this.textureLoadThrottler.CreateLoader( + new TextureLoadThrottler.ReadOnlyThrottleBasisProvider(), + _ => Task.FromResult( + this.interfaceManager.LoadImage(bytes.ToArray()) + ?? throw new("Failed to load image because of an unknown reason.")), + cancellationToken); /// - public async Task GetFromImageAsync(Stream stream, bool leaveOpen = false) - { - await using var streamDispose = leaveOpen ? null : stream; - await using var ms = stream.CanSeek ? new MemoryStream((int)stream.Length) : new(); - await stream.CopyToAsync(ms).ConfigureAwait(false); - return await this.GetFromImageAsync(ms.GetBuffer()); - } + public Task GetFromImageAsync( + Stream stream, + bool leaveOpen = false, + CancellationToken cancellationToken = default) => + this.textureLoadThrottler.CreateLoader( + new TextureLoadThrottler.ReadOnlyThrottleBasisProvider(), + async ct => + { + await using var streamDispose = leaveOpen ? null : stream; + await using var ms = stream.CanSeek ? new MemoryStream((int)stream.Length) : new(); + await stream.CopyToAsync(ms, ct).ConfigureAwait(false); + return await this.GetFromImageAsync(ms.GetBuffer(), ct); + }, + cancellationToken); /// - public IDalamudTextureWrap GetFromRaw(RawImageSpecification specs, ReadOnlySpan bytes) => + public IDalamudTextureWrap GetFromRaw( + RawImageSpecification specs, + ReadOnlySpan bytes, + CancellationToken cancellationToken = default) => this.interfaceManager.LoadImageFromDxgiFormat( bytes, specs.Pitch, @@ -247,23 +275,47 @@ internal sealed class TextureManager : IServiceType, IDisposable, ITextureProvid (SharpDX.DXGI.Format)specs.DxgiFormat); /// - public Task GetFromRawAsync(RawImageSpecification specs, ReadOnlyMemory bytes) => - Task.Run(() => this.GetFromRaw(specs, bytes.Span)); + public Task GetFromRawAsync( + RawImageSpecification specs, + ReadOnlyMemory bytes, + CancellationToken cancellationToken = default) => + this.textureLoadThrottler.CreateLoader( + new TextureLoadThrottler.ReadOnlyThrottleBasisProvider(), + ct => Task.FromResult(this.GetFromRaw(specs, bytes.Span, ct)), + cancellationToken); /// - public async Task GetFromRawAsync( + public Task GetFromRawAsync( RawImageSpecification specs, Stream stream, - bool leaveOpen = false) - { - await using var streamDispose = leaveOpen ? null : stream; - await using var ms = stream.CanSeek ? new MemoryStream((int)stream.Length) : new(); - await stream.CopyToAsync(ms).ConfigureAwait(false); - return await this.GetFromRawAsync(specs, ms.GetBuffer()); - } + bool leaveOpen = false, + CancellationToken cancellationToken = default) => + this.textureLoadThrottler.CreateLoader( + new TextureLoadThrottler.ReadOnlyThrottleBasisProvider(), + async ct => + { + await using var streamDispose = leaveOpen ? null : stream; + await using var ms = stream.CanSeek ? new MemoryStream((int)stream.Length) : new(); + await stream.CopyToAsync(ms, ct).ConfigureAwait(false); + return await this.GetFromRawAsync(specs, ms.GetBuffer(), ct); + }, + cancellationToken); /// - public IDalamudTextureWrap GetTexture(TexFile file) => this.interfaceManager.LoadImageFromTexFile(file); + public IDalamudTextureWrap GetTexture(TexFile file) => this.GetFromTexFileAsync(file).Result; + + /// + public Task GetFromTexFileAsync( + TexFile file, + CancellationToken cancellationToken = default) => + this.textureLoadThrottler.CreateLoader( + new TextureLoadThrottler.ReadOnlyThrottleBasisProvider(), + _ => Task.FromResult(this.interfaceManager.LoadImageFromTexFile(file)), + cancellationToken); + + /// + public bool SupportsDxgiFormat(int dxgiFormat) => + this.interfaceManager.SupportsDxgiFormat((SharpDX.DXGI.Format)dxgiFormat); /// public bool TryGetIconPath(in GameIconLookup lookup, out string path) @@ -376,12 +428,6 @@ internal sealed class TextureManager : IServiceType, IDisposable, ITextureProvid } } - private static SharableTexture CreateGamePathSharableTexture(string gamePath) => - new GamePathSharableTexture(gamePath); - - private static SharableTexture CreateFileSystemSharableTexture(string fileSystemPath) => - new FileSystemSharableTexture(fileSystemPath); - private static string FormatIconPath(uint iconId, string? type, bool highResolution) { var format = highResolution ? HighResolutionIconFileFormat : IconFileFormat; diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/IconBrowserWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/IconBrowserWidget.cs index f9886dd2c..4a5bd89cf 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/IconBrowserWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/IconBrowserWidget.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -using System.Linq; using System.Numerics; +using System.Threading.Tasks; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; @@ -14,15 +14,12 @@ namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// public class IconBrowserWidget : IDataWindowWidget { - // Remove range 170,000 -> 180,000 by default, this specific range causes exceptions. - private readonly HashSet nullValues = Enumerable.Range(170000, 9999).ToHashSet(); - private Vector2 iconSize = new(64.0f, 64.0f); private Vector2 editIconSize = new(64.0f, 64.0f); - private List valueRange = Enumerable.Range(0, 200000).ToList(); + private List? valueRange; + private Task>? iconIdsTask; - private int lastNullValueCount; private int startRange; private int stopRange = 200000; private bool showTooltipImage; @@ -48,25 +45,51 @@ public class IconBrowserWidget : IDataWindowWidget /// public void Draw() { + this.iconIdsTask ??= Task.Run( + () => + { + var texm = Service.Get(); + + var result = new List<(int ItemId, string Path)>(200000); + for (var iconId = 0; iconId < 200000; iconId++) + { + // // Remove range 170,000 -> 180,000 by default, this specific range causes exceptions. + // if (iconId is >= 170000 and < 180000) + // continue; + if (!texm.TryGetIconPath(new((uint)iconId), out var path)) + continue; + result.Add((iconId, path)); + } + + return result; + }); + this.DrawOptions(); - if (ImGui.BeginChild("ScrollableSection", ImGui.GetContentRegionAvail(), false, ImGuiWindowFlags.NoMove)) + if (!this.iconIdsTask.IsCompleted) { - var itemsPerRow = (int)MathF.Floor( - ImGui.GetContentRegionMax().X / (this.iconSize.X + ImGui.GetStyle().ItemSpacing.X)); - var itemHeight = this.iconSize.Y + ImGui.GetStyle().ItemSpacing.Y; - - ImGuiClip.ClippedDraw(this.valueRange, this.DrawIcon, itemsPerRow, itemHeight); + ImGui.TextUnformatted("Loading..."); } - - ImGui.EndChild(); - - this.ProcessMouseDragging(); - - if (this.lastNullValueCount != this.nullValues.Count) + else if (!this.iconIdsTask.IsCompletedSuccessfully) + { + ImGui.TextUnformatted(this.iconIdsTask.Exception?.ToString() ?? "Unknown error"); + } + else { this.RecalculateIndexRange(); - this.lastNullValueCount = this.nullValues.Count; + + if (ImGui.BeginChild("ScrollableSection", ImGui.GetContentRegionAvail(), false, ImGuiWindowFlags.NoMove)) + { + var itemsPerRow = (int)MathF.Floor( + ImGui.GetContentRegionMax().X / (this.iconSize.X + ImGui.GetStyle().ItemSpacing.X)); + var itemHeight = this.iconSize.Y + ImGui.GetStyle().ItemSpacing.Y; + + ImGuiClip.ClippedDraw(this.valueRange!, this.DrawIcon, itemsPerRow, itemHeight); + } + + ImGui.EndChild(); + + this.ProcessMouseDragging(); } } @@ -92,11 +115,13 @@ public class IconBrowserWidget : IDataWindowWidget ImGui.Columns(2); ImGui.PushItemWidth(ImGui.GetContentRegionAvail().X); - if (ImGui.InputInt("##StartRange", ref this.startRange, 0, 0)) this.RecalculateIndexRange(); + if (ImGui.InputInt("##StartRange", ref this.startRange, 0, 0)) + this.valueRange = null; ImGui.NextColumn(); ImGui.PushItemWidth(ImGui.GetContentRegionAvail().X); - if (ImGui.InputInt("##StopRange", ref this.stopRange, 0, 0)) this.RecalculateIndexRange(); + if (ImGui.InputInt("##StopRange", ref this.stopRange, 0, 0)) + this.valueRange = null; ImGui.NextColumn(); ImGui.Checkbox("Show Image in Tooltip", ref this.showTooltipImage); @@ -114,40 +139,32 @@ public class IconBrowserWidget : IDataWindowWidget private void DrawIcon(int iconId) { var texm = Service.Get(); - try + var cursor = ImGui.GetCursorScreenPos(); + + if (texm.ImmediateTryGetFromGameIcon(new((uint)iconId), out var texture, out var exc)) { - var cursor = ImGui.GetCursorScreenPos(); + ImGui.Image(texture.ImGuiHandle, this.iconSize); - if (texm.ImmediateTryGetFromGameIcon(new((uint)iconId), out var texture, out var exc)) + // If we have the option to show a tooltip image, draw the image, but make sure it's not too big. + if (ImGui.IsItemHovered() && this.showTooltipImage) { - ImGui.Image(texture.ImGuiHandle, this.iconSize); + ImGui.BeginTooltip(); - // If we have the option to show a tooltip image, draw the image, but make sure it's not too big. - if (ImGui.IsItemHovered() && this.showTooltipImage) - { - ImGui.BeginTooltip(); + var scale = GetImageScaleFactor(texture); - var scale = GetImageScaleFactor(texture); + var textSize = ImGui.CalcTextSize(iconId.ToString()); + ImGui.SetCursorPosX( + texture.Size.X * scale / 2.0f - textSize.X / 2.0f + ImGui.GetStyle().FramePadding.X * 2.0f); + ImGui.Text(iconId.ToString()); - var textSize = ImGui.CalcTextSize(iconId.ToString()); - ImGui.SetCursorPosX( - texture.Size.X * scale / 2.0f - textSize.X / 2.0f + ImGui.GetStyle().FramePadding.X * 2.0f); - ImGui.Text(iconId.ToString()); - - ImGui.Image(texture.ImGuiHandle, texture.Size * scale); - ImGui.EndTooltip(); - } - - // else, just draw the iconId. - else if (ImGui.IsItemHovered()) - { - ImGui.SetTooltip(iconId.ToString()); - } + ImGui.Image(texture.ImGuiHandle, texture.Size * scale); + ImGui.EndTooltip(); } - else if (exc is not null) + + // else, just draw the iconId. + else if (ImGui.IsItemHovered()) { - // This texture failed to load; draw nothing, and prevent from trying to show it again. - this.nullValues.Add(iconId); + ImGui.SetTooltip(iconId.ToString()); } ImGui.GetWindowDrawList().AddRect( @@ -155,10 +172,46 @@ public class IconBrowserWidget : IDataWindowWidget cursor + this.iconSize, ImGui.GetColorU32(ImGuiColors.DalamudWhite)); } - catch (Exception) + else if (exc is not null) { - // If something went wrong, prevent from trying to show this icon again. - this.nullValues.Add(iconId); + ImGui.Dummy(this.iconSize); + using (Service.Get().IconFontHandle?.Push()) + { + var iconText = FontAwesomeIcon.Ban.ToIconString(); + var textSize = ImGui.CalcTextSize(iconText); + ImGui.GetWindowDrawList().AddText( + cursor + ((this.iconSize - textSize) / 2), + ImGui.GetColorU32(ImGuiColors.DalamudRed), + iconText); + } + + if (ImGui.IsItemHovered()) + ImGui.SetTooltip($"{iconId}\n{exc}".Replace("%", "%%")); + + ImGui.GetWindowDrawList().AddRect( + cursor, + cursor + this.iconSize, + ImGui.GetColorU32(ImGuiColors.DalamudRed)); + } + else + { + const uint color = 0x50FFFFFFu; + const string text = "..."; + + ImGui.Dummy(this.iconSize); + var textSize = ImGui.CalcTextSize(text); + ImGui.GetWindowDrawList().AddText( + cursor + ((this.iconSize - textSize) / 2), + color, + text); + + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(iconId.ToString()); + + ImGui.GetWindowDrawList().AddRect( + cursor, + cursor + this.iconSize, + color); } } @@ -195,14 +248,14 @@ public class IconBrowserWidget : IDataWindowWidget private void RecalculateIndexRange() { - if (this.stopRange <= this.startRange || this.stopRange <= 0 || this.startRange < 0) + if (this.valueRange is not null) + return; + + this.valueRange = new(); + foreach (var (id, _) in this.iconIdsTask!.Result) { - this.valueRange = new List(); - } - else - { - this.valueRange = Enumerable.Range(this.startRange, this.stopRange - this.startRange).ToList(); - this.valueRange.RemoveAll(value => this.nullValues.Contains(value)); + if (this.startRange <= id && id < this.stopRange) + this.valueRange.Add(id); } } } diff --git a/Dalamud/Plugin/Services/ITextureProvider.cs b/Dalamud/Plugin/Services/ITextureProvider.cs index 8441ca3dc..c314d7392 100644 --- a/Dalamud/Plugin/Services/ITextureProvider.cs +++ b/Dalamud/Plugin/Services/ITextureProvider.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Threading; using System.Threading.Tasks; using Dalamud.Interface.Internal; @@ -36,7 +37,7 @@ public partial interface ITextureProvider /// If the file is unavailable, then the returned instance of will point to an /// empty texture instead. /// Thrown when called outside the UI thread. - public IDalamudTextureWrap ImmediateGetFromGameIcon(in GameIconLookup lookup); + IDalamudTextureWrap ImmediateGetFromGameIcon(in GameIconLookup lookup); /// Gets a texture from a file shipped as a part of the game resources for use with the current frame. /// @@ -47,7 +48,7 @@ public partial interface ITextureProvider /// If the file is unavailable, then the returned instance of will point to an /// empty texture instead. /// Thrown when called outside the UI thread. - public IDalamudTextureWrap ImmediateGetFromGame(string path); + IDalamudTextureWrap ImmediateGetFromGame(string path); /// Gets a texture from a file on the filesystem for use with the current frame. /// The filesystem path to a .tex, .atex, or an image file such as .png. @@ -57,7 +58,7 @@ public partial interface ITextureProvider /// If the file is unavailable, then the returned instance of will point to an /// empty texture instead. /// Thrown when called outside the UI thread. - public IDalamudTextureWrap ImmediateGetFromFile(string file); + IDalamudTextureWrap ImmediateGetFromFile(string file); /// Gets the corresponding game icon for use with the current frame. /// The icon specifier. @@ -68,7 +69,7 @@ public partial interface ITextureProvider /// still being loaded, or the load has failed. /// on the returned will be ignored. /// Thrown when called outside the UI thread. - public bool ImmediateTryGetFromGameIcon( + bool ImmediateTryGetFromGameIcon( in GameIconLookup lookup, [NotNullWhen(true)] out IDalamudTextureWrap? texture, out Exception? exception); @@ -83,7 +84,7 @@ public partial interface ITextureProvider /// still being loaded, or the load has failed. /// on the returned will be ignored. /// Thrown when called outside the UI thread. - public bool ImmediateTryGetFromGame( + bool ImmediateTryGetFromGame( string path, [NotNullWhen(true)] out IDalamudTextureWrap? texture, out Exception? exception); @@ -97,60 +98,90 @@ public partial interface ITextureProvider /// still being loaded, or the load has failed. /// on the returned will be ignored. /// Thrown when called outside the UI thread. - public bool ImmediateTryGetFromFile( + bool ImmediateTryGetFromFile( string file, [NotNullWhen(true)] out IDalamudTextureWrap? texture, out Exception? exception); /// Gets the corresponding game icon for use with the current frame. /// The icon specifier. + /// The cancellation token. /// A containing the loaded texture on success. Dispose after use. - public Task GetFromGameIconAsync(in GameIconLookup lookup); + Task GetFromGameIconAsync( + in GameIconLookup lookup, + CancellationToken cancellationToken = default); /// Gets a texture from a file shipped as a part of the game resources. /// The game-internal path to a .tex, .atex, or an image file such as .png. + /// The cancellation token. /// A containing the loaded texture on success. Dispose after use. - public Task GetFromGameAsync(string path); + Task GetFromGameAsync( + string path, + CancellationToken cancellationToken = default); /// Gets a texture from a file on the filesystem. /// The filesystem path to a .tex, .atex, or an image file such as .png. + /// The cancellation token. /// A containing the loaded texture on success. Dispose after use. - public Task GetFromFileAsync(string file); + Task GetFromFileAsync( + string file, + CancellationToken cancellationToken = default); /// Gets a texture from the given bytes, trying to interpret it as a .tex file or other well-known image /// files, such as .png. /// The bytes to load. + /// The cancellation token. /// A containing the loaded texture on success. Dispose after use. - public Task GetFromImageAsync(ReadOnlyMemory bytes); + Task GetFromImageAsync( + ReadOnlyMemory bytes, + CancellationToken cancellationToken = default); /// Gets a texture from the given stream, trying to interpret it as a .tex file or other well-known image /// files, such as .png. /// The stream to load data from. /// Whether to leave the stream open once the task completes, sucessfully or not. + /// The cancellation token. /// A containing the loaded texture on success. Dispose after use. - public Task GetFromImageAsync(Stream stream, bool leaveOpen = false); + /// will be closed or not only according to ; + /// is irrelevant in closing the stream. + Task GetFromImageAsync( + Stream stream, + bool leaveOpen = false, + CancellationToken cancellationToken = default); /// Gets a texture from the given bytes, interpreting it as a raw bitmap. /// The specifications for the raw bitmap. /// The bytes to load. + /// The cancellation token. /// The texture loaded from the supplied raw bitmap. Dispose after use. - public IDalamudTextureWrap GetFromRaw(RawImageSpecification specs, ReadOnlySpan bytes); + IDalamudTextureWrap GetFromRaw( + RawImageSpecification specs, + ReadOnlySpan bytes, + CancellationToken cancellationToken = default); /// Gets a texture from the given bytes, interpreting it as a raw bitmap. /// The specifications for the raw bitmap. /// The bytes to load. + /// The cancellation token. /// A containing the loaded texture on success. Dispose after use. - public Task GetFromRawAsync(RawImageSpecification specs, ReadOnlyMemory bytes); + Task GetFromRawAsync( + RawImageSpecification specs, + ReadOnlyMemory bytes, + CancellationToken cancellationToken = default); /// Gets a texture from the given stream, interpreting the read data as a raw bitmap. /// The specifications for the raw bitmap. /// The stream to load data from. /// Whether to leave the stream open once the task completes, sucessfully or not. + /// The cancellation token. /// A containing the loaded texture on success. Dispose after use. - public Task GetFromRawAsync( + /// will be closed or not only according to ; + /// is irrelevant in closing the stream. + Task GetFromRawAsync( RawImageSpecification specs, Stream stream, - bool leaveOpen = false); + bool leaveOpen = false, + CancellationToken cancellationToken = default); /// /// Get a path for a specific icon's .tex file. @@ -158,7 +189,7 @@ public partial interface ITextureProvider /// The icon lookup. /// The path to the icon. /// If a corresponding file could not be found. - public string GetIconPath(in GameIconLookup lookup); + string GetIconPath(in GameIconLookup lookup); /// /// Gets the path of an icon. @@ -166,12 +197,31 @@ public partial interface ITextureProvider /// The icon lookup. /// The resolved path. /// true if the corresponding file exists and has been set. - public bool TryGetIconPath(in GameIconLookup lookup, [NotNullWhen(true)] out string? path); + bool TryGetIconPath(in GameIconLookup lookup, [NotNullWhen(true)] out string? path); + + /// + /// Get a texture handle for the specified Lumina . + /// Alias for fetching from . + /// + /// The texture to obtain a handle to. + /// A texture wrap that can be used to render the texture. Dispose after use. + IDalamudTextureWrap GetTexture(TexFile file); /// /// Get a texture handle for the specified Lumina . /// /// The texture to obtain a handle to. + /// The cancellation token. /// A texture wrap that can be used to render the texture. Dispose after use. - public IDalamudTextureWrap GetTexture(TexFile file); + Task GetFromTexFileAsync( + TexFile file, + CancellationToken cancellationToken = default); + + /// + /// Determines whether the system supports the given DXGI format. + /// For use with . + /// + /// The DXGI format. + /// true if supported. + bool SupportsDxgiFormat(int dxgiFormat); }