diff --git a/EmoTracker.Data/ApplicationSettings.cs b/EmoTracker.Data/ApplicationSettings.cs index c5b1772..92aaf25 100644 --- a/EmoTracker.Data/ApplicationSettings.cs +++ b/EmoTracker.Data/ApplicationSettings.cs @@ -99,6 +99,7 @@ public bool FastToolTips string mLastActivePackageVariant; string mCommandLinePackage; string mCommandLinePackageVariant; + bool mNoAsyncImages; ObservableCollection mPackageRepositories = new ObservableCollection(); @@ -180,6 +181,17 @@ public string CommandLinePackageVariant set { SetProperty(ref mCommandLinePackageVariant, value); } } + /// + /// When true, disables async background image pre-caching and forces + /// synchronous image resolution on the UI thread (the pre-refactor + /// behavior). Set via the --no-async-images command-line flag. + /// + public bool NoAsyncImages + { + get { return mNoAsyncImages; } + set { SetProperty(ref mNoAsyncImages, value); } + } + public string TwitchChannelName { get { return mTwitchChannelName; } @@ -317,6 +329,11 @@ private void LoadSettings() } + if (String.Equals(cargs[n], "--no-async-images")) + { + NoAsyncImages = true; + } + } } catch diff --git a/EmoTracker.Data/ItemDatabase.cs b/EmoTracker.Data/ItemDatabase.cs index 536e8d0..a81bed8 100644 --- a/EmoTracker.Data/ItemDatabase.cs +++ b/EmoTracker.Data/ItemDatabase.cs @@ -64,29 +64,26 @@ public bool IncrementalLoad(string path, IGamePackage package, bool bLegacy = fa { try { - LocationDatabase.Instance.SuspendRefresh = true; - - using (StreamReader reader = new StreamReader(package.Open(path))) + using (new LocationDatabase.SuspendRefreshScope()) { - JArray items = (JArray)JToken.ReadFrom(new JsonTextReader(reader)); - foreach (JObject item in items) + using (StreamReader reader = new StreamReader(package.Open(path))) { - ITrackableItem instance = ItemBase.CreateItem(item, package); - if (instance != null) - mItems.Add(instance); + JArray items = (JArray)JToken.ReadFrom(new JsonTextReader(reader)); + foreach (JObject item in items) + { + ITrackableItem instance = ItemBase.CreateItem(item, package); + if (instance != null) + mItems.Add(instance); + } } - } - bSuccess = true; + bSuccess = true; + } } catch (Exception e) { ScriptManager.Instance.OutputException(e); } - finally - { - LocationDatabase.Instance.SuspendRefresh = false; - } } return bSuccess; diff --git a/EmoTracker.Data/LocationDatabase.cs b/EmoTracker.Data/LocationDatabase.cs index 4458e55..b747a73 100644 --- a/EmoTracker.Data/LocationDatabase.cs +++ b/EmoTracker.Data/LocationDatabase.cs @@ -16,17 +16,14 @@ public class LocationDatabase : ObservableSingleton, ICodeProv { public class SuspendRefreshScope : IDisposable { - bool mbSuspend; - public SuspendRefreshScope() { - mbSuspend = LocationDatabase.Instance.SuspendRefresh; - LocationDatabase.Instance.SuspendRefresh = true; + LocationDatabase.Instance.PushSuspendRefresh(); } public virtual void Dispose() { - LocationDatabase.Instance.SuspendRefresh = mbSuspend; + LocationDatabase.Instance.PopSuspendRefresh(); } } @@ -39,13 +36,37 @@ public virtual void Dispose() public bool SuspendRefresh { - get { return mbSuspendRefresh; } + get { return mSuspendRefreshCount > 0; } set { - if (SetProperty(ref mbSuspendRefresh, value) && !mbSuspendRefresh) - { - RefeshAccessibility(bPendingOnly: true); - } + // Legacy compatibility: direct assignment is discouraged. + // Prefer SuspendRefreshScope for reentrant-safe scoping. + if (value) + PushSuspendRefresh(); + else + PopSuspendRefresh(); + } + } + + internal void PushSuspendRefresh() + { + ++mSuspendRefreshCount; + } + + internal void PopSuspendRefresh() + { + if (mSuspendRefreshCount <= 0) + { + ScriptManager.Instance.OutputError("PopSuspendRefresh called with no matching Push — possible over-close bug"); + System.Diagnostics.Debug.Fail("PopSuspendRefresh: underflow — more Pops than Pushes"); + return; + } + + --mSuspendRefreshCount; + + if (mSuspendRefreshCount == 0) + { + RefeshAccessibility(bPendingOnly: true); } } @@ -141,7 +162,7 @@ internal bool IncrementalLoad(string path, IGamePackage package, bool bLegacy = { try { - mbSuspendRefresh = true; + PushSuspendRefresh(); using (Stream s = package.Open(path)) { @@ -170,7 +191,7 @@ internal bool IncrementalLoad(string path, IGamePackage package, bool bLegacy = } finally { - mbSuspendRefresh = false; + PopSuspendRefresh(); } RefeshAccessibility(); @@ -279,13 +300,13 @@ public void UnpinLocation(Location location) mPinnedLocations.Remove(location); } - bool mbSuspendRefresh = false; + int mSuspendRefreshCount = 0; bool mbInRefresh = false; uint mPendingRefreshCount = 0; internal void RefeshAccessibility(bool bPendingOnly = false) { - if (!mbSuspendRefresh) + if (mSuspendRefreshCount == 0) { if (!bPendingOnly) ++mPendingRefreshCount; @@ -595,10 +616,9 @@ internal void Save(JObject root) internal bool Load(JObject root) { + PushSuspendRefresh(); try { - SuspendRefresh = true; - JObject locationDatabaseData = root.GetValue("location_database"); if (locationDatabaseData == null) return true; @@ -665,7 +685,7 @@ internal bool Load(JObject root) } finally { - SuspendRefresh = false; + PopSuspendRefresh(); } } diff --git a/EmoTracker.Data/Media/ImageReference.cs b/EmoTracker.Data/Media/ImageReference.cs index 1c8b1df..c54f863 100644 --- a/EmoTracker.Data/Media/ImageReference.cs +++ b/EmoTracker.Data/Media/ImageReference.cs @@ -1,112 +1,324 @@ -using EmoTracker.Core; -using System; -using System.Collections.Generic; - -namespace EmoTracker.Data.Media -{ - public abstract class ImageReference : ObservableObject - { - static bool sTracking; - static List sTrackedReferences; - - public static List LastCollectedReferences { get; private set; } - - public static void BeginTrackingCreatedReferences() - { - sTrackedReferences = new List(); - sTracking = true; - } - - public static List EndTrackingCreatedReferences() - { - sTracking = false; - LastCollectedReferences = sTrackedReferences; - var result = sTrackedReferences; - sTrackedReferences = null; - return result; - } - - public static ImageReference FromPackRelativePath(string path, string filter = null) - { - return FromPackRelativePath(Tracker.Instance.ActiveGamePackage, path, filter); - } - - public static ImageReference FromPackRelativePath(IGamePackage package, string path, string filter = null) - { - if (string.IsNullOrWhiteSpace(path)) - return null; - - path = path.Trim(); - path = path.TrimStart(',', '/', '\\'); - - if (package == null || !package.Exists(path)) - return null; - - if (!path.StartsWith("gamepackage://")) - { - path = "gamepackage://" + path; - } - - var result = new ConcreteImageReference() - { - URI = new Uri(path), - Filter = filter - }; - - if (sTracking) - sTrackedReferences?.Add(result); - - return result; - } - - public static ImageReference FromImageReference(ImageReference existingReference, string filter = null) - { - if (existingReference == null) - return null; - - if (string.IsNullOrWhiteSpace(filter)) - return existingReference; - - var result = new FilterImageReference() - { - Reference = existingReference, - Filter = filter - }; - - if (sTracking) - sTrackedReferences?.Add(result); - - return result; - } - - public static ImageReference FromExternalURI(Uri uri, string filter = null) - { - return new ConcreteImageReference() - { - URI = uri, - Filter = filter - }; - } - - public static ImageReference FromLayeredImageReferences(params ImageReference[] layers) - { - LayeredImageReference instance = new LayeredImageReference(); - - foreach (ImageReference layer in layers) - { - if (layer != null) - instance.Layers.Add(layer); - } - - if (instance.Layers.Count > 0) - { - if (sTracking) - sTrackedReferences?.Add(instance); - - return instance; - } - - return null; - } - } -} +using EmoTracker.Core; +using System; +using System.Collections.Generic; +using System.IO; + +namespace EmoTracker.Data.Media +{ + public abstract class ImageReference : ObservableObject + { + /// + /// The resolved display-ready image for this reference. Set by the image + /// resolution service on the UI thread once background generation completes. + /// XAML bindings should bind to Icon.ResolvedImage (etc.) so that the + /// UI updates automatically when the image becomes available. + /// + object mResolvedImage; + public object ResolvedImage + { + get { return mResolvedImage; } + set { SetProperty(ref mResolvedImage, value); } + } + + /// + /// Source image width in pixels, read from the image header at creation time. + /// Used to create correctly-sized placeholder images so Avalonia's layout + /// system can measure controls before the real image is resolved. + /// Zero if dimensions could not be determined. + /// + public int SourceWidth { get; set; } + + /// + /// Source image height in pixels. See . + /// + public int SourceHeight { get; set; } + + /// + /// Optional callback invoked whenever a new ImageReference is created via + /// a factory method. Set by the image resolution service at startup so + /// that newly-created references are automatically queued for background + /// resolution. + /// + public static Action OnImageReferenceCreated { get; set; } + + static void NotifyCreated(ImageReference imageRef) + { + OnImageReferenceCreated?.Invoke(imageRef); + } + + /// + /// Reads the width and height from a PNG or BMP image stream header + /// without fully decoding the image. Returns (0, 0) if the format + /// is not recognised or the stream is too short. + /// + internal static (int width, int height) ReadImageDimensions(Stream stream) + { + if (stream == null || !stream.CanRead) + return (0, 0); + + try + { + byte[] header = new byte[26]; + int bytesRead = 0; + while (bytesRead < header.Length) + { + int n = stream.Read(header, bytesRead, header.Length - bytesRead); + if (n == 0) break; + bytesRead += n; + } + + // PNG: signature(8) + IHDR length(4) + "IHDR"(4) + width(4) + height(4) + if (bytesRead >= 24 && + header[0] == 137 && header[1] == 80 && header[2] == 78 && header[3] == 71) + { + int w = (header[16] << 24) | (header[17] << 16) | (header[18] << 8) | header[19]; + int h = (header[20] << 24) | (header[21] << 16) | (header[22] << 8) | header[23]; + return (w, h); + } + + // BMP: "BM" + filesize(4) + reserved(4) + offset(4) + headersize(4) + width(4) + height(4) + if (bytesRead >= 26 && header[0] == (byte)'B' && header[1] == (byte)'M') + { + int w = header[18] | (header[19] << 8) | (header[20] << 16) | (header[21] << 24); + int h = header[22] | (header[23] << 8) | (header[24] << 16) | (header[25] << 24); + if (h < 0) h = -h; // top-down BMP uses negative height + return (w, h); + } + + // JPEG: SOI marker (0xFF 0xD8), then scan for SOF0/SOF2 frame marker + if (bytesRead >= 2 && header[0] == 0xFF && header[1] == 0xD8) + { + return ReadJpegDimensions(stream, header, bytesRead); + } + } + catch + { + // Swallow – dimensions are best-effort + } + + return (0, 0); + } + + /// + /// Scans JPEG markers to find a Start-Of-Frame (SOF) segment and reads + /// the image dimensions from it. The stream position is after the initial + /// header bytes that were already read into . + /// + static (int width, int height) ReadJpegDimensions(Stream stream, byte[] header, int bytesRead) + { + // We've consumed the first 'bytesRead' bytes into header[]. + // Continue reading the marker stream from the current position. + // JPEG markers are 0xFF followed by a marker type byte. + // SOF markers: 0xC0 (baseline), 0xC1 (extended), 0xC2 (progressive). + // SOF payload: length(2) + precision(1) + height(2) + width(2) + + // First, process any remaining bytes in the header buffer starting + // after the 2-byte SOI marker. + int pos = 2; + byte[] buf = new byte[2]; + + while (true) + { + // Read marker: 0xFF + type + int b1, b2; + + if (pos + 1 < bytesRead) + { + b1 = header[pos]; + b2 = header[pos + 1]; + pos += 2; + } + else + { + b1 = stream.ReadByte(); + b2 = stream.ReadByte(); + } + + if (b1 < 0 || b2 < 0) + return (0, 0); // unexpected end of stream + + if (b1 != 0xFF) + return (0, 0); // not a valid marker + + // Skip padding 0xFF bytes + while (b2 == 0xFF) + { + b2 = stream.ReadByte(); + if (b2 < 0) return (0, 0); + } + + // SOF0 (0xC0), SOF1 (0xC1), SOF2 (0xC2) contain dimensions + if (b2 >= 0xC0 && b2 <= 0xC2) + { + // Read: length(2) + precision(1) + height(2) + width(2) + byte[] sof = new byte[7]; + int sofRead = 0; + while (sofRead < 7) + { + int n = stream.Read(sof, sofRead, 7 - sofRead); + if (n == 0) return (0, 0); + sofRead += n; + } + + int height = (sof[3] << 8) | sof[4]; + int width = (sof[5] << 8) | sof[6]; + return (width, height); + } + + // Not a SOF marker – skip this segment + // Read segment length (2 bytes, big-endian, includes the length bytes) + int len1 = stream.ReadByte(); + int len2 = stream.ReadByte(); + if (len1 < 0 || len2 < 0) return (0, 0); + + int segLen = (len1 << 8) | len2; + if (segLen < 2) return (0, 0); + + // Skip the rest of the segment + int toSkip = segLen - 2; + if (stream.CanSeek) + { + stream.Position += toSkip; + } + else + { + byte[] skipBuf = new byte[Math.Min(toSkip, 4096)]; + while (toSkip > 0) + { + int n = stream.Read(skipBuf, 0, Math.Min(toSkip, skipBuf.Length)); + if (n == 0) return (0, 0); + toSkip -= n; + } + } + + // Safety: don't scan forever + if (stream.CanSeek && stream.Position > 65536) + return (0, 0); + } + } + + /// + /// Reads image dimensions from the package for the given path and stores + /// them on the reference. The stream is opened, header is read, and + /// the stream is disposed immediately. + /// + static void PopulateDimensions(ImageReference result, IGamePackage package, string rawPath) + { + try + { + // rawPath is the gamepackage:// URI – extract the actual file path + string filePath = rawPath; + if (filePath.StartsWith("gamepackage://")) + filePath = filePath.Substring("gamepackage://".Length); + + using (Stream s = package.Open(filePath)) + { + if (s != null) + { + var (w, h) = ReadImageDimensions(s); + result.SourceWidth = w; + result.SourceHeight = h; + } + } + } + catch + { + // Dimensions are best-effort; leave as 0×0 + } + } + + public static ImageReference FromPackRelativePath(string path, string filter = null) + { + return FromPackRelativePath(Tracker.Instance.ActiveGamePackage, path, filter); + } + + public static ImageReference FromPackRelativePath(IGamePackage package, string path, string filter = null) + { + if (string.IsNullOrWhiteSpace(path)) + return null; + + path = path.Trim(); + path = path.TrimStart(',', '/', '\\'); + + if (package == null || !package.Exists(path)) + return null; + + if (!path.StartsWith("gamepackage://")) + { + path = "gamepackage://" + path; + } + + var result = new ConcreteImageReference() + { + URI = new Uri(path), + Filter = filter + }; + + PopulateDimensions(result, package, path); + NotifyCreated(result); + + return result; + } + + public static ImageReference FromImageReference(ImageReference existingReference, string filter = null) + { + if (existingReference == null) + return null; + + if (string.IsNullOrWhiteSpace(filter)) + return existingReference; + + var result = new FilterImageReference() + { + Reference = existingReference, + Filter = filter + }; + + // Filters don't change dimensions – inherit from the source + result.SourceWidth = existingReference.SourceWidth; + result.SourceHeight = existingReference.SourceHeight; + + NotifyCreated(result); + + return result; + } + + public static ImageReference FromExternalURI(Uri uri, string filter = null) + { + var result = new ConcreteImageReference() + { + URI = uri, + Filter = filter + }; + + NotifyCreated(result); + + return result; + } + + public static ImageReference FromLayeredImageReferences(params ImageReference[] layers) + { + LayeredImageReference instance = new LayeredImageReference(); + + foreach (ImageReference layer in layers) + { + if (layer != null) + instance.Layers.Add(layer); + } + + if (instance.Layers.Count > 0) + { + // Layered images composite at the first layer's dimensions + var firstLayer = instance.Layers[0]; + instance.SourceWidth = firstLayer.SourceWidth; + instance.SourceHeight = firstLayer.SourceHeight; + + NotifyCreated(instance); + + return instance; + } + + return null; + } + } +} diff --git a/EmoTracker.Data/Packages/Sources/ZipPackageSource.cs b/EmoTracker.Data/Packages/Sources/ZipPackageSource.cs index 83a62f5..f25ed88 100644 --- a/EmoTracker.Data/Packages/Sources/ZipPackageSource.cs +++ b/EmoTracker.Data/Packages/Sources/ZipPackageSource.cs @@ -7,6 +7,7 @@ namespace EmoTracker.Data.Packages public class ZipPackageSource : IGamePackageSource { private ZipArchive mArchive; + private readonly object mArchiveLock = new object(); private string mPath; public string ArchivePath @@ -22,10 +23,13 @@ public IEnumerable Files if (mArchive != null) { - foreach (ZipArchiveEntry entry in mArchive.Entries) + lock (mArchiveLock) { - if (!string.IsNullOrWhiteSpace(entry.FullName) && !entry.FullName.EndsWith("/")) - files.Add(entry.FullName); + foreach (ZipArchiveEntry entry in mArchive.Entries) + { + if (!string.IsNullOrWhiteSpace(entry.FullName) && !entry.FullName.EndsWith("/")) + files.Add(entry.FullName); + } } files.Sort(FileListSort); @@ -61,15 +65,22 @@ public Stream Open(string path) { if (mArchive != null && !string.IsNullOrWhiteSpace(path)) { - ZipArchiveEntry entry = mArchive.GetEntry(path); - if (entry != null) + // ZipArchive is NOT thread-safe for concurrent reads. The async + // image pre-cache worker resolves images on a background thread + // while the UI thread may also open streams (e.g. PopulateDimensions + // during pack load). Serialize all access to prevent corruption. + lock (mArchiveLock) { - using (Stream src = entry.Open()) + ZipArchiveEntry entry = mArchive.GetEntry(path); + if (entry != null) { - MemoryStream stream = new MemoryStream(); - src.CopyTo(stream); - stream.Seek(0, SeekOrigin.Begin); - return stream; + using (Stream src = entry.Open()) + { + MemoryStream stream = new MemoryStream(); + src.CopyTo(stream); + stream.Seek(0, SeekOrigin.Begin); + return stream; + } } } } diff --git a/EmoTracker.Data/ScriptManager.cs b/EmoTracker.Data/ScriptManager.cs index b62d9c0..a9d3f62 100644 --- a/EmoTracker.Data/ScriptManager.cs +++ b/EmoTracker.Data/ScriptManager.cs @@ -681,8 +681,6 @@ public IMemorySegment AddMemoryWatch(string name, ulong startAddress, ulong leng { using (new LocationDatabase.SuspendRefreshScope()) { - LocationDatabase.Instance.SuspendRefresh = true; - if (callback != null) { object[] results = callback.Call(segment); diff --git a/EmoTracker.Data/Scripting/LuaItem.cs b/EmoTracker.Data/Scripting/LuaItem.cs index 97171c0..4240dbe 100644 --- a/EmoTracker.Data/Scripting/LuaItem.cs +++ b/EmoTracker.Data/Scripting/LuaItem.cs @@ -183,15 +183,10 @@ public override void OnLeftClick() { if (OnLeftClickFunc != null) { - LocationDatabase.Instance.SuspendRefresh = true; - try + using (new LocationDatabase.SuspendRefreshScope()) { OnLeftClickFunc.Call(this); } - finally - { - LocationDatabase.Instance.SuspendRefresh = false; - } } } catch (Exception e) @@ -206,15 +201,10 @@ public override void OnRightClick() { if (OnRightClickFunc != null) { - LocationDatabase.Instance.SuspendRefresh = true; - try + using (new LocationDatabase.SuspendRefreshScope()) { OnRightClickFunc.Call(this); } - finally - { - LocationDatabase.Instance.SuspendRefresh = false; - } } } catch (Exception e) diff --git a/EmoTracker.Data/Tracker.cs b/EmoTracker.Data/Tracker.cs index 7d80d74..64590d4 100644 --- a/EmoTracker.Data/Tracker.cs +++ b/EmoTracker.Data/Tracker.cs @@ -483,8 +483,6 @@ public void Reload() LoadPackageSettings(); - ImageReference.BeginTrackingCreatedReferences(); - ScriptManager.Instance.Load(mActiveGamePackage); // Legacy loads - should this be contingent on a flag in the manifest @@ -497,8 +495,6 @@ public void Reload() } finally { - ImageReference.EndTrackingCreatedReferences(); - AccessibilityRule.ClearCaches(); mbReloadInProgress = false; diff --git a/EmoTracker.UI/Controls/InputMaskingImage.cs b/EmoTracker.UI/Controls/InputMaskingImage.cs index 9256ade..f281414 100644 --- a/EmoTracker.UI/Controls/InputMaskingImage.cs +++ b/EmoTracker.UI/Controls/InputMaskingImage.cs @@ -21,7 +21,11 @@ private bool HitTestAlphaMask(Avalonia.Point point) if (Source == null) return false; var maskEntry = IconUtility.GetAlphaMask(Source); - if (maskEntry == null) return true; + // No mask → treat as fully transparent (click-through). + // This is critical for the async image pipeline: placeholder images + // don't have alpha masks, and returning true here would make them + // block ALL input until the real image loads. + if (maskEntry == null) return false; var (mask, maskW, maskH) = maskEntry.Value; diff --git a/EmoTracker.UI/Converters/GamePackageConverters.cs b/EmoTracker.UI/Converters/GamePackageConverters.cs index 3890760..be53f88 100644 --- a/EmoTracker.UI/Converters/GamePackageConverters.cs +++ b/EmoTracker.UI/Converters/GamePackageConverters.cs @@ -38,7 +38,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn var game = PackageManager.Instance.FindGame(name); if (game != null) - return Media.ImageReferenceService.Instance.ResolveImageReference(game.Image); + return Media.ImageReferenceService.Instance.RequestImage(game.Image); return null; } diff --git a/EmoTracker.UI/Converters/ImageReferenceConverter.cs b/EmoTracker.UI/Converters/ImageReferenceConverter.cs index b099ec8..d49eaa5 100644 --- a/EmoTracker.UI/Converters/ImageReferenceConverter.cs +++ b/EmoTracker.UI/Converters/ImageReferenceConverter.cs @@ -1,23 +1,33 @@ -using EmoTracker.Core; -using EmoTracker.Data.Media; -using EmoTracker.UI.Media; -using System; -using System.Globalization; - -using Avalonia.Data.Converters; - -namespace EmoTracker.UI.Converters -{ - public class ImageReferenceConverter : Singleton, IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return ImageReferenceService.Instance.ResolveImageReference(value as ImageReference); - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} +using EmoTracker.Core; +using EmoTracker.Data.Media; +using EmoTracker.UI.Media; +using System; +using System.Globalization; + +using Avalonia.Data.Converters; + +namespace EmoTracker.UI.Converters +{ + public class ImageReferenceConverter : Singleton, IValueConverter + { + /// + /// Returns the cached image for the given , + /// or the placeholder if the image is still being resolved in the + /// background. Boosts the reference to immediate priority so it is + /// resolved as soon as possible. + /// + /// Note: This converter is used by bindings that have not yet been + /// migrated to the path-through pattern (e.g. {Binding Icon.ResolvedImage}). + /// Bindings using the path-through pattern do not need a converter. + /// + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return ImageReferenceService.Instance.RequestImage(value as ImageReference); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/EmoTracker.UI/EmoTracker.UI.csproj b/EmoTracker.UI/EmoTracker.UI.csproj index 985f5ff..5a2a4fa 100644 --- a/EmoTracker.UI/EmoTracker.UI.csproj +++ b/EmoTracker.UI/EmoTracker.UI.csproj @@ -7,6 +7,7 @@ EmoTracker.UI net8.0 false + true diff --git a/EmoTracker.UI/Media/ImageReferenceService.cs b/EmoTracker.UI/Media/ImageReferenceService.cs index 3585e62..5b1aa8a 100644 --- a/EmoTracker.UI/Media/ImageReferenceService.cs +++ b/EmoTracker.UI/Media/ImageReferenceService.cs @@ -6,45 +6,377 @@ using System.Collections.Generic; using System.Linq; using System.Threading; -using System.Threading.Tasks; using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Threading; namespace EmoTracker.UI.Media { + /// + /// Priority levels for image resolution work items. + /// Lower numeric value = higher priority. + /// + public enum ImagePriority + { + /// The UI is waiting to display this image right now. + Immediate = 0, + /// Standard pre-cache priority during pack load. + Normal = 100, + } + public class ImageReferenceService : ObservableSingleton { - ConcurrentDictionary mCache = new ConcurrentDictionary(); + // ── Cache ─────────────────────────────────────────────────────── + readonly ConcurrentDictionary mCache = new ConcurrentDictionary(); readonly object mResolutionLock = new object(); - CancellationTokenSource mPreCacheCts; + // ── Instance Tracking ─────────────────────────────────────────── + // Multiple distinct ImageReference objects can share the same equality + // key (e.g. 10 items that all use "items/small_key.png" each create + // their own ConcreteImageReference). Only one object per equality key + // enters the work queue, but ALL objects need their ResolvedImage set + // when resolution completes. This dictionary tracks every unresolved + // instance keyed by equality so PostResolvedImage can fan out to all. + readonly Dictionary> mPendingInstances = new Dictionary>(); + readonly object mInstancesLock = new object(); + + // ── Sized Placeholders ────────────────────────────────────────── + // Cache of transparent placeholder bitmaps keyed by (width, height). + // Shared across all references with the same source dimensions so + // we don't create thousands of identical bitmaps. + static readonly Dictionary<(int w, int h), IImage> sPlaceholderCache = new Dictionary<(int, int), IImage>(); + static readonly object sPlaceholderLock = new object(); + + /// + /// Returns a transparent placeholder image of the specified size. + /// Reuses cached instances for identical dimensions. + /// Falls back to 1×1 if width or height is zero. + /// + public static IImage GetPlaceholder(int width, int height) + { + if (width <= 0 || height <= 0) + width = height = 1; + + lock (sPlaceholderLock) + { + var key = (width, height); + if (sPlaceholderCache.TryGetValue(key, out IImage existing)) + return existing; + + var bmp = new WriteableBitmap( + new Avalonia.PixelSize(width, height), + new Avalonia.Vector(96, 96), + Avalonia.Platform.PixelFormat.Bgra8888, + AlphaFormat.Premul); + sPlaceholderCache[key] = bmp; + return bmp; + } + } + + /// + /// Returns a correctly-sized transparent placeholder for the given + /// image reference, using its + /// and to determine size. + /// + public static IImage GetPlaceholder(ImageReference imageRef) + { + if (imageRef == null) + return GetPlaceholder(1, 1); + return GetPlaceholder(imageRef.SourceWidth, imageRef.SourceHeight); + } + + // ── Priority Queue ────────────────────────────────────────────── + readonly object mQueueLock = new object(); + readonly SortedDictionary<(int Priority, long Order), ImageReference> mQueue = new SortedDictionary<(int, long), ImageReference>(); + readonly Dictionary mQueueIndex = new Dictionary(); + long mInsertionOrder; + readonly ManualResetEventSlim mQueueSignal = new ManualResetEventSlim(false); + + // ── Worker Thread ─────────────────────────────────────────────── + Thread mWorkerThread; + volatile bool mShutdown; + + /// + /// When true, resolves synchronously on the + /// calling thread (the pre-refactor behavior). The background worker + /// thread is not started. Set before calling . + /// + public bool SyncMode { get; set; } + + // ── Public API ────────────────────────────────────────────────── + + /// + /// Starts the background worker thread and wires up the + /// callback + /// so that newly-created references are automatically queued. + /// Call once at application startup. + /// + public void Start() + { + if (mWorkerThread != null) + return; + + mShutdown = false; + + if (SyncMode) + { + // In sync mode, resolve images immediately on creation so + // path-through bindings ({Binding Icon.ResolvedImage}) see + // the resolved image right away. + ImageReference.OnImageReferenceCreated = (imageRef) => + { + ResolveImageReference(imageRef); + }; + return; + } + + ImageReference.OnImageReferenceCreated = (imageRef) => + { + // Multiple ImageReference objects can share the same equality key + // (same URI + filter) while being distinct object instances (e.g. + // 10 items that use the same small-key icon each create their own + // ConcreteImageReference). If the image is already cached (first + // instance was resolved), set the result immediately. + IImage cached = GetCachedImage(imageRef); + if (cached != null) + { + imageRef.ResolvedImage = cached; + return; + } + + // Register this instance so that when resolution completes for + // any equal key, ALL instances get their ResolvedImage updated. + RegisterPendingInstance(imageRef); + + // Set a correctly-sized placeholder as ResolvedImage immediately + // so Avalonia's layout system measures controls at the right size + // before the real image is resolved on the background thread. + if (imageRef.ResolvedImage == null && + imageRef.SourceWidth > 0 && imageRef.SourceHeight > 0) + { + imageRef.ResolvedImage = GetPlaceholder(imageRef); + } + + QueueResolution(imageRef, ImagePriority.Normal); + }; + + mWorkerThread = new Thread(WorkerLoop) + { + Name = "ImageReferenceService Worker", + IsBackground = true, + Priority = ThreadPriority.BelowNormal + }; + mWorkerThread.Start(); + } + + /// + /// Signals the background worker to stop and waits for it to finish. + /// Call at application shutdown. + /// + public void Stop() + { + mShutdown = true; + mQueueSignal.Set(); + + ImageReference.OnImageReferenceCreated = null; + + // Don't block indefinitely – the thread is IsBackground so it + // will be torn down if the process exits. + mWorkerThread?.Join(2000); + mWorkerThread = null; + } + + /// + /// Clears all cached images and drains the work queue. + /// Called on pack unload so stale images are discarded. + /// public void ClearImageCache() { - mPreCacheCts?.Cancel(); - mPreCacheCts = null; + lock (mQueueLock) + { + mQueue.Clear(); + mQueueIndex.Clear(); + mQueueSignal.Reset(); + } mCache.Clear(); + + lock (mInstancesLock) + { + mPendingInstances.Clear(); + } + + // Clear the source image cache used by ConcreteImageReferenceResolver + ConcreteImageReferenceResolver.ClearSourceCache(); } + /// + /// Adds an to the background work queue + /// at the specified priority. If the reference is already queued at a + /// lower priority (higher numeric value), it is boosted. + /// Thread-safe; may be called from any thread. + /// + public void QueueResolution(ImageReference imageRef, ImagePriority priority) + { + if (imageRef == null) + return; + + // Already resolved – nothing to do. + if (mCache.ContainsKey(imageRef)) + return; + + int pri = (int)priority; + + lock (mQueueLock) + { + if (mQueueIndex.TryGetValue(imageRef, out var existing)) + { + if (pri >= existing.Priority) + return; // already at same or higher priority + + // Remove old entry and re-insert at higher priority + mQueue.Remove(existing); + mQueueIndex.Remove(imageRef); + } + + var key = (pri, Interlocked.Increment(ref mInsertionOrder)); + mQueue[key] = imageRef; + mQueueIndex[imageRef] = key; + mQueueSignal.Set(); + } + } + + /// + /// Returns the cached image for the given reference, or null + /// if it has not been resolved yet. + /// + public IImage GetCachedImage(ImageReference imageRef) + { + if (imageRef == null) + return null; + mCache.TryGetValue(imageRef, out IImage cached); + return cached; + } + + /// + /// Synchronously resolves an image reference. Used by composite + /// resolvers (Filter, Layered) that need resolved sub-images + /// during background resolution. Not intended for UI-thread callers – + /// those should read or call + /// instead. + /// public IImage ResolveImageReference(ImageReference imageRef) { if (imageRef == null) return null; - // Fast path: lock-free cache read + // Fast path: lock-free cache read. + // In sync mode, also set ResolvedImage on the requesting object so + // duplicate ImageReference instances (same URI+filter, different object) + // get the resolved image immediately. if (mCache.TryGetValue(imageRef, out IImage cachedSrc)) + { + if (SyncMode && imageRef.ResolvedImage as IImage != cachedSrc) + imageRef.ResolvedImage = cachedSrc; return cachedSrc; + } // Slow path: acquire lock for resolution + IImage result; lock (mResolutionLock) { - // Double-check after acquiring lock (background may have resolved it) + // Double-check after acquiring lock if (mCache.TryGetValue(imageRef, out cachedSrc)) + { + if (SyncMode && imageRef.ResolvedImage as IImage != cachedSrc) + imageRef.ResolvedImage = cachedSrc; return cachedSrc; + } + + result = ResolveAndCache(imageRef); + } + + // In sync mode, set ResolvedImage directly so path-through + // bindings see the image immediately. This is safe because + // sync-mode callers are on the UI thread. + if (result != null && SyncMode) + imageRef.ResolvedImage = result; + + return result; + } + + /// + /// Called by UI-thread code (converters, bindings) when an image is + /// needed for display. Returns the cached image if available, otherwise + /// boosts the reference to and + /// returns a correctly-sized placeholder. + /// + public IImage RequestImage(ImageReference imageRef) + { + if (imageRef == null) + return null; + + if (mCache.TryGetValue(imageRef, out IImage cached)) + return cached; + + if (SyncMode) + return ResolveImageReference(imageRef); + + QueueResolution(imageRef, ImagePriority.Immediate); + return GetPlaceholder(imageRef); + } + + /// + /// Returns the number of items currently in the background work queue. + /// + public int QueueCount + { + get { lock (mQueueLock) { return mQueue.Count; } } + } + + /// + /// Returns the number of resolved images in the cache. + /// + public int CacheCount => mCache.Count; - return ResolveAndCache(imageRef); + // ── Instance Tracking ─────────────────────────────────────────── + + /// + /// Registers an ImageReference object so that when an equal key is + /// resolved, this specific object's ResolvedImage gets set too. + /// + void RegisterPendingInstance(ImageReference imageRef) + { + lock (mInstancesLock) + { + if (!mPendingInstances.TryGetValue(imageRef, out var list)) + { + list = new List(); + mPendingInstances[imageRef] = list; + } + list.Add(imageRef); + } + } + + /// + /// Removes and returns all pending instances for the given equality key. + /// + List TakePendingInstances(ImageReference imageRef) + { + lock (mInstancesLock) + { + if (mPendingInstances.TryGetValue(imageRef, out var list)) + { + mPendingInstances.Remove(imageRef); + return list; + } + return null; } } + // ── Internal Resolution ───────────────────────────────────────── + IImage ResolveAndCache(ImageReference imageRef) { foreach (ImageReferenceResolver entry in TypedObjectRegistry.SupportRegistry) @@ -61,49 +393,122 @@ IImage ResolveAndCache(ImageReference imageRef) return null; } - public Task PreCacheImagesAsync(List refs) - { - mPreCacheCts?.Cancel(); + // ── Background Worker ─────────────────────────────────────────── - var cts = new CancellationTokenSource(); - mPreCacheCts = cts; - var token = cts.Token; + void WorkerLoop() + { + while (!mShutdown) + { + // Wait for work or shutdown signal + mQueueSignal.Wait(); - // Sort: ConcreteImageReference first (base images), then Filter, then Layered - // This avoids redundant recursive resolution during pre-cache - var sorted = refs - .OrderBy(r => r is ConcreteImageReference ? 0 : r is FilterImageReference ? 1 : 2) - .ToList(); + if (mShutdown) + break; - return Task.Run(() => - { - foreach (var imageRef in sorted) + // Process items until the queue is drained + while (TryDequeueNext(out var imageRef)) { - if (token.IsCancellationRequested) + if (mShutdown) break; - // Skip if already cached (lock-free read) + // Skip if already resolved (may have been resolved by a + // recursive call from a Filter/Layered resolver). if (mCache.ContainsKey(imageRef)) + { + PostResolvedImage(imageRef, mCache[imageRef]); continue; + } - lock (mResolutionLock) + try { - // Double-check after lock - if (mCache.ContainsKey(imageRef)) - continue; - - try - { - ResolveAndCache(imageRef); - } - catch + IImage resolved; + lock (mResolutionLock) { - // Swallow individual failures during pre-cache; - // they will surface if the UI requests them later. + // Double-check under lock + if (mCache.TryGetValue(imageRef, out resolved)) + { + PostResolvedImage(imageRef, resolved); + continue; + } + + resolved = ResolveAndCache(imageRef); } + + PostResolvedImage(imageRef, resolved); + } + catch + { + // Individual resolution failures are silently ignored; + // the UI will continue showing the placeholder. } } - }, token); + + // All Immediate-priority items have been resolved. Force + // the UI to re-measure layouts so controls that were sized + // for placeholders adopt the final image dimensions. + Dispatcher.UIThread.Post(() => + { + if (Avalonia.Application.Current?.ApplicationLifetime + is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow?.InvalidateMeasure(); + } + }, DispatcherPriority.Background); + } + } + + bool TryDequeueNext(out ImageReference imageRef) + { + lock (mQueueLock) + { + if (mQueue.Count == 0) + { + mQueueSignal.Reset(); + imageRef = null; + return false; + } + + // SortedDictionary enumerator yields items in key order + // (lowest priority value first, then lowest insertion order). + using (var enumerator = mQueue.GetEnumerator()) + { + enumerator.MoveNext(); + var key = enumerator.Current.Key; + imageRef = enumerator.Current.Value; + mQueue.Remove(key); + mQueueIndex.Remove(imageRef); + } + + return true; + } + } + + void PostResolvedImage(ImageReference imageRef, IImage resolved) + { + if (resolved == null) + return; + + // Collect ALL object instances that share this equality key. + // Only one instance enters the queue, but many may exist (e.g. + // 10 items using the same small-key icon). Every instance's + // ResolvedImage must be set so their bindings update. + var instances = TakePendingInstances(imageRef); + + // ResolvedImage must be set on the UI thread so that + // PropertyChanged fires there and Avalonia bindings update. + Dispatcher.UIThread.Post(() => + { + if (instances != null) + { + foreach (var instance in instances) + instance.ResolvedImage = resolved; + } + else + { + // Fallback: set on the specific object that was dequeued + imageRef.ResolvedImage = resolved; + } + }); } } } diff --git a/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs b/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs index e691b6a..fe3f7a4 100644 --- a/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs +++ b/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs @@ -1,43 +1,111 @@ -using EmoTracker.Data; -using EmoTracker.Data.Media; -using System; -using System.IO; - -using Avalonia.Media; - -namespace EmoTracker.UI.Media.Resolvers -{ - public class ConcreteImageReferenceResolver : ImageReferenceResolver - { - public override bool CanResolveReference(ImageReference imageRef) - { - return imageRef as ConcreteImageReference != null; - } - - public override IImage ResolveReference(ImageReference imageRef) - { - ConcreteImageReference concreteRef = imageRef as ConcreteImageReference; - if (concreteRef == null) - return null; - - if (concreteRef.URI == null) - return null; - - if (concreteRef.URI.Scheme.Equals("gamepackage", StringComparison.OrdinalIgnoreCase)) - { - if (Tracker.Instance.ActiveGamePackage == null) - return null; - - using (Stream s = Tracker.Instance.ActiveGamePackage.Open(string.Format("{0}{1}", Uri.UnescapeDataString(concreteRef.URI.Host), Uri.UnescapeDataString(concreteRef.URI.AbsolutePath)))) - { - if (s == null) - return null; - - return Utility.IconUtility.ApplyFilterSpecToImage(Tracker.Instance.ActiveGamePackage, Utility.IconUtility.GetImage(s), concreteRef.Filter); - } - } - - return Utility.IconUtility.GetImageRaw(concreteRef.URI); - } - } -} +using EmoTracker.Data; +using EmoTracker.Data.Media; +using System; +using System.Collections.Generic; +using System.IO; + +using Avalonia.Media; +using SkiaSharp; + +namespace EmoTracker.UI.Media.Resolvers +{ + public class ConcreteImageReferenceResolver : ImageReferenceResolver + { + /// + /// Cache of decoded base SKBitmaps keyed by the pack-relative file path. + /// Multiple instances that point to + /// the same source image but with different filters share the decoded + /// base bitmap. Cleared on pack unload via . + /// + static readonly Dictionary sSourceCache + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Clears the source image cache. Called by + /// on pack unload. + /// + public static void ClearSourceCache() + { + lock (sSourceCache) + { + foreach (var kvp in sSourceCache) + kvp.Value?.Dispose(); + sSourceCache.Clear(); + } + } + + public override bool CanResolveReference(ImageReference imageRef) + { + return imageRef as ConcreteImageReference != null; + } + + public override IImage ResolveReference(ImageReference imageRef) + { + ConcreteImageReference concreteRef = imageRef as ConcreteImageReference; + if (concreteRef == null) + return null; + + if (concreteRef.URI == null) + return null; + + if (concreteRef.URI.Scheme.Equals("gamepackage", StringComparison.OrdinalIgnoreCase)) + { + if (Tracker.Instance.ActiveGamePackage == null) + return null; + + string filePath = string.Format("{0}{1}", + Uri.UnescapeDataString(concreteRef.URI.Host), + Uri.UnescapeDataString(concreteRef.URI.AbsolutePath)); + + // Get the decoded base SKBitmap from cache, or decode it + SKBitmap baseSK = GetCachedSource(filePath); + if (baseSK == null) + { + using (Stream s = Tracker.Instance.ActiveGamePackage.Open(filePath)) + { + if (s == null) + return null; + + baseSK = Utility.IconUtility.DecodeSKBitmap(s); + } + + if (baseSK == null) + return null; + + PutCachedSource(filePath, baseSK); + } + + // Clone the base bitmap so filter operations don't mutate the + // cached original, then run the entire filter chain in SKBitmap + // space (no intermediate PNG round-trips). + SKBitmap working = baseSK.Copy(); + working = Utility.IconUtility.ApplyFilterSpecToSKBitmap( + Tracker.Instance.ActiveGamePackage, working, concreteRef.Filter); + + // Convert to Avalonia IImage once at the end, computing the + // alpha mask for InputMaskingImage hit-testing. + return Utility.IconUtility.FinalizeToAvalonia(working); + } + + return Utility.IconUtility.GetImageRaw(concreteRef.URI); + } + + static SKBitmap GetCachedSource(string filePath) + { + lock (sSourceCache) + { + if (sSourceCache.TryGetValue(filePath, out SKBitmap bmp)) + return bmp; + return null; + } + } + + static void PutCachedSource(string filePath, SKBitmap bmp) + { + lock (sSourceCache) + { + sSourceCache[filePath] = bmp; + } + } + } +} diff --git a/EmoTracker.UI/Media/Resolvers/FilterImageReferenceResolver.cs b/EmoTracker.UI/Media/Resolvers/FilterImageReferenceResolver.cs index 9f8960b..5940a6d 100644 --- a/EmoTracker.UI/Media/Resolvers/FilterImageReferenceResolver.cs +++ b/EmoTracker.UI/Media/Resolvers/FilterImageReferenceResolver.cs @@ -1,25 +1,38 @@ -using EmoTracker.Data; -using EmoTracker.Data.Media; - -using Avalonia.Media; - -namespace EmoTracker.UI.Media.Resolvers -{ - class FilterImageReferenceResolver : ImageReferenceResolver - { - public override bool CanResolveReference(ImageReference imageRef) - { - return imageRef as FilterImageReference != null; - } - - public override IImage ResolveReference(ImageReference imageRef) - { - FilterImageReference concreteRef = imageRef as FilterImageReference; - if (concreteRef == null) - return null; - - var baseImg = ImageReferenceService.Instance.ResolveImageReference(concreteRef.Reference); - return Utility.IconUtility.ApplyFilterSpecToImage(Tracker.Instance.ActiveGamePackage, baseImg, concreteRef.Filter); - } - } -} +using EmoTracker.Data; +using EmoTracker.Data.Media; + +using Avalonia.Media; +using SkiaSharp; + +namespace EmoTracker.UI.Media.Resolvers +{ + class FilterImageReferenceResolver : ImageReferenceResolver + { + public override bool CanResolveReference(ImageReference imageRef) + { + return imageRef as FilterImageReference != null; + } + + public override IImage ResolveReference(ImageReference imageRef) + { + FilterImageReference concreteRef = imageRef as FilterImageReference; + if (concreteRef == null) + return null; + + var baseImg = ImageReferenceService.Instance.ResolveImageReference(concreteRef.Reference); + if (baseImg == null) + return null; + + // Convert the resolved base IImage to SKBitmap, apply the filter + // chain entirely in SKBitmap space, then convert back once. + SKBitmap baseSK = Utility.IconUtility.ToSkBitmapForFilter(baseImg); + if (baseSK == null) + return Utility.IconUtility.ApplyFilterSpecToImage(Tracker.Instance.ActiveGamePackage, baseImg, concreteRef.Filter); + + baseSK = Utility.IconUtility.ApplyFilterSpecToSKBitmap( + Tracker.Instance.ActiveGamePackage, baseSK, concreteRef.Filter); + + return Utility.IconUtility.FinalizeToAvalonia(baseSK); + } + } +} diff --git a/EmoTracker.UI/Media/Resolvers/LayeredImageReferenceResolver.cs b/EmoTracker.UI/Media/Resolvers/LayeredImageReferenceResolver.cs index 86f5906..8b14494 100644 --- a/EmoTracker.UI/Media/Resolvers/LayeredImageReferenceResolver.cs +++ b/EmoTracker.UI/Media/Resolvers/LayeredImageReferenceResolver.cs @@ -1,34 +1,58 @@ -using EmoTracker.Data.Media; - -using Avalonia.Media; - -namespace EmoTracker.UI.Media.Resolvers -{ - class LayeredImageReferenceResolver : ImageReferenceResolver - { - public override bool CanResolveReference(ImageReference imageRef) - { - return imageRef as LayeredImageReference != null; - } - - public override IImage ResolveReference(ImageReference imageRef) - { - LayeredImageReference concreteRef = imageRef as LayeredImageReference; - if (concreteRef == null) - return null; - - if (concreteRef.Layers.Count == 0) - return null; - - IImage img = null; - foreach (ImageReference layerRef in concreteRef.Layers) - { - var layerImg = ImageReferenceService.Instance.ResolveImageReference(layerRef); - img = Utility.IconUtility.ApplyOverlayImage(img, layerImg); - } - - - return img; - } - } -} +using EmoTracker.Data.Media; + +using Avalonia.Media; +using SkiaSharp; + +namespace EmoTracker.UI.Media.Resolvers +{ + class LayeredImageReferenceResolver : ImageReferenceResolver + { + public override bool CanResolveReference(ImageReference imageRef) + { + return imageRef as LayeredImageReference != null; + } + + public override IImage ResolveReference(ImageReference imageRef) + { + LayeredImageReference concreteRef = imageRef as LayeredImageReference; + if (concreteRef == null) + return null; + + if (concreteRef.Layers.Count == 0) + return null; + + // Composite all layers in SKBitmap space — one PNG conversion at the end + // instead of N round-trips through PNG encode→decode per layer. + SKBitmap composite = null; + foreach (ImageReference layerRef in concreteRef.Layers) + { + var layerImg = ImageReferenceService.Instance.ResolveImageReference(layerRef); + if (layerImg == null) + continue; + + SKBitmap layerSK = Utility.IconUtility.ToSkBitmapForFilter(layerImg); + if (layerSK == null) + continue; + + if (composite == null) + { + composite = layerSK; + } + else + { + var prev = composite; + composite = Utility.IconUtility.ApplyOverlaySK(composite, layerSK); + // ApplyOverlaySK disposes overlay (layerSK) and may return a new + // bitmap; dispose the old composite if it changed. + if (composite != prev) + prev.Dispose(); + } + } + + if (composite == null) + return null; + + return Utility.IconUtility.FinalizeToAvalonia(composite); + } + } +} diff --git a/EmoTracker.UI/Media/Utility/IconUtility.cs b/EmoTracker.UI/Media/Utility/IconUtility.cs index 2fe95a1..aefd175 100644 --- a/EmoTracker.UI/Media/Utility/IconUtility.cs +++ b/EmoTracker.UI/Media/Utility/IconUtility.cs @@ -1,518 +1,738 @@ -#nullable enable annotations -using EmoTracker.Core; -using EmoTracker.Data; -using System; -using System.IO; -using System.Linq; - -using Avalonia.Media; -using Avalonia.Media.Imaging; -using SkiaSharp; -using System.Collections.Generic; - -namespace EmoTracker.UI.Media.Utility -{ - public class IconUtility : ObservableSingleton - { - private bool mbEnableDpiConversion = true; - - public bool EnableDpiConversion - { - get { return mbEnableDpiConversion; } - set { SetProperty(ref mbEnableDpiConversion, value); } - } - - public enum LuminanceMode - { - Avg, - Average, - Max, - Blue, - Green, - Red, - BT709, - BT601 - } - - public static string[] GetArgs(string filterCommand) - { - string[] args = filterCommand.Split(new char[] { '|' }, StringSplitOptions.RemoveEmptyEntries); - for (int i = 0; i < args.Length; ++i) - args[i] = args[i].Trim(); - return args; - } - - // ── Avalonia / SkiaSharp image pipeline ────────────────────────────────── - - // Alpha masks keyed by IImage: bool[] of length (width * height), true = opaque - private static readonly Dictionary sAlphaMasks = new(); - - // Cached Skia-encoded PNG bytes for each IImage produced by SkToAvalonia. - // Used by ToSkBitmap to bypass Avalonia's Bitmap.Save(Stream), which may strip the - // alpha channel on some platforms — causing overlay compositing to treat every pixel - // as fully opaque and completely hide the base layer. - private static readonly Dictionary sPngCache = new(); - - // HTTP/HTTPS image download cache. null value means "download in progress". - private static readonly System.Collections.Concurrent.ConcurrentDictionary sHttpCache = new(); - private static readonly System.Net.Http.HttpClient sHttpClient = new(); - - /// - /// Raised on the UI thread after an HTTP image finishes downloading. - /// Subscribers (e.g. ApplicationModel) can use this to refresh bindings. - /// - public static event EventHandler? HttpImageLoaded; - - /// Returns the precomputed alpha mask for an image, or null if not available. - public static (bool[] mask, int w, int h)? GetAlphaMask(IImage image) - { - if (image != null && sAlphaMasks.TryGetValue(image, out var entry)) - return entry; - return null; - } - - /// Convert an Avalonia IImage back to an SKBitmap for pixel processing. - /// - /// Uses the Skia-encoded PNG bytes cached by when available, - /// avoiding Bitmap.Save(Stream) which may drop the alpha channel on some platforms - /// (causing all pixels to appear fully opaque and breaking overlay compositing). - /// Always returns a Bgra8888 bitmap so downstream pixel loops can read and write - /// alpha correctly regardless of the source image's original colour type. - /// - private static SKBitmap ToSkBitmap(IImage image) - { - if (image is not Avalonia.Media.Imaging.Bitmap avBitmap) - return null; - try - { - Stream pngStream; - if (sPngCache.TryGetValue(avBitmap, out byte[] cachedBytes)) - { - // Use the exact bytes that Skia encoded — guaranteed to have correct alpha. - pngStream = new MemoryStream(cachedBytes, writable: false); - } - else - { - // Fallback for images not created by SkToAvalonia (e.g. from GetImageRaw). - var ms = new MemoryStream(); - avBitmap.Save(ms); - ms.Position = 0; - pngStream = ms; - } - - using (pngStream) - { - SKBitmap decoded = SKBitmap.Decode(pngStream); - if (decoded == null) return null; - - // Promote to Bgra8888/Premul so pixel operations can read and write alpha correctly. - // Rgb888x forces alpha to 255; AlphaType.Opaque causes SKImage.FromBitmap to encode - // as RGB PNG (no alpha channel), so transparent pixels from the color-key pass are - // lost when the image is round-tripped through sPngCache. - if (decoded.ColorType != SKColorType.Bgra8888 || decoded.AlphaType == SKAlphaType.Opaque) - { - var targetInfo = new SKImageInfo(decoded.Width, decoded.Height, SKColorType.Bgra8888, SKAlphaType.Premul); - SKBitmap promoted = new SKBitmap(targetInfo); - using (var cvs = new SKCanvas(promoted)) - { - cvs.Clear(SKColors.Transparent); - cvs.DrawBitmap(decoded, 0, 0); - } - decoded.Dispose(); - return promoted; - } - return decoded; - } - } - catch { return null; } - } - - /// Convert an SKBitmap to an Avalonia IImage, optionally caching its alpha mask. - private static IImage SkToAvalonia(SKBitmap bmp, bool storeMask = false) - { - using var skImg = SKImage.FromBitmap(bmp); - using var encoded = skImg.Encode(SKEncodedImageFormat.Png, 100); - byte[] pngBytes = encoded.ToArray(); - var avBitmap = new Avalonia.Media.Imaging.Bitmap(new MemoryStream(pngBytes)); - - // Always cache the raw PNG bytes so ToSkBitmap can round-trip back to SKBitmap - // without going through Bitmap.Save(Stream), which may strip the alpha channel. - sPngCache[avBitmap] = pngBytes; - - if (storeMask) - { - var mask = new bool[bmp.Width * bmp.Height]; - for (int y = 0; y < bmp.Height; y++) - for (int x = 0; x < bmp.Width; x++) - mask[y * bmp.Width + x] = bmp.GetPixel(x, y).Alpha >= 10; - sAlphaMasks[avBitmap] = (mask, bmp.Width, bmp.Height); - } - - return avBitmap; - } - - /// - /// Translates a WPF pack://application:,,,/AssemblyName;component/path URI - /// to the Avalonia equivalent avares://AssemblyName/path. - /// Returns the original URI unchanged for all other schemes. - /// - private static Uri TranslatePackUri(Uri uri) - { - const string packPrefix = "pack://application:,,,/"; - string orig = uri.OriginalString; - if (!orig.StartsWith(packPrefix, StringComparison.OrdinalIgnoreCase)) - return uri; - string rest = orig.Substring(packPrefix.Length); - int compIdx = rest.IndexOf(";component/", StringComparison.OrdinalIgnoreCase); - if (compIdx < 0) return uri; - string assembly = rest.Substring(0, compIdx); - string path = rest.Substring(compIdx + ";component".Length); // includes leading / - return new Uri($"avares://{assembly}{path}"); - } - - public static IImage GetImageRaw(Uri uri) - { - try - { - Uri resolved = TranslatePackUri(uri); - if (resolved.Scheme == "avares") - { - using var stream = Avalonia.Platform.AssetLoader.Open(resolved); - return new Avalonia.Media.Imaging.Bitmap(stream); - } - if (resolved.IsFile) - return new Avalonia.Media.Imaging.Bitmap(resolved.LocalPath); - if (resolved.Scheme == "http" || resolved.Scheme == "https") - return GetImageFromHttp(resolved); - return null; - } - catch { return null; } - } - - private static IImage? GetImageFromHttp(Uri uri) - { - string key = uri.AbsoluteUri; - if (sHttpCache.TryGetValue(key, out IImage? cached)) - return cached; // null if still loading, non-null if loaded - - // Mark as loading to avoid duplicate downloads - sHttpCache[key] = null; - - // Download in background; raise HttpImageLoaded when done so callers can refresh - _ = System.Threading.Tasks.Task.Run(async () => - { - try - { - byte[] bytes = await sHttpClient.GetByteArrayAsync(uri).ConfigureAwait(false); - using var ms = new System.IO.MemoryStream(bytes); - sHttpCache[key] = new Avalonia.Media.Imaging.Bitmap(ms); - } - catch - { - sHttpCache.TryRemove(key, out _); // allow retry on next call - } - await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => - HttpImageLoaded?.Invoke(null, EventArgs.Empty)); - }); - - return null; - } - - public static IImage GetImage(Uri uri) - { - try - { - Uri resolved = TranslatePackUri(uri); - if (resolved.Scheme == "avares") - { - using var stream = Avalonia.Platform.AssetLoader.Open(resolved); - return GetImage(stream); - } - if (resolved.IsFile) - { - using var stream = File.OpenRead(resolved.LocalPath); - return GetImage(stream); - } - return null; - } - catch { return null; } - } - - public static IImage GetImage(Stream stream) - { - if (stream == null) - return null; - try - { - SKBitmap decoded = SKBitmap.Decode(stream); - if (decoded == null) return null; - - // Promote to Bgra8888/Premul before the color-key pass. - // SKBitmap.Decode may return Rgb888x (no alpha channel, e.g. RGB PNG or 24-bit BMP). - // Even if the color type is already Bgra8888, AlphaType.Opaque causes SKImage.FromBitmap - // to encode as RGB PNG — dropping all alpha — so transparent pixels set by the color-key - // pass are lost when the image is round-tripped through sPngCache for filter operations. - SKBitmap bmp; - if (decoded.ColorType != SKColorType.Bgra8888 || decoded.AlphaType == SKAlphaType.Opaque) - { - var targetInfo = new SKImageInfo(decoded.Width, decoded.Height, SKColorType.Bgra8888, SKAlphaType.Premul); - bmp = new SKBitmap(targetInfo); - using (var cvs = new SKCanvas(bmp)) - { - cvs.Clear(SKColors.Transparent); - cvs.DrawBitmap(decoded, 0, 0); - } - decoded.Dispose(); - } - else - { - bmp = decoded; - } - - // Apply color key: magenta (R=255, G=0, B=255) → transparent - for (int y = 0; y < bmp.Height; y++) - { - for (int x = 0; x < bmp.Width; x++) - { - SKColor c = bmp.GetPixel(x, y); - if (c.Red == 255 && c.Green == 0 && c.Blue == 255) - bmp.SetPixel(x, y, SKColors.Transparent); - } - } - - var result = SkToAvalonia(bmp, storeMask: true); - bmp.Dispose(); - return result; - } - catch { return null; } - } - - public static IImage ApplyOverlayImage(IGamePackage package, IImage image, params string[] args) - { - if (package == null) - return image; - - if (args.Length >= 1) - { - IImage overlay = GetImage(package.Open(args[0])); - if (overlay != null) - return ApplyOverlayImage(image, overlay); - } - - return image; - } - - public static IImage ApplyOverlayImage(IImage image, IImage overlay) - { - if (overlay == null) return image; - if (image == null) return overlay; - - try - { - SKBitmap baseBmp = ToSkBitmap(image); - SKBitmap overlayBmp = ToSkBitmap(overlay); - - if (baseBmp == null) return image; - if (overlayBmp == null) { baseBmp.Dispose(); return image; } - - if (baseBmp.Width != overlayBmp.Width || baseBmp.Height != overlayBmp.Height) - { - baseBmp.Dispose(); - overlayBmp.Dispose(); - ScriptManager.Instance.OutputError("Not applying overlay to base image because dimensions don't match."); - return image; - } - - for (int y = 0; y < baseBmp.Height; y++) - { - for (int x = 0; x < baseBmp.Width; x++) - { - SKColor b = baseBmp.GetPixel(x, y); - SKColor o = overlayBmp.GetPixel(x, y); - - float alpha = o.Alpha / 255.0f; - float invAlpha = 1.0f - alpha; - - byte r = (byte)Math.Clamp((int)(o.Red * alpha + b.Red * invAlpha), 0, 255); - byte g = (byte)Math.Clamp((int)(o.Green * alpha + b.Green * invAlpha), 0, 255); - byte bl = (byte)Math.Clamp((int)(o.Blue * alpha + b.Blue * invAlpha), 0, 255); - byte a = Math.Max(b.Alpha, o.Alpha); - - baseBmp.SetPixel(x, y, new SKColor(r, g, bl, a)); - } - } - - overlayBmp.Dispose(); - var result = SkToAvalonia(baseBmp, storeMask: true); - baseBmp.Dispose(); - return result; - } - catch { return image; } - } - - private static byte Lerp(byte a, byte b, float factor) - { - if (factor <= 0.0f) return a; - if (factor >= 1.0f) return b; - return (byte)(((float)a * (1.0f - factor)) + ((float)b * factor) + 0.5f); - } - - public static IImage MakeImageGrayscale(IImage image, LuminanceMode mode = LuminanceMode.Average, float saturation = 0.0f) - { - if (image == null) return null; - - SKBitmap bmp = ToSkBitmap(image); - if (bmp == null) return image; - - for (int y = 0; y < bmp.Height; y++) - { - for (int x = 0; x < bmp.Width; x++) - { - SKColor c = bmp.GetPixel(x, y); - byte r = c.Red, g = c.Green, b = c.Blue; - byte ro = r, go = g, bo = b; - - switch (mode) - { - case LuminanceMode.Avg: - case LuminanceMode.Average: r = g = b = (byte)((r + g + b) / 3.0); break; - case LuminanceMode.Max: r = g = b = Math.Max(Math.Max(r, g), b); break; - case LuminanceMode.Blue: r = g = b; break; - case LuminanceMode.Green: b = r = g; break; - case LuminanceMode.Red: b = g = r; break; - case LuminanceMode.BT709: r = g = b = (byte)(((uint)r + (uint)r + (uint)b + (uint)g + (uint)g + (uint)g) / 6); break; - case LuminanceMode.BT601: r = g = b = (byte)(((uint)r + (uint)r + (uint)b + (uint)g + (uint)g + (uint)g) >> 3); break; - } - - b = Lerp(b, bo, saturation); - g = Lerp(g, go, saturation); - r = Lerp(r, ro, saturation); - - bmp.SetPixel(x, y, new SKColor(r, g, b, c.Alpha)); - } - } - - var result = SkToAvalonia(bmp, storeMask: true); - bmp.Dispose(); - return result; - } - - public static IImage AdjustSaturation(IGamePackage package, IImage image, params string[] args) - { - if (image == null) return null; - - if (args.Length >= 1) - { - float saturation; - if (float.TryParse(args[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out saturation)) - { - LuminanceMode mode = LuminanceMode.Average; - if (args.Length >= 2) - Enum.TryParse(args[1], true, out mode); - saturation = Math.Min(Math.Max(saturation, 0.0f), 1.0f); - return MakeImageGrayscale(image, mode, saturation); - } - } - - return image; - } - - public static IImage AdjustBrightness(IGamePackage package, IImage image, params string[] args) - { - if (image == null) return null; - - if (args.Length >= 1) - { - float brightness; - if (float.TryParse(args[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out brightness)) - { - brightness = Math.Max(brightness, 0.0f); - - SKBitmap bmp = ToSkBitmap(image); - if (bmp == null) return image; - - for (int y = 0; y < bmp.Height; y++) - { - for (int x = 0; x < bmp.Width; x++) - { - SKColor c = bmp.GetPixel(x, y); - byte r = (byte)Math.Clamp(c.Red * brightness, 0, 255); - byte g = (byte)Math.Clamp(c.Green * brightness, 0, 255); - byte b = (byte)Math.Clamp(c.Blue * brightness, 0, 255); - bmp.SetPixel(x, y, new SKColor(r, g, b, c.Alpha)); - } - } - - var result = SkToAvalonia(bmp, storeMask: true); - bmp.Dispose(); - return result; - } - } - - return image; - } - - public static IImage MakeImageDim(IImage image, int divisor = 2) - { - if (image == null) return null; - - SKBitmap bmp = ToSkBitmap(image); - if (bmp == null) return image; - - for (int y = 0; y < bmp.Height; y++) - { - for (int x = 0; x < bmp.Width; x++) - { - SKColor c = bmp.GetPixel(x, y); - byte r = (byte)(c.Red - c.Red / divisor); - byte g = (byte)(c.Green - c.Green / divisor); - byte b = (byte)(c.Blue - c.Blue / divisor); - bmp.SetPixel(x, y, new SKColor(r, g, b, c.Alpha)); - } - } - - var result = SkToAvalonia(bmp, storeMask: true); - bmp.Dispose(); - return result; - } - - public static IImage ApplyFilterSpecToImage(IGamePackage package, IImage image, string filterSpec) - { - if (image == null) - return null; - - if (!string.IsNullOrWhiteSpace(filterSpec)) - { - string[] mods = filterSpec.Split(','); - foreach (string modRaw in mods) - { - string[] tokens = GetArgs(modRaw); - if (tokens.Length >= 1) - { - string mod = tokens[0]; - string[] args = tokens.Skip(1).ToArray(); - - if (mod.StartsWith("grayscale", StringComparison.OrdinalIgnoreCase)) - image = IconUtility.MakeImageGrayscale(image); - else if (mod.StartsWith("dim", StringComparison.OrdinalIgnoreCase)) - image = IconUtility.MakeImageDim(image); - else if (mod.StartsWith("halfdim", StringComparison.OrdinalIgnoreCase)) - image = IconUtility.MakeImageDim(image, 4); - else if (mod.StartsWith("quarterdim", StringComparison.OrdinalIgnoreCase)) - image = IconUtility.MakeImageDim(image, 8); - else if (mod.StartsWith("brightness", StringComparison.OrdinalIgnoreCase)) - image = IconUtility.AdjustBrightness(package, image, args); - else if (mod.StartsWith("overlay", StringComparison.OrdinalIgnoreCase)) - image = IconUtility.ApplyOverlayImage(package, image, args); - else if (mod.StartsWith("saturation", StringComparison.OrdinalIgnoreCase)) - image = IconUtility.AdjustSaturation(package, image, args); - else if (mod.StartsWith("@disabled", StringComparison.OrdinalIgnoreCase)) - image = IconUtility.ApplyFilterSpecToImage(package, image, Tracker.Instance.DisabledImageFilterSpec); - } - } - } - - return image; - } - } -} +#nullable enable annotations +using EmoTracker.Core; +using EmoTracker.Data; +using System; +using System.IO; +using System.Linq; + +using Avalonia.Media; +using Avalonia.Media.Imaging; +using SkiaSharp; +using System.Collections.Generic; + +namespace EmoTracker.UI.Media.Utility +{ + public class IconUtility : ObservableSingleton + { + private bool mbEnableDpiConversion = true; + + public bool EnableDpiConversion + { + get { return mbEnableDpiConversion; } + set { SetProperty(ref mbEnableDpiConversion, value); } + } + + public enum LuminanceMode + { + Avg, + Average, + Max, + Blue, + Green, + Red, + BT709, + BT601 + } + + public static string[] GetArgs(string filterCommand) + { + string[] args = filterCommand.Split(new char[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < args.Length; ++i) + args[i] = args[i].Trim(); + return args; + } + + // ── Avalonia / SkiaSharp image pipeline ────────────────────────────────── + + // Alpha masks keyed by IImage: bool[] of length (width * height), true = opaque. + // ConcurrentDictionary because the background image worker writes masks while + // the UI thread reads them (InputMaskingImage.HitTest via GetAlphaMask). + private static readonly System.Collections.Concurrent.ConcurrentDictionary sAlphaMasks = new(); + + // Cached Skia-encoded PNG bytes for each IImage produced by SkToAvalonia. + // Used by ToSkBitmap to bypass Avalonia's Bitmap.Save(Stream), which may strip the + // alpha channel on some platforms — causing overlay compositing to treat every pixel + // as fully opaque and completely hide the base layer. + // ConcurrentDictionary because the background image worker writes entries while + // filter resolution may read them concurrently. + private static readonly System.Collections.Concurrent.ConcurrentDictionary sPngCache = new(); + + // HTTP/HTTPS image download cache. null value means "download in progress". + private static readonly System.Collections.Concurrent.ConcurrentDictionary sHttpCache = new(); + private static readonly System.Net.Http.HttpClient sHttpClient = new(); + + /// + /// Raised on the UI thread after an HTTP image finishes downloading. + /// Subscribers (e.g. ApplicationModel) can use this to refresh bindings. + /// + public static event EventHandler? HttpImageLoaded; + + /// Returns the precomputed alpha mask for an image, or null if not available. + public static (bool[] mask, int w, int h)? GetAlphaMask(IImage image) + { + if (image != null && sAlphaMasks.TryGetValue(image, out var entry)) + return entry; + return null; + } + + // ── SKBitmap decode / promote ─────────────────────────────────────────── + + /// + /// Decode a stream to an SKBitmap, promote to Bgra8888/Premul, and apply + /// the magenta colour-key. This is the internal entry point for the + /// SKBitmap filter pipeline — callers get an SKBitmap they can pass through + /// without any PNG round-trips. + /// + internal static SKBitmap DecodeSKBitmap(Stream stream) + { + if (stream == null) + return null; + try + { + SKBitmap decoded = SKBitmap.Decode(stream); + if (decoded == null) return null; + + // Promote to Bgra8888/Premul. + SKBitmap bmp; + if (decoded.ColorType != SKColorType.Bgra8888 || decoded.AlphaType == SKAlphaType.Opaque) + { + var targetInfo = new SKImageInfo(decoded.Width, decoded.Height, SKColorType.Bgra8888, SKAlphaType.Premul); + bmp = new SKBitmap(targetInfo); + using (var cvs = new SKCanvas(bmp)) + { + cvs.Clear(SKColors.Transparent); + cvs.DrawBitmap(decoded, 0, 0); + } + decoded.Dispose(); + } + else + { + bmp = decoded; + } + + // Apply color key: magenta (R=255, G=0, B=255) → transparent. + // Uses direct pixel buffer access for ~10-50× speedup over GetPixel/SetPixel. + ApplyColorKey(bmp); + + return bmp; + } + catch { return null; } + } + + /// + /// Replace magenta (255, 0, 255) pixels with transparent using direct pixel + /// buffer access. The bitmap must be Bgra8888. + /// + private static unsafe void ApplyColorKey(SKBitmap bmp) + { + int pixelCount = bmp.Width * bmp.Height; + uint* pixels = (uint*)bmp.GetPixels().ToPointer(); + + for (int i = 0; i < pixelCount; i++) + { + uint c = pixels[i]; + // BGRA8888 little-endian: B=byte0, G=byte1, R=byte2, A=byte3 + // As uint32: 0xAARRGGBB + byte blue = (byte)(c); + byte green = (byte)(c >> 8); + byte red = (byte)(c >> 16); + + if (red == 255 && green == 0 && blue == 255) + pixels[i] = 0; // fully transparent + } + } + + /// + /// Compute the alpha mask from an SKBitmap using direct pixel buffer access. + /// + private static unsafe bool[] ComputeAlphaMask(SKBitmap bmp) + { + int pixelCount = bmp.Width * bmp.Height; + var mask = new bool[pixelCount]; + uint* pixels = (uint*)bmp.GetPixels().ToPointer(); + + for (int i = 0; i < pixelCount; i++) + { + byte alpha = (byte)(pixels[i] >> 24); + mask[i] = alpha >= 10; + } + + return mask; + } + + // ── IImage ↔ SKBitmap conversion ──────────────────────────────────────── + + /// + /// Convert an Avalonia IImage to an SKBitmap for use in the filter pipeline. + /// Returns null if conversion fails. The caller owns the returned bitmap. + /// + internal static SKBitmap ToSkBitmapForFilter(IImage image) => ToSkBitmap(image); + + /// Convert an Avalonia IImage back to an SKBitmap for pixel processing. + /// + /// Uses the Skia-encoded PNG bytes cached by when available, + /// avoiding Bitmap.Save(Stream) which may drop the alpha channel on some platforms. + /// Always returns a Bgra8888 bitmap. + /// + private static SKBitmap ToSkBitmap(IImage image) + { + if (image is not Avalonia.Media.Imaging.Bitmap avBitmap) + return null; + try + { + Stream pngStream; + if (sPngCache.TryGetValue(avBitmap, out byte[] cachedBytes)) + { + pngStream = new MemoryStream(cachedBytes, writable: false); + } + else + { + var ms = new MemoryStream(); + avBitmap.Save(ms); + ms.Position = 0; + pngStream = ms; + } + + using (pngStream) + { + SKBitmap decoded = SKBitmap.Decode(pngStream); + if (decoded == null) return null; + + if (decoded.ColorType != SKColorType.Bgra8888 || decoded.AlphaType == SKAlphaType.Opaque) + { + var targetInfo = new SKImageInfo(decoded.Width, decoded.Height, SKColorType.Bgra8888, SKAlphaType.Premul); + SKBitmap promoted = new SKBitmap(targetInfo); + using (var cvs = new SKCanvas(promoted)) + { + cvs.Clear(SKColors.Transparent); + cvs.DrawBitmap(decoded, 0, 0); + } + decoded.Dispose(); + return promoted; + } + return decoded; + } + } + catch { return null; } + } + + /// + /// Convert an SKBitmap to an Avalonia IImage, computing and caching its + /// alpha mask for InputMaskingImage hit-testing. Disposes the input bitmap. + /// This is the single conversion point at the END of the SKBitmap pipeline. + /// + internal static IImage FinalizeToAvalonia(SKBitmap bmp) + { + if (bmp == null) return null; + try + { + var mask = ComputeAlphaMask(bmp); + var avBitmap = SkToAvaloniaCore(bmp); + sAlphaMasks[avBitmap] = (mask, bmp.Width, bmp.Height); + bmp.Dispose(); + return avBitmap; + } + catch + { + bmp?.Dispose(); + return null; + } + } + + /// Convert an SKBitmap to an Avalonia IImage, optionally caching its alpha mask. + private static IImage SkToAvalonia(SKBitmap bmp, bool storeMask = false) + { + var avBitmap = SkToAvaloniaCore(bmp); + + if (storeMask) + { + var mask = ComputeAlphaMask(bmp); + sAlphaMasks[avBitmap] = (mask, bmp.Width, bmp.Height); + } + + return avBitmap; + } + + /// + /// Core SKBitmap → Avalonia Bitmap conversion. Encodes as PNG and caches + /// the PNG bytes so ToSkBitmap can round-trip without Bitmap.Save. + /// + private static Avalonia.Media.Imaging.Bitmap SkToAvaloniaCore(SKBitmap bmp) + { + using var skImg = SKImage.FromBitmap(bmp); + using var encoded = skImg.Encode(SKEncodedImageFormat.Png, 100); + byte[] pngBytes = encoded.ToArray(); + var avBitmap = new Avalonia.Media.Imaging.Bitmap(new MemoryStream(pngBytes)); + + sPngCache[avBitmap] = pngBytes; + + return avBitmap; + } + + // ── SKBitmap-based filter operations ──────────────────────────────────── + // + // These operate entirely in SKBitmap space. Skia's colour-matrix filter + // runs natively (potentially SIMD-optimised) and is orders of magnitude + // faster than per-pixel GetPixel/SetPixel loops. + // + // The caller is responsible for disposing the INPUT bitmap if it differs + // from the returned bitmap (the ApplyFilterSpecToSKBitmap method does this + // automatically for chained filters). + + /// Apply a 4×5 colour matrix to an SKBitmap via Skia's native path. + private static SKBitmap ApplyColorMatrix(SKBitmap src, float[] matrix) + { + var result = new SKBitmap(src.Info); + using var canvas = new SKCanvas(result); + canvas.Clear(SKColors.Transparent); + using var filter = SKColorFilter.CreateColorMatrix(matrix); + using var paint = new SKPaint + { + ColorFilter = filter, + BlendMode = SKBlendMode.Src // overwrite destination, don't composite + }; + canvas.DrawBitmap(src, 0, 0, paint); + return result; + } + + /// + /// Build the 4×5 colour matrix for a grayscale+saturation operation. + /// When is 0 the result is fully desaturated; + /// when 1 the image is unchanged. + /// + private static float[] ComputeGrayscaleMatrix(LuminanceMode mode, float saturation) + { + // Luminance weights per mode — determines how RGB channels + // contribute to the grayscale value. + float wr, wg, wb; + switch (mode) + { + case LuminanceMode.Max: + // Max-luminance can't be expressed as a linear matrix. + // Fall back to equal weights as a reasonable approximation. + wr = wg = wb = 1f / 3f; + break; + case LuminanceMode.Blue: + wr = 0; wg = 0; wb = 1; + break; + case LuminanceMode.Green: + wr = 0; wg = 1; wb = 0; + break; + case LuminanceMode.Red: + wr = 1; wg = 0; wb = 0; + break; + case LuminanceMode.BT709: + // The original code uses (2R+3G+B)/6 which is closer to BT.601 + wr = 2f / 6f; wg = 3f / 6f; wb = 1f / 6f; + break; + case LuminanceMode.BT601: + // Original uses (2R+3G+B)>>3 — divides by 8, not 6. + // Weights intentionally sum to 0.75, producing a dimmer result. + wr = 2f / 8f; wg = 3f / 8f; wb = 1f / 8f; + break; + default: // Average, Avg + wr = wg = wb = 1f / 3f; + break; + } + + // Saturation matrix: lerp between grayscale and identity. + // out_r = r * (wr*(1-s) + s) + g * wg*(1-s) + b * wb*(1-s) + // (and analogously for g, b) + float s = saturation; + float inv = 1f - s; + + return new float[] + { + wr * inv + s, wg * inv, wb * inv, 0, 0, + wr * inv, wg * inv + s, wb * inv, 0, 0, + wr * inv, wg * inv, wb * inv + s, 0, 0, + 0, 0, 0, 1, 0 + }; + } + + internal static SKBitmap MakeGrayscaleSK(SKBitmap src, LuminanceMode mode = LuminanceMode.Average, float saturation = 0.0f) + { + return ApplyColorMatrix(src, ComputeGrayscaleMatrix(mode, saturation)); + } + + internal static SKBitmap MakeDimSK(SKBitmap src, int divisor = 2) + { + // Original: r = r - r/divisor → factor = 1 - 1/divisor + float factor = 1.0f - 1.0f / divisor; + float[] matrix = + { + factor, 0, 0, 0, 0, + 0, factor, 0, 0, 0, + 0, 0, factor, 0, 0, + 0, 0, 0, 1, 0 + }; + return ApplyColorMatrix(src, matrix); + } + + internal static SKBitmap AdjustBrightnessSK(SKBitmap src, float brightness) + { + float[] matrix = + { + brightness, 0, 0, 0, 0, + 0, brightness, 0, 0, 0, + 0, 0, brightness, 0, 0, + 0, 0, 0, 1, 0 + }; + return ApplyColorMatrix(src, matrix); + } + + /// + /// Composite an overlay bitmap on top of a base bitmap using Skia's + /// built-in SrcOver blend mode. Disposes the overlay; returns a new bitmap. + /// The base bitmap is NOT disposed (caller manages it). + /// + internal static SKBitmap ApplyOverlaySK(SKBitmap baseBmp, SKBitmap overlay) + { + if (overlay == null) return baseBmp; + if (baseBmp == null) return overlay; + + if (baseBmp.Width != overlay.Width || baseBmp.Height != overlay.Height) + { + ScriptManager.Instance.OutputError("Not applying overlay to base image because dimensions don't match."); + overlay.Dispose(); + return baseBmp; + } + + // Clone base and draw overlay on top with SrcOver blend + var result = baseBmp.Copy(); + using var canvas = new SKCanvas(result); + using var paint = new SKPaint { BlendMode = SKBlendMode.SrcOver }; + canvas.DrawBitmap(overlay, 0, 0, paint); + overlay.Dispose(); + return result; + } + + /// + /// Apply the full filter specification, staying in SKBitmap space for the + /// entire chain. Each filter step produces a new SKBitmap; intermediates + /// are disposed automatically. The INPUT bitmap is consumed (may be + /// disposed or returned). + /// + internal static SKBitmap ApplyFilterSpecToSKBitmap(IGamePackage package, SKBitmap bmp, string filterSpec) + { + if (bmp == null) + return null; + + if (!string.IsNullOrWhiteSpace(filterSpec)) + { + string[] mods = filterSpec.Split(','); + foreach (string modRaw in mods) + { + string[] tokens = GetArgs(modRaw); + if (tokens.Length >= 1) + { + string mod = tokens[0]; + string[] args = tokens.Skip(1).ToArray(); + + SKBitmap prev = bmp; + + if (mod.StartsWith("grayscale", StringComparison.OrdinalIgnoreCase)) + bmp = MakeGrayscaleSK(bmp); + else if (mod.StartsWith("dim", StringComparison.OrdinalIgnoreCase)) + bmp = MakeDimSK(bmp); + else if (mod.StartsWith("halfdim", StringComparison.OrdinalIgnoreCase)) + bmp = MakeDimSK(bmp, 4); + else if (mod.StartsWith("quarterdim", StringComparison.OrdinalIgnoreCase)) + bmp = MakeDimSK(bmp, 8); + else if (mod.StartsWith("brightness", StringComparison.OrdinalIgnoreCase)) + { + if (args.Length >= 1 && + float.TryParse(args[0], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out float brightness)) + { + brightness = Math.Max(brightness, 0.0f); + bmp = AdjustBrightnessSK(bmp, brightness); + } + } + else if (mod.StartsWith("overlay", StringComparison.OrdinalIgnoreCase)) + { + if (package != null && args.Length >= 1) + { + SKBitmap overlay = DecodeSKBitmap(package.Open(args[0])); + if (overlay != null) + bmp = ApplyOverlaySK(bmp, overlay); + } + } + else if (mod.StartsWith("saturation", StringComparison.OrdinalIgnoreCase)) + { + if (args.Length >= 1 && + float.TryParse(args[0], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out float saturation)) + { + LuminanceMode mode = LuminanceMode.Average; + if (args.Length >= 2) + Enum.TryParse(args[1], true, out mode); + saturation = Math.Min(Math.Max(saturation, 0.0f), 1.0f); + bmp = MakeGrayscaleSK(bmp, mode, saturation); + } + } + else if (mod.StartsWith("@disabled", StringComparison.OrdinalIgnoreCase)) + bmp = ApplyFilterSpecToSKBitmap(package, bmp, Tracker.Instance.DisabledImageFilterSpec); + + // Dispose the intermediate bitmap if a new one was produced + if (bmp != prev) + prev.Dispose(); + } + } + } + + return bmp; + } + + // ── Legacy IImage-based API (backward compat) ─────────────────────────── + + /// + /// Translates a WPF pack://application:,,,/AssemblyName;component/path URI + /// to the Avalonia equivalent avares://AssemblyName/path. + /// Returns the original URI unchanged for all other schemes. + /// + private static Uri TranslatePackUri(Uri uri) + { + const string packPrefix = "pack://application:,,,/"; + string orig = uri.OriginalString; + if (!orig.StartsWith(packPrefix, StringComparison.OrdinalIgnoreCase)) + return uri; + string rest = orig.Substring(packPrefix.Length); + int compIdx = rest.IndexOf(";component/", StringComparison.OrdinalIgnoreCase); + if (compIdx < 0) return uri; + string assembly = rest.Substring(0, compIdx); + string path = rest.Substring(compIdx + ";component".Length); // includes leading / + return new Uri($"avares://{assembly}{path}"); + } + + public static IImage GetImageRaw(Uri uri) + { + try + { + Uri resolved = TranslatePackUri(uri); + if (resolved.Scheme == "avares") + { + using var stream = Avalonia.Platform.AssetLoader.Open(resolved); + return new Avalonia.Media.Imaging.Bitmap(stream); + } + if (resolved.IsFile) + return new Avalonia.Media.Imaging.Bitmap(resolved.LocalPath); + if (resolved.Scheme == "http" || resolved.Scheme == "https") + return GetImageFromHttp(resolved); + return null; + } + catch { return null; } + } + + private static IImage? GetImageFromHttp(Uri uri) + { + string key = uri.AbsoluteUri; + if (sHttpCache.TryGetValue(key, out IImage? cached)) + return cached; // null if still loading, non-null if loaded + + sHttpCache[key] = null; + + _ = System.Threading.Tasks.Task.Run(async () => + { + try + { + byte[] bytes = await sHttpClient.GetByteArrayAsync(uri).ConfigureAwait(false); + using var ms = new System.IO.MemoryStream(bytes); + sHttpCache[key] = new Avalonia.Media.Imaging.Bitmap(ms); + } + catch + { + sHttpCache.TryRemove(key, out _); + } + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + HttpImageLoaded?.Invoke(null, EventArgs.Empty)); + }); + + return null; + } + + public static IImage GetImage(Uri uri) + { + try + { + Uri resolved = TranslatePackUri(uri); + if (resolved.Scheme == "avares") + { + using var stream = Avalonia.Platform.AssetLoader.Open(resolved); + return GetImage(stream); + } + if (resolved.IsFile) + { + using var stream = File.OpenRead(resolved.LocalPath); + return GetImage(stream); + } + return null; + } + catch { return null; } + } + + /// + /// Decode a stream to an Avalonia IImage with colour-key and alpha mask. + /// Kept for backward compatibility; new resolver code should prefer + /// + . + /// + public static IImage GetImage(Stream stream) + { + SKBitmap bmp = DecodeSKBitmap(stream); + if (bmp == null) return null; + + var result = SkToAvalonia(bmp, storeMask: true); + bmp.Dispose(); + return result; + } + + // ── Legacy IImage filter wrappers ─────────────────────────────────────── + // These are kept for any call sites that still pass IImage. They convert + // to SKBitmap, apply the fast SKBitmap filter, convert back. + + public static IImage ApplyOverlayImage(IGamePackage package, IImage image, params string[] args) + { + if (package == null) + return image; + + if (args.Length >= 1) + { + IImage overlay = GetImage(package.Open(args[0])); + if (overlay != null) + return ApplyOverlayImage(image, overlay); + } + + return image; + } + + public static IImage ApplyOverlayImage(IImage image, IImage overlay) + { + if (overlay == null) return image; + if (image == null) return overlay; + + try + { + SKBitmap baseBmp = ToSkBitmap(image); + SKBitmap overlayBmp = ToSkBitmap(overlay); + + if (baseBmp == null) return image; + if (overlayBmp == null) { baseBmp.Dispose(); return image; } + + var result = ApplyOverlaySK(baseBmp, overlayBmp); + + // overlayBmp disposed by ApplyOverlaySK; baseBmp is not + if (result != baseBmp) + baseBmp.Dispose(); + + var avResult = SkToAvalonia(result, storeMask: true); + result.Dispose(); + return avResult; + } + catch { return image; } + } + + public static IImage MakeImageGrayscale(IImage image, LuminanceMode mode = LuminanceMode.Average, float saturation = 0.0f) + { + if (image == null) return null; + SKBitmap bmp = ToSkBitmap(image); + if (bmp == null) return image; + SKBitmap result = MakeGrayscaleSK(bmp, mode, saturation); + bmp.Dispose(); + var avResult = SkToAvalonia(result, storeMask: true); + result.Dispose(); + return avResult; + } + + public static IImage AdjustSaturation(IGamePackage package, IImage image, params string[] args) + { + if (image == null) return null; + if (args.Length >= 1) + { + if (float.TryParse(args[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float saturation)) + { + LuminanceMode mode = LuminanceMode.Average; + if (args.Length >= 2) + Enum.TryParse(args[1], true, out mode); + saturation = Math.Min(Math.Max(saturation, 0.0f), 1.0f); + return MakeImageGrayscale(image, mode, saturation); + } + } + return image; + } + + public static IImage AdjustBrightness(IGamePackage package, IImage image, params string[] args) + { + if (image == null) return null; + if (args.Length >= 1) + { + if (float.TryParse(args[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float brightness)) + { + brightness = Math.Max(brightness, 0.0f); + SKBitmap bmp = ToSkBitmap(image); + if (bmp == null) return image; + SKBitmap result = AdjustBrightnessSK(bmp, brightness); + bmp.Dispose(); + var avResult = SkToAvalonia(result, storeMask: true); + result.Dispose(); + return avResult; + } + } + return image; + } + + public static IImage MakeImageDim(IImage image, int divisor = 2) + { + if (image == null) return null; + SKBitmap bmp = ToSkBitmap(image); + if (bmp == null) return image; + SKBitmap result = MakeDimSK(bmp, divisor); + bmp.Dispose(); + var avResult = SkToAvalonia(result, storeMask: true); + result.Dispose(); + return avResult; + } + + /// + /// Legacy IImage-based filter chain. Kept for backward compatibility. + /// Resolvers should prefer the SKBitmap pipeline. + /// + public static IImage ApplyFilterSpecToImage(IGamePackage package, IImage image, string filterSpec) + { + if (image == null) + return null; + + if (!string.IsNullOrWhiteSpace(filterSpec)) + { + string[] mods = filterSpec.Split(','); + foreach (string modRaw in mods) + { + string[] tokens = GetArgs(modRaw); + if (tokens.Length >= 1) + { + string mod = tokens[0]; + string[] args = tokens.Skip(1).ToArray(); + + if (mod.StartsWith("grayscale", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.MakeImageGrayscale(image); + else if (mod.StartsWith("dim", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.MakeImageDim(image); + else if (mod.StartsWith("halfdim", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.MakeImageDim(image, 4); + else if (mod.StartsWith("quarterdim", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.MakeImageDim(image, 8); + else if (mod.StartsWith("brightness", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.AdjustBrightness(package, image, args); + else if (mod.StartsWith("overlay", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.ApplyOverlayImage(package, image, args); + else if (mod.StartsWith("saturation", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.AdjustSaturation(package, image, args); + else if (mod.StartsWith("@disabled", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.ApplyFilterSpecToImage(package, image, Tracker.Instance.DisabledImageFilterSpec); + } + } + } + + return image; + } + } +} diff --git a/EmoTracker/App.axaml.cs b/EmoTracker/App.axaml.cs index 6f770ab..c9f217c 100644 --- a/EmoTracker/App.axaml.cs +++ b/EmoTracker/App.axaml.cs @@ -62,6 +62,7 @@ public override void OnFrameworkInitializationCompleted() { try { + UI.Media.ImageReferenceService.Instance.Stop(); UpdateService.Instance.Dispose(); if (e.ApplicationExitCode == 0) diff --git a/EmoTracker/ApplicationModel.cs b/EmoTracker/ApplicationModel.cs index 56beae5..17a8007 100644 --- a/EmoTracker/ApplicationModel.cs +++ b/EmoTracker/ApplicationModel.cs @@ -6,7 +6,6 @@ using EmoTracker.Data.JSON; using EmoTracker.Data.Layout; using EmoTracker.Data.Locations; -using EmoTracker.Data.Media; using EmoTracker.Data.Packages; using EmoTracker.Data.Scripting; using EmoTracker.Extensions; @@ -168,13 +167,31 @@ private void OnHttpImageLoaded(object? sender, EventArgs e) Avalonia.Threading.Dispatcher.UIThread.Post(() => { _httpRefreshScheduled = false; - EmoTracker.UI.Media.ImageReferenceService.Instance.ClearImageCache(); - NotifyPropertyChanged(nameof(AvailablePackagesGroupedView)); + + // Resolve game banner images into ResolvedImage so that + // bindings ({Binding Game.Image.ResolvedImage}) update. + // HTTP images bypass the ImageReferenceService pipeline + // (they download asynchronously into IconUtility.sHttpCache), + // so we bridge the two systems here. + foreach (var game in PackageManager.Instance.AvailableGames) + { + if (game.Image != null && game.Image.ResolvedImage == null) + { + var resolved = EmoTracker.UI.Media.ImageReferenceService.Instance.ResolveImageReference(game.Image); + if (resolved != null) + game.Image.ResolvedImage = resolved; + } + } }, Avalonia.Threading.DispatcherPriority.Background); } public void Initialize() { + // Start the image resolution service. When --no-async-images is + // set, resolution falls back to synchronous on-demand behaviour. + ImageReferenceService.Instance.SyncMode = Data.ApplicationSettings.Instance.NoAsyncImages; + ImageReferenceService.Instance.Start(); + // Load and start extensions Extensions.ExtensionManager.CreateInstance(); Extensions.ExtensionManager.Instance.Start(); @@ -699,11 +716,6 @@ private void Tracker_OnPackageLoadComplete(object sender, EventArgs e) OpenPackageDocumentationCommand.RaiseCanExecuteChanged(); - // Kick off background pre-caching of all image references collected during pack load - var collectedRefs = ImageReference.LastCollectedReferences; - if (collectedRefs != null && collectedRefs.Count > 0) - _ = ImageReferenceService.Instance.PreCacheImagesAsync(collectedRefs); - WindowService.Instance.FocusMainWindow(); } public void AcquireLayouts() @@ -860,7 +872,11 @@ public IEnumerable AvailablePackagesGroupedView var game = PackageManager.Instance.FindGame(e.Game); return game?.Name ?? e.Game; }) - .Select(g => new PackageGroup(g.Key, g)); + .Select(g => + { + var game = PackageManager.Instance.FindGame(g.Key); + return new PackageGroup(g.Key, g, game); + }); } } @@ -940,10 +956,12 @@ public class PackageGroup { public string Name { get; } public IEnumerable Items { get; } - public PackageGroup(string name, IEnumerable items) + public PackageManager.Game Game { get; } + public PackageGroup(string name, IEnumerable items, PackageManager.Game game = null) { Name = name; Items = items; + Game = game; } } diff --git a/EmoTracker/Extensions/McpServer/McpServerExtension.cs b/EmoTracker/Extensions/McpServer/McpServerExtension.cs index 30418db..5f480e3 100644 --- a/EmoTracker/Extensions/McpServer/McpServerExtension.cs +++ b/EmoTracker/Extensions/McpServer/McpServerExtension.cs @@ -116,6 +116,7 @@ private async Task StartServerAsync() .WithTools() .WithTools() .WithTools() + .WithTools() .WithHttpTransport(); mApp = builder.Build(); diff --git a/EmoTracker/Extensions/McpServer/Tools/ImageCacheTools.cs b/EmoTracker/Extensions/McpServer/Tools/ImageCacheTools.cs new file mode 100644 index 0000000..664e0f2 --- /dev/null +++ b/EmoTracker/Extensions/McpServer/Tools/ImageCacheTools.cs @@ -0,0 +1,29 @@ +using Avalonia.Threading; +using EmoTracker.UI.Media; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json; +using System.Threading.Tasks; + +namespace EmoTracker.Extensions.McpServer.Tools +{ + [McpServerToolType] + public class ImageCacheTools + { + [McpServerTool(Name = "get_image_queue_status")] + [Description("Get the current status of the async image resolution queue, including queue depth, cache size, and whether sync mode is active")] + public static async Task GetImageQueueStatus() + { + return await Dispatcher.UIThread.InvokeAsync(() => + { + var service = ImageReferenceService.Instance; + return JsonSerializer.Serialize(new + { + syncMode = service.SyncMode, + queueCount = service.QueueCount, + cacheCount = service.CacheCount + }); + }); + } + } +} diff --git a/EmoTracker/UI/CapturableItemControl.axaml b/EmoTracker/UI/CapturableItemControl.axaml index ccac496..23c0d2b 100644 --- a/EmoTracker/UI/CapturableItemControl.axaml +++ b/EmoTracker/UI/CapturableItemControl.axaml @@ -34,7 +34,7 @@ - @@ -46,7 +46,7 @@ - diff --git a/EmoTracker/UI/LayoutControl.axaml b/EmoTracker/UI/LayoutControl.axaml index 975a26b..f4ba294 100644 --- a/EmoTracker/UI/LayoutControl.axaml +++ b/EmoTracker/UI/LayoutControl.axaml @@ -305,7 +305,7 @@ - - + @@ -600,9 +600,9 @@ IsVisible="{Binding Style, Converter={x:Static converters:ObjectEqualsConverter.Instance}, ConverterParameter=Settings}" /> - - diff --git a/EmoTracker/UI/LocationControl.axaml b/EmoTracker/UI/LocationControl.axaml index fff7e0c..623196f 100644 --- a/EmoTracker/UI/LocationControl.axaml +++ b/EmoTracker/UI/LocationControl.axaml @@ -112,7 +112,7 @@ diff --git a/EmoTracker/UI/LocationMapControl.axaml b/EmoTracker/UI/LocationMapControl.axaml index 81ff31d..6f9c7a1 100644 --- a/EmoTracker/UI/LocationMapControl.axaml +++ b/EmoTracker/UI/LocationMapControl.axaml @@ -47,7 +47,7 @@ - @@ -70,7 +70,7 @@ + Source="{Binding Image.ResolvedImage, FallbackValue={x:Null}}"/> @@ -151,7 +151,7 @@ StretchDirection="Both"> + Source="{Binding ResolvedImage}" /> diff --git a/EmoTracker/UI/PackageManagerWindow.axaml b/EmoTracker/UI/PackageManagerWindow.axaml index 3bbd7f4..b9c223a 100644 --- a/EmoTracker/UI/PackageManagerWindow.axaml +++ b/EmoTracker/UI/PackageManagerWindow.axaml @@ -171,8 +171,7 @@ + Source="{Binding Game.Image.ResolvedImage, FallbackValue={x:Null}}"/> + Source="{Binding Icon.ResolvedImage, FallbackValue={x:Null}}"> diff --git a/EmoTracker/UI/TrackableItemControl.axaml.cs b/EmoTracker/UI/TrackableItemControl.axaml.cs index 3c0701d..ae5d66e 100644 --- a/EmoTracker/UI/TrackableItemControl.axaml.cs +++ b/EmoTracker/UI/TrackableItemControl.axaml.cs @@ -128,10 +128,8 @@ public void NotifyCanExecutedChanged() public void Execute(object? parameter) { - try + using (new LocationDatabase.SuspendRefreshScope()) { - LocationDatabase.Instance.SuspendRefresh = true; - ITrackableItem? item = parameter as ITrackableItem; if (item != null) { @@ -148,10 +146,6 @@ public void Execute(object? parameter) } } } - finally - { - LocationDatabase.Instance.SuspendRefresh = false; - } } } @@ -175,10 +169,8 @@ public void NotifyCanExecutedChanged() public void Execute(object? parameter) { - try + using (new LocationDatabase.SuspendRefreshScope()) { - LocationDatabase.Instance.SuspendRefresh = true; - ITrackableItem? item = parameter as ITrackableItem; if (item != null) { @@ -195,10 +187,6 @@ public void Execute(object? parameter) } } } - finally - { - LocationDatabase.Instance.SuspendRefresh = false; - } } }