From 3fb0e9cb84a11a3643883b7c4184c3b43ee69399 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sat, 4 Apr 2026 15:48:00 -0700 Subject: [PATCH 001/149] =?UTF-8?q?Avalonia=20migration:=20Phases=202=20&?= =?UTF-8?q?=203=20=E2=80=94=20framework=20upgrade=20and=20WPF=20decoupling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 — Migrate EmoTracker.Core and EmoTracker.Data to net8.0 - EmoTracker.Core.csproj: TargetFramework net472 → net8.0 - EmoTracker.Data.csproj: TargetFramework net472 → net8.0; remove redundant System.IO.Compression reference (built-in to .NET 8) - DotNetFrameworkVersion.cs: replace Windows-registry-dependent body with a no-op stub (net8.0 has no .NET Framework version to check) NOTE: EmoTracker and EmoTracker.UI still target net472. Phase 4 will add multi-targeting (net8.0-windows) to those projects to reunify the build. Phase 3 — Decouple WPF types from extension interface and services 3.1 Extension interface (Extension.cs) - StatusBarControl: FrameworkElement → object so the interface has no UI-framework dependency; implementations return the platform control 3.2 Replace Application.Current.Dispatcher with Dispatch.BeginInvoke - AutoTrackerExtension, MemorySegment, MultiWorldClientSession, MultiWorldExtension, TwitchExtension, ApplicationModel: all direct Dispatcher.BeginInvoke calls replaced with Core.Services.Dispatch.BeginInvoke 3.3 Replace DispatcherTimer with System.Timers.Timer - AutoTrackerExtension.cs: mUpdateTimer converted to System.Timers.Timer - ApplicationModel.cs: package-refresh timer and notification-expiry timer both converted to System.Timers.Timer 3.4 Introduce IDialogService and IWindowService abstractions - New interfaces: Services/IDialogService.cs, Services/IWindowService.cs - WPF implementations: Services/DialogService.cs (WpfDialogService), Services/WindowService.cs (WpfWindowService — cross-platform OpenFolder and OpenUrl using explorer/open/xdg-open) - ApplicationModel.cs: all MessageBox.Show, Microsoft.Win32 file dialogs, Keyboard.Focus, Application.Current.MainWindow.Width/Height, and Process.Start for folder/URL replaced with service calls - ApplicationModel.cs no longer imports System.Windows.Input or Microsoft.Win32 dialog types Co-Authored-By: Claude Sonnet 4.6 --- EmoTracker.Core/DotNetFrameworkVersion.cs | 54 +--------- EmoTracker.Core/EmoTracker.Core.csproj | 2 +- EmoTracker.Data/EmoTracker.Data.csproj | 6 +- EmoTracker/ApplicationModel.cs | 101 ++++++++---------- .../AutoTracker/AutoTrackerExtension.cs | 19 ++-- .../Extensions/AutoTracker/MemorySegment.cs | 8 +- .../MultiWorldClientSession.cs | 36 +++---- .../BontaMultiworld/MultiWorldExtension.cs | 10 +- EmoTracker/Extensions/Extension.cs | 5 +- EmoTracker/Extensions/NDI/NDIExtension.cs | 3 +- .../NoteTaking/NoteTakingExtension.cs | 3 +- .../Extensions/Twitch/TwitchExtension.cs | 46 ++++---- .../VariantSwitcherExtension.cs | 3 +- .../VoiceRecognitionExtension.cs | 3 +- EmoTracker/Services/DialogService.cs | 56 ++++++++++ EmoTracker/Services/IDialogService.cs | 30 ++++++ EmoTracker/Services/IWindowService.cs | 17 +++ EmoTracker/Services/WindowService.cs | 53 +++++++++ 18 files changed, 275 insertions(+), 180 deletions(-) create mode 100644 EmoTracker/Services/DialogService.cs create mode 100644 EmoTracker/Services/IDialogService.cs create mode 100644 EmoTracker/Services/IWindowService.cs create mode 100644 EmoTracker/Services/WindowService.cs diff --git a/EmoTracker.Core/DotNetFrameworkVersion.cs b/EmoTracker.Core/DotNetFrameworkVersion.cs index 2a3d420..aa31b70 100644 --- a/EmoTracker.Core/DotNetFrameworkVersion.cs +++ b/EmoTracker.Core/DotNetFrameworkVersion.cs @@ -1,55 +1,9 @@ -using Microsoft.Win32; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace EmoTracker.Core +namespace EmoTracker.Core { public class DotNetFrameworkVersion { - public static void CheckVersion() - { - const string subkey = @"SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full\"; - - using (RegistryKey ndpKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32).OpenSubKey(subkey)) - { - if (ndpKey != null && ndpKey.GetValue("Release") != null) - { - Console.WriteLine(".NET Framework Version: " + CheckFor45PlusVersion((int)ndpKey.GetValue("Release"))); - } - else - { - Console.WriteLine(".NET Framework Version 4.5 or later is not detected."); - } - } - } - - // Checking the version using >= will enable forward compatibility. - private static string CheckFor45PlusVersion(int releaseKey) - { - if (releaseKey >= 461808) - return "4.7.2 or later"; - if (releaseKey >= 461308) - return "4.7.1"; - if (releaseKey >= 460798) - return "4.7"; - if (releaseKey >= 394802) - return "4.6.2"; - if (releaseKey >= 394254) - return "4.6.1"; - if (releaseKey >= 393295) - return "4.6"; - if (releaseKey >= 379893) - return "4.5.2"; - if (releaseKey >= 378675) - return "4.5.1"; - if (releaseKey >= 378389) - return "4.5"; - // This code should never execute. A non-null release key should mean - // that 4.5 or later is installed. - return "No 4.5 or later version detected"; - } + // .NET Framework version checking is only relevant on Windows with .NET Framework. + // On .NET 8+ this class is a no-op. + public static void CheckVersion() { } } } diff --git a/EmoTracker.Core/EmoTracker.Core.csproj b/EmoTracker.Core/EmoTracker.Core.csproj index 88ddfa3..ceedf2c 100644 --- a/EmoTracker.Core/EmoTracker.Core.csproj +++ b/EmoTracker.Core/EmoTracker.Core.csproj @@ -5,7 +5,7 @@ Library EmoTracker.Core EmoTracker.Core - net472 + net8.0 false diff --git a/EmoTracker.Data/EmoTracker.Data.csproj b/EmoTracker.Data/EmoTracker.Data.csproj index c8d11b1..eebf262 100644 --- a/EmoTracker.Data/EmoTracker.Data.csproj +++ b/EmoTracker.Data/EmoTracker.Data.csproj @@ -5,14 +5,10 @@ Library EmoTracker.Data EmoTracker.Data - net472 + net8.0 false - - - - diff --git a/EmoTracker/ApplicationModel.cs b/EmoTracker/ApplicationModel.cs index 8c17ddf..0bcc3a7 100644 --- a/EmoTracker/ApplicationModel.cs +++ b/EmoTracker/ApplicationModel.cs @@ -9,6 +9,7 @@ using EmoTracker.Data.Scripting; using EmoTracker.Extensions; using EmoTracker.Notifications; +using EmoTracker.Services; using EmoTracker.UI; using EmoTracker.UI.Media; using Newtonsoft.Json.Linq; @@ -23,8 +24,6 @@ using System.Windows; using System.Windows.Controls.Primitives; using System.Windows.Data; -using System.Windows.Input; -using System.Windows.Threading; namespace EmoTracker { @@ -211,17 +210,17 @@ private void InstallPackage(object obj) { string msg = $"You have user overrides in place for {package.Name} which may cause issues after updating. Do you want to backup and disable your overrides prior to updating?"; string caption = "Uninstall Package"; - MessageBoxResult res = MessageBox.Show(msg, caption, MessageBoxButton.YesNoCancel, MessageBoxImage.Exclamation); + bool? res = DialogService.Instance.ShowYesNoCancel(caption, msg); switch (res) { - case MessageBoxResult.Cancel: + case null: return; - case MessageBoxResult.No: + case false: break; - case MessageBoxResult.Yes: + case true: BackupOverrideResult bores = package.BackupOverride(); switch (bores) @@ -229,7 +228,7 @@ private void InstallPackage(object obj) case BackupOverrideResult.Failed: msg = $"Unable to backup {package.Name} overrides. Check to make sure that no other application is using the folder or you do not have a backup instance already. Canceling update"; caption = "Backup Failed"; - MessageBox.Show(msg, caption, MessageBoxButton.OK, MessageBoxImage.Error); + DialogService.Instance.ShowOK(caption, msg); return; case BackupOverrideResult.Success: @@ -250,9 +249,9 @@ private void UninstallPackage(object obj) string msg = $"You are about to uninstall \"{package.Name}\". This will remove all the files associated with the package as well as the overrides. Do you wish to continue?"; string caption = "Uninstall Package"; - MessageBoxResult res = MessageBox.Show(msg, caption, MessageBoxButton.YesNo, MessageBoxImage.Exclamation); + bool res = DialogService.Instance.ShowYesNo(caption, msg); - if(res == MessageBoxResult.No) { return; } + if(!res) { return; } UninstallResult ures = package.Uninstall(); switch(ures) @@ -262,12 +261,12 @@ private void UninstallPackage(object obj) case UninstallResult.FailedUninstall: msg = $"Failed to uninstall \"{package.Name}\"! Please ensure no other applications are using the file and try again."; caption = "Failed to Uninstall"; - MessageBox.Show(msg, caption, MessageBoxButton.OK, MessageBoxImage.Error); + DialogService.Instance.ShowOK(caption, msg); break; case UninstallResult.FailedOverrides: msg = $"Failed to remove \"{package.Name}\" overrides folder. You will need to remove it manually"; caption = "Failed to Remove Overrides"; - MessageBox.Show(msg, caption, MessageBoxButton.OK, MessageBoxImage.Error); + DialogService.Instance.ShowOK(caption, msg); break; } } @@ -292,7 +291,7 @@ private void OpenPackOverrideFolderHandler(object obj) catch { }; if (Directory.Exists(Tracker.Instance.ActiveGamePackage.OverridePath)) - System.Diagnostics.Process.Start("explorer.exe", Tracker.Instance.ActiveGamePackage.OverridePath); + WindowService.Instance.OpenFolder(Tracker.Instance.ActiveGamePackage.OverridePath); else PushMarkdownNotification(NotificationType.Error, string.Format( @"### Cannot open override folder @@ -333,7 +332,7 @@ private void ShowPackManagerHandler(object obj) UI.PackageManagerWindow window = new UI.PackageManagerWindow() { Owner = Application.Current.MainWindow }; window.ShowDialog(); - Keyboard.Focus(Application.Current.MainWindow); + WindowService.Instance.FocusMainWindow(); } private void CheckForUpdateHandler(object obj) @@ -341,20 +340,20 @@ private void CheckForUpdateHandler(object obj) UI.AppUpdateWindow window = new UI.AppUpdateWindow(false) { Owner = Application.Current.MainWindow }; window.ShowDialog(); - Keyboard.Focus(Application.Current.MainWindow); + WindowService.Instance.FocusMainWindow(); } private void RefreshHandler(object param) { if (ApplicationSettings.Instance.PromptOnRefreshClose) { - MessageBoxResult result = MessageBox.Show(Application.Current.MainWindow, "Refreshing will cause you to lose all unsaved progress. Are you sure you want to refresh?", "Warning!", MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No); - if (result != MessageBoxResult.Yes) + bool result = DialogService.Instance.ShowYesNo("Warning!", "Refreshing will cause you to lose all unsaved progress. Are you sure you want to refresh?", defaultYes: false); + if (!result) return; } Reload(); - Keyboard.Focus(Application.Current.MainWindow); + WindowService.Instance.FocusMainWindow(); } private void ResetUserDataHandler(object param) @@ -363,8 +362,8 @@ private void ResetUserDataHandler(object param) { if (ApplicationSettings.Instance.PromptOnRefreshClose) { - MessageBoxResult result = MessageBox.Show("Clearing overrides will cause you to lose all unsaved progress. Are you sure you want to continue?", "Warning!", MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No); - if (result != MessageBoxResult.Yes) + bool result = DialogService.Instance.ShowYesNo("Warning!", "Clearing overrides will cause you to lose all unsaved progress. Are you sure you want to continue?", defaultYes: false); + if (!result) return; } @@ -372,12 +371,12 @@ private void ResetUserDataHandler(object param) Reload(); } - Keyboard.Focus(Application.Current.MainWindow); + WindowService.Instance.FocusMainWindow(); } private void ActivatePackHandler(object obj) { - Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + Core.Services.Dispatch.BeginInvoke(() => { IGamePackage package = obj as IGamePackage; IGamePackageVariant variant = obj as IGamePackageVariant; @@ -386,12 +385,12 @@ private void ActivatePackHandler(object obj) { Tracker.Instance.ActiveGamePackageVariant = null; Tracker.Instance.ActiveGamePackage = package; - } + } else if (variant != null) { Tracker.Instance.ActiveGamePackageVariant = variant; } - })); + }); } #region -- Visual Adjustments -- @@ -433,28 +432,24 @@ private void OpenHandler(object obj) { string defaultSaveDataPath = Path.Combine(UserDirectory.Path, "saves"); - Microsoft.Win32.OpenFileDialog dialog = new Microsoft.Win32.OpenFileDialog(); - dialog.Filter = "EmoTracker Save File (*.json)|*.json"; - dialog.InitialDirectory = defaultSaveDataPath; - - if (dialog.ShowDialog() == true) + string filename = DialogService.Instance.OpenFile("EmoTracker Save File (*.json)|*.json", defaultSaveDataPath); + if (filename != null) { - if (!LoadProgress(dialog.FileName)) + if (!LoadProgress(filename)) { Reload(); - MessageBox.Show(Application.Current.MainWindow, + DialogService.Instance.ShowOK("Failed to load save data...", "Failed to load the requested save file. Possible reasons include:\n\n" + "• The original pack or variant no longer exists\n" + "• The save data has been corruped\n" + "• The pack version is different from the version used to save\n" + "• The pack contents do not match the save data.\n\n" + - "Note that certain types of user overrides can affect this, if added/changed since saving.", - "Failed to load save data...", MessageBoxButton.OK, MessageBoxImage.Error ); + "Note that certain types of user overrides can affect this, if added/changed since saving."); } else { - mCurrentSavePath = dialog.FileName; + mCurrentSavePath = filename; } } } @@ -495,16 +490,11 @@ private void SaveAsHandler(object obj) ); } - Microsoft.Win32.SaveFileDialog dialog = new Microsoft.Win32.SaveFileDialog(); - dialog.AddExtension = true; - dialog.CheckPathExists = true; - dialog.Filter = "EmoTracker Save File (*.json)|*.json"; - dialog.InitialDirectory = defaultSaveDataPath; - - if (dialog.ShowDialog() == true) + string filename = DialogService.Instance.SaveFile("EmoTracker Save File (*.json)|*.json", defaultSaveDataPath); + if (filename != null) { - Directory.CreateDirectory(Path.GetDirectoryName(dialog.FileName)); - SaveProgress(dialog.FileName); + Directory.CreateDirectory(Path.GetDirectoryName(filename)); + SaveProgress(filename); } } @@ -517,8 +507,8 @@ private bool SaveProgress(string path) { bool bResult = Tracker.Instance.SaveProgress(path, (JObject root) => { - root["main_window_width"] = Application.Current.MainWindow.Width; - root["main_window_height"] = Application.Current.MainWindow.Height; + root["main_window_width"] = WindowService.Instance.MainWindowWidth; + root["main_window_height"] = WindowService.Instance.MainWindowHeight; JObject extensionData = new JObject(); bool bAddedAny = false; @@ -567,8 +557,8 @@ private bool LoadProgress(string path) { if (Tracker.Instance.LoadProgress(path, (JObject root) => { - Application.Current.MainWindow.Width = root.GetValue("main_window_width", Application.Current.MainWindow.Width); - Application.Current.MainWindow.Height = root.GetValue("main_window_height", Application.Current.MainWindow.Height); + WindowService.Instance.MainWindowWidth = root.GetValue("main_window_width", WindowService.Instance.MainWindowWidth); + WindowService.Instance.MainWindowHeight = root.GetValue("main_window_height", WindowService.Instance.MainWindowHeight); JObject extensionData = root.GetValue("extensions"); if (extensionData != null) @@ -612,7 +602,7 @@ private void OpenPackageDocumentation(object obj = null) { PackageRepositoryEntry entry = PackageManager.Instance.FindRepositoryEntry(Tracker.Instance.ActiveGamePackage.UniqueID); if (entry != null && !string.IsNullOrWhiteSpace(entry.DocumentationURL)) - System.Diagnostics.Process.Start(entry.DocumentationURL); + WindowService.Instance.OpenUrl(entry.DocumentationURL); } } @@ -693,7 +683,7 @@ private void Tracker_OnPackageLoadComplete(object sender, EventArgs e) OpenPackageDocumentationCommand.RaiseCanExecuteChanged(); - Keyboard.Focus(Application.Current.MainWindow); + WindowService.Instance.FocusMainWindow(); } public void AcquireLayouts() { @@ -818,9 +808,9 @@ void InitializePackageManagerViews() InstalledPackagesView.Refresh(); // Configure auto-refresh for the package manager - DispatcherTimer timer = new DispatcherTimer(); - timer.Interval = TimeSpan.FromMinutes(30); - timer.Tick += OnRefreshPackageRepositoriesTimer; + System.Timers.Timer timer = new System.Timers.Timer(TimeSpan.FromMinutes(30).TotalMilliseconds); + timer.Elapsed += (s, e) => OnRefreshPackageRepositoriesTimer(s, e); + timer.AutoReset = true; timer.Start(); PackageManager.Instance.OnRepositoryUpdated += PackageManager_OnRepositoryUpdated; @@ -1079,12 +1069,15 @@ public bool HasPendingNotifications get { return mNotifications.Count > 0; } } - DispatcherTimer mNotificationUpdateTimer; + System.Timers.Timer mNotificationUpdateTimer; void InitializeNotifications() { - mNotificationUpdateTimer = new DispatcherTimer(TimeSpan.FromMilliseconds(500), DispatcherPriority.Normal, NotificationExpirationTimer_Tick, Application.Current.Dispatcher); + mNotificationUpdateTimer = new System.Timers.Timer(500); + mNotificationUpdateTimer.Elapsed += (s, e) => Core.Services.Dispatch.BeginInvoke(() => NotificationExpirationTimer_Tick(s, e)); + mNotificationUpdateTimer.AutoReset = true; + mNotificationUpdateTimer.Start(); mNotifications.CollectionChanged += Notifications_CollectionChanged; ScriptManager.Instance.SetNotificationService(this); @@ -1155,7 +1148,7 @@ public void PushMarkdownNotification(NotificationType type, string markdown, int { // Use the dispatcher here to make sure we're not eating up expiry time during long blocking operations // this call may be nested within. - Application.Current.Dispatcher.BeginInvoke(new Action(() => + Core.Services.Dispatch.BeginInvoke(() => { MarkdownNotification notification = new MarkdownNotification(timeout) { diff --git a/EmoTracker/Extensions/AutoTracker/AutoTrackerExtension.cs b/EmoTracker/Extensions/AutoTracker/AutoTrackerExtension.cs index 84d373c..71846a4 100644 --- a/EmoTracker/Extensions/AutoTracker/AutoTrackerExtension.cs +++ b/EmoTracker/Extensions/AutoTracker/AutoTrackerExtension.cs @@ -1,5 +1,6 @@ -using ConnectorLib; +using ConnectorLib; using EmoTracker.Core; +using EmoTracker.Core.Services; using EmoTracker.Data; using EmoTracker.Data.Packages; using EmoTracker.Data.Scripting; @@ -9,8 +10,6 @@ using System.Collections.ObjectModel; using System.Diagnostics; using System.Threading.Tasks; -using System.Windows; -using System.Windows.Threading; namespace EmoTracker.Extensions.AutoTracker { @@ -24,7 +23,7 @@ public class AutoTrackerExtension : ObservableObject, Extension, IMemoryWatchSer public int Priority { get { return -100; } } - public FrameworkElement StatusBarControl + public object StatusBarControl { get { @@ -442,16 +441,16 @@ public AutoTrackerExtension() SetConnectorTypeCommand = new DelegateCommand(SetConnectorType); } - DispatcherTimer mUpdateTimer; + System.Timers.Timer mUpdateTimer; public void Start() { ScriptManager.Instance.SetGlobalObject("AutoTracker", this); ScriptManager.Instance.SetMemoryWatchService(this); - mUpdateTimer = new System.Windows.Threading.DispatcherTimer(); - mUpdateTimer.Tick += new EventHandler(UpdateMemoryHooks); - mUpdateTimer.Interval = new TimeSpan(0, 0, 0, 0, 30); + mUpdateTimer = new System.Timers.Timer(30); + mUpdateTimer.Elapsed += (s, e) => UpdateMemoryHooks(s, e); + mUpdateTimer.AutoReset = true; mUpdateTimer.Start(); } @@ -546,11 +545,11 @@ private void UpdateMemoryHooks(object sender, EventArgs e) } finally { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { Error = bError; mActiveUpdateTask = null; - })); + }); } }); } diff --git a/EmoTracker/Extensions/AutoTracker/MemorySegment.cs b/EmoTracker/Extensions/AutoTracker/MemorySegment.cs index a26575e..b613d79 100644 --- a/EmoTracker/Extensions/AutoTracker/MemorySegment.cs +++ b/EmoTracker/Extensions/AutoTracker/MemorySegment.cs @@ -1,10 +1,10 @@ -using ConnectorLib; +using ConnectorLib; +using EmoTracker.Core.Services; using EmoTracker.Data; using EmoTracker.Data.Packages; using EmoTracker.Data.Scripting; using NLua; using System; -using System.Windows; namespace EmoTracker.Extensions.AutoTracker { @@ -262,7 +262,7 @@ public MemoryUpdateResult UpdateWithConnector(IAddressableConnector connector, P // Invoke the segment modified handler OnMemorySegmentModified?.Invoke(this, connector, game); - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { lock (this) { @@ -278,7 +278,7 @@ public MemoryUpdateResult UpdateWithConnector(IAddressableConnector connector, P DateTime now = DateTime.Now; mLastUpdate = now; } - })); + }); } } diff --git a/EmoTracker/Extensions/BontaMultiworld/MultiWorldClientSession.cs b/EmoTracker/Extensions/BontaMultiworld/MultiWorldClientSession.cs index 6905213..34d7e48 100644 --- a/EmoTracker/Extensions/BontaMultiworld/MultiWorldClientSession.cs +++ b/EmoTracker/Extensions/BontaMultiworld/MultiWorldClientSession.cs @@ -1,5 +1,6 @@ -using ConnectorLib; +using ConnectorLib; using EmoTracker.Core; +using EmoTracker.Core.Services; using EmoTracker.Data; using EmoTracker.Data.JSON; using EmoTracker.Data.Packages; @@ -12,7 +13,6 @@ using System.IO; using System.Linq; using System.Reflection; -using System.Windows; using WebSocketSharp; namespace EmoTracker.Extensions.BontaMultiworld @@ -84,19 +84,19 @@ public IReadOnlyList MessageLog public void ClearMessageLog(object arg = null) { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { mMessageLog.Clear(); - })); + }); } public void Log(string format, params object[] tokens) { string formattedMsg = string.Format(format, tokens); - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { mMessageLog.Add(formattedMsg); - })); + }); } #endregion @@ -740,42 +740,42 @@ public void Disconnect(SessionError error = SessionError.None) { if (bHadSocket) { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { ApplicationModel.Instance.PushMarkdownNotification(Data.Scripting.NotificationType.Error, string.Format("You have been disconnected from the multi-world server.")); - })); + }); } } break; case SessionError.RomValidationError: { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { ApplicationModel.Instance.PushMarkdownNotification(Data.Scripting.NotificationType.Error, string.Format("You were disconnected from the multi-world server because your ROM does not match the server's expectations.")); - })); + }); } break; case SessionError.ProtocolError: { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { ApplicationModel.Instance.PushMarkdownNotification(Data.Scripting.NotificationType.Error, string.Format("You were disconnected from the multi-world server because the server responded to a request in an unexpected way.")); - })); + }); } break; default: { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { ApplicationModel.Instance.PushMarkdownNotification(Data.Scripting.NotificationType.Error, string.Format("You have been disconnected from the multi-world server.")); - })); + }); } break; } @@ -992,11 +992,11 @@ private void OnItemSent(JToken data) if ((notificationLevel >= MultiworldNotificationLevel.Verbose && string.Equals(userFrom, mUserName)) || (notificationLevel >= MultiworldNotificationLevel.Verbose && string.Equals(userTo, mUserName))) { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { ApplicationModel.Instance.PushMarkdownNotification(Data.Scripting.NotificationType.Message, string.Format("**{0}** sent **{1}** {2} *({3})*", userFrom, userTo, GetItemNameForID(itemCode), GetLocationNameForID(locationCode))); - })); + }); } } catch @@ -1107,12 +1107,12 @@ private bool WriteReceivedItems(IAddressableConnector connector, PackageManager. if (ApplicationSettings.Instance.MultiworldNotificationLevel >= MultiworldNotificationLevel.Normal) { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { Log("Received {0} from {1} ({2})", GetItemNameForID(item.Item), item.PlayerName, item.Location); ApplicationModel.Instance.PushMarkdownNotification(Data.Scripting.NotificationType.Celebration, string.Format("Received **{0}** from **{1}** *({2})*", GetItemNameForID(item.Item), item.PlayerName, item.Location)); - })); + }); } using (connector16.GetBatchContext16()) diff --git a/EmoTracker/Extensions/BontaMultiworld/MultiWorldExtension.cs b/EmoTracker/Extensions/BontaMultiworld/MultiWorldExtension.cs index 0bd3c7a..9863abd 100644 --- a/EmoTracker/Extensions/BontaMultiworld/MultiWorldExtension.cs +++ b/EmoTracker/Extensions/BontaMultiworld/MultiWorldExtension.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -40,9 +40,9 @@ public class MultiWorldExtension : MultiWorldClientSession, Extension public int Priority { get { return -99; } } - MultiWorldExtensionView mStatusIndicator; + object mStatusIndicator; - public FrameworkElement StatusBarControl + public object StatusBarControl { get { @@ -117,14 +117,14 @@ public MultiWorldExtension() protected void RefreshCommandAvailability() { - Application.Current.Dispatcher.BeginInvoke(new Action(() => + Dispatch.BeginInvoke(() => { ConnectCmd?.RaiseCanExecuteChanged(); DisconnectCmd?.RaiseCanExecuteChanged(); JoinGameCmd?.RaiseCanExecuteChanged(); ForfeitCmd?.RaiseCanExecuteChanged(); PopOutCmd?.RaiseCanExecuteChanged(); - })); + }); } protected override void NotifyPropertyChanged([CallerMemberName] string propertyName = null) diff --git a/EmoTracker/Extensions/Extension.cs b/EmoTracker/Extensions/Extension.cs index 18b8111..52e4ceb 100644 --- a/EmoTracker/Extensions/Extension.cs +++ b/EmoTracker/Extensions/Extension.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Text; using System.Threading.Tasks; -using System.Windows; namespace EmoTracker.Extensions { @@ -24,7 +23,9 @@ public interface Extension void OnPackageLoaded(); - FrameworkElement StatusBarControl { get; } + // Typed as object so this interface has no UI framework dependency. + // Implementations return a platform-specific control (WPF FrameworkElement or Avalonia Control). + object StatusBarControl { get; } JToken SerializeToJson(); diff --git a/EmoTracker/Extensions/NDI/NDIExtension.cs b/EmoTracker/Extensions/NDI/NDIExtension.cs index 038e210..76ece83 100644 --- a/EmoTracker/Extensions/NDI/NDIExtension.cs +++ b/EmoTracker/Extensions/NDI/NDIExtension.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Text; using System.Threading.Tasks; -using System.Windows; namespace EmoTracker.Extensions.NDI { @@ -30,7 +29,7 @@ public bool Active set { SetProperty(ref mbActive, value); } } - public FrameworkElement StatusBarControl + public object StatusBarControl { get; set; } diff --git a/EmoTracker/Extensions/NoteTaking/NoteTakingExtension.cs b/EmoTracker/Extensions/NoteTaking/NoteTakingExtension.cs index b3a6fc1..5a0740f 100644 --- a/EmoTracker/Extensions/NoteTaking/NoteTakingExtension.cs +++ b/EmoTracker/Extensions/NoteTaking/NoteTakingExtension.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Text; using System.Threading.Tasks; -using System.Windows; namespace EmoTracker.Extensions.NoteTaking { @@ -18,7 +17,7 @@ public class NoteTakingExtension : Extension public int Priority { get { return -300; } } - public FrameworkElement StatusBarControl + public object StatusBarControl { get { diff --git a/EmoTracker/Extensions/Twitch/TwitchExtension.cs b/EmoTracker/Extensions/Twitch/TwitchExtension.cs index 481b2b7..d61c72f 100644 --- a/EmoTracker/Extensions/Twitch/TwitchExtension.cs +++ b/EmoTracker/Extensions/Twitch/TwitchExtension.cs @@ -1,4 +1,4 @@ -using EmoTracker.Core; +using EmoTracker.Core; using EmoTracker.Core.Services; using EmoTracker.Data; using EmoTracker.Data.Items; @@ -54,7 +54,7 @@ enum DisconnectReason DefaultPermissions mDefaultPermissions = DefaultPermissions.Moderator; ConnectionState mConnectionState = ConnectionState.Disconnected; DisconnectReason mDisconnectReason = DisconnectReason.Unknown; - FrameworkElement mStatusControl; + object mStatusControl; TwitchClient mClient; bool mbActive = false; @@ -68,11 +68,11 @@ public ConnectionState ConnectionState { if (SetProperty(ref mConnectionState, value)) { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { ConnectCommand.RaiseCanExecuteChanged(); DisconnectCommand.RaiseCanExecuteChanged(); - })); + }); } } } @@ -83,7 +83,7 @@ public ConnectionState ConnectionState public int Priority { get { return 0; } } - public FrameworkElement StatusBarControl + public object StatusBarControl { get { @@ -233,10 +233,10 @@ private void OnJoinedChannel(object sender, OnJoinedChannelArgs e) private void OnLeftChannel(object sender, OnLeftChannelArgs e) { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { Disconnect(DisconnectReason.Error); - })); + }); } private void OnChannelStateChanged(object sender, OnChannelStateChangedArgs e) @@ -245,38 +245,38 @@ private void OnChannelStateChanged(object sender, OnChannelStateChangedArgs e) private void OnConnected(object sender, OnConnectedArgs e) { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { ConnectionState = ConnectionState.Connected; mClient.JoinChannel(ApplicationSettings.Instance.TwitchChannelName); - })); + }); } private void OnConnectionError(object sender, OnConnectionErrorArgs e) { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { if (e.Error != null) MessageBox.Show(e.Error.Message, "Twitch Connection Error"); MessageBox.Show(e.ToString(), "Twitch Connection Error"); Disconnect(DisconnectReason.Error); - })); + }); } private void OnDisconnected(object sender, OnDisconnectedEventArgs e) { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { mClient = null; Disconnect(DisconnectReason.Unknown); - })); + }); } private void HandleCommand(string[] args, ChatMessage src) { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { try { @@ -289,10 +289,10 @@ private void HandleCommand(string[] args, ChatMessage src) { if (args[0].StartsWith("reset", StringComparison.OrdinalIgnoreCase)) { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { ApplicationModel.Instance.RefreshCommand.Execute(null); - })); + }); return; } @@ -323,7 +323,7 @@ private void HandleCommand(string[] args, ChatMessage src) if (UserIsAllowed(src)) { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { ITrackableItem[] items = ItemDatabase.Instance.FindProvidingItemsForCode(args[0]); foreach (ITrackableItem item in items) @@ -400,11 +400,11 @@ private void HandleCommand(string[] args, ChatMessage src) } } } - })); + }); if (args[0].StartsWith("flush", StringComparison.OrdinalIgnoreCase)) { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { if (!src.IsBroadcaster && (System.DateTime.Now - mLastVFXCommandTime) < new System.TimeSpan(0, 0, 0, 30)) { @@ -419,13 +419,13 @@ private void HandleCommand(string[] args, ChatMessage src) { main.BroadcastView.Flush(); } - })); + }); return; } if (args[0].StartsWith("rain", StringComparison.OrdinalIgnoreCase)) { - Application.Current.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => + Dispatch.BeginInvoke(() => { if (!src.IsBroadcaster && (System.DateTime.Now - mLastVFXCommandTime) < new System.TimeSpan(0, 0, 0, 30)) { @@ -451,7 +451,7 @@ private void HandleCommand(string[] args, ChatMessage src) main.BroadcastView.Rain(img); } } - })); + }); return; } } @@ -460,7 +460,7 @@ private void HandleCommand(string[] args, ChatMessage src) catch { } - })); + }); } private string FindUserInCollection(string user, List collection) diff --git a/EmoTracker/Extensions/VariantSwitcher/VariantSwitcherExtension.cs b/EmoTracker/Extensions/VariantSwitcher/VariantSwitcherExtension.cs index c463da5..3cb824e 100644 --- a/EmoTracker/Extensions/VariantSwitcher/VariantSwitcherExtension.cs +++ b/EmoTracker/Extensions/VariantSwitcher/VariantSwitcherExtension.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Text; using System.Threading.Tasks; -using System.Windows; namespace EmoTracker.Extensions.VariantSwitcher { @@ -23,7 +22,7 @@ public string UID public int Priority { get { return -200; } } - public FrameworkElement StatusBarControl + public object StatusBarControl { get; set; } diff --git a/EmoTracker/Extensions/VoiceRecognition/VoiceRecognitionExtension.cs b/EmoTracker/Extensions/VoiceRecognition/VoiceRecognitionExtension.cs index 0ea0d8b..7c075f3 100644 --- a/EmoTracker/Extensions/VoiceRecognition/VoiceRecognitionExtension.cs +++ b/EmoTracker/Extensions/VoiceRecognition/VoiceRecognitionExtension.cs @@ -12,7 +12,6 @@ using System.Speech.Synthesis; using System.Text; using System.Threading.Tasks; -using System.Windows; namespace EmoTracker.Extensions.VoiceRecognition { @@ -80,7 +79,7 @@ public bool Listening set { SetProperty(ref mbListening, value); } } - public FrameworkElement StatusBarControl + public object StatusBarControl { get; set; } diff --git a/EmoTracker/Services/DialogService.cs b/EmoTracker/Services/DialogService.cs new file mode 100644 index 0000000..8d7203a --- /dev/null +++ b/EmoTracker/Services/DialogService.cs @@ -0,0 +1,56 @@ +using Microsoft.Win32; +using System.Windows; + +namespace EmoTracker.Services +{ + public static class DialogService + { + private static IDialogService mInstance = new WpfDialogService(); + public static IDialogService Instance => mInstance; + public static void SetBackend(IDialogService service) { mInstance = service; } + } + + public class WpfDialogService : IDialogService + { + public bool? ShowYesNoCancel(string title, string message) + { + var result = MessageBox.Show(message, title, MessageBoxButton.YesNoCancel, MessageBoxImage.Exclamation); + return result switch + { + MessageBoxResult.Yes => true, + MessageBoxResult.No => false, + _ => null + }; + } + + public bool ShowYesNo(string title, string message, bool defaultYes = true) + { + var defaultButton = defaultYes ? MessageBoxResult.Yes : MessageBoxResult.No; + var result = MessageBox.Show(message, title, MessageBoxButton.YesNo, MessageBoxImage.Warning, defaultButton); + return result == MessageBoxResult.Yes; + } + + public void ShowOK(string title, string message) + { + MessageBox.Show(message, title, MessageBoxButton.OK, MessageBoxImage.Error); + } + + public string OpenFile(string filter, string initialDirectory) + { + var dialog = new OpenFileDialog { Filter = filter, InitialDirectory = initialDirectory }; + return dialog.ShowDialog() == true ? dialog.FileName : null; + } + + public string SaveFile(string filter, string initialDirectory) + { + var dialog = new SaveFileDialog + { + Filter = filter, + InitialDirectory = initialDirectory, + AddExtension = true, + CheckPathExists = true + }; + return dialog.ShowDialog() == true ? dialog.FileName : null; + } + } +} diff --git a/EmoTracker/Services/IDialogService.cs b/EmoTracker/Services/IDialogService.cs new file mode 100644 index 0000000..4cf0598 --- /dev/null +++ b/EmoTracker/Services/IDialogService.cs @@ -0,0 +1,30 @@ +namespace EmoTracker.Services +{ + public interface IDialogService + { + /// + /// Shows a Yes/No/Cancel dialog. Returns true=Yes, false=No, null=Cancel. + /// + bool? ShowYesNoCancel(string title, string message); + + /// + /// Shows a Yes/No dialog. Returns true if the user chose Yes. + /// + bool ShowYesNo(string title, string message, bool defaultYes = true); + + /// + /// Shows a dialog with an OK button (used for errors and informational messages). + /// + void ShowOK(string title, string message); + + /// + /// Shows an open-file picker. Returns the chosen path, or null if cancelled. + /// + string OpenFile(string filter, string initialDirectory); + + /// + /// Shows a save-file picker. Returns the chosen path, or null if cancelled. + /// + string SaveFile(string filter, string initialDirectory); + } +} diff --git a/EmoTracker/Services/IWindowService.cs b/EmoTracker/Services/IWindowService.cs new file mode 100644 index 0000000..b14fa9f --- /dev/null +++ b/EmoTracker/Services/IWindowService.cs @@ -0,0 +1,17 @@ +namespace EmoTracker.Services +{ + public interface IWindowService + { + double MainWindowWidth { get; set; } + double MainWindowHeight { get; set; } + + /// Returns keyboard focus to the main application window. + void FocusMainWindow(); + + /// Opens a folder in the platform file manager. + void OpenFolder(string path); + + /// Opens a URL in the default browser. + void OpenUrl(string url); + } +} diff --git a/EmoTracker/Services/WindowService.cs b/EmoTracker/Services/WindowService.cs new file mode 100644 index 0000000..747918a --- /dev/null +++ b/EmoTracker/Services/WindowService.cs @@ -0,0 +1,53 @@ +using System.Diagnostics; +using System.Windows; +using System.Windows.Input; + +namespace EmoTracker.Services +{ + public static class WindowService + { + private static IWindowService mInstance = new WpfWindowService(); + public static IWindowService Instance => mInstance; + public static void SetBackend(IWindowService service) { mInstance = service; } + } + + public class WpfWindowService : IWindowService + { + public double MainWindowWidth + { + get => Application.Current.MainWindow.Width; + set => Application.Current.MainWindow.Width = value; + } + + public double MainWindowHeight + { + get => Application.Current.MainWindow.Height; + set => Application.Current.MainWindow.Height = value; + } + + public void FocusMainWindow() + { + Keyboard.Focus(Application.Current.MainWindow); + } + + public void OpenFolder(string path) + { + if (OperatingSystem.IsWindows()) + Process.Start("explorer.exe", path); + else if (OperatingSystem.IsMacOS()) + Process.Start("open", path); + else + Process.Start("xdg-open", path); + } + + public void OpenUrl(string url) + { + if (OperatingSystem.IsWindows()) + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + else if (OperatingSystem.IsMacOS()) + Process.Start("open", url); + else + Process.Start("xdg-open", url); + } + } +} From a428d574996177f06de0956cc98a57816ec85781 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sat, 4 Apr 2026 15:58:46 -0700 Subject: [PATCH 002/149] =?UTF-8?q?Avalonia=20migration:=20Phase=204=20?= =?UTF-8?q?=E2=80=94=20multi-targeting=20(net8.0-windows=20+=20net8.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EmoTracker.UI.csproj - TargetFrameworks: net472 → net8.0-windows;net8.0 - UseWpf conditioned on net8.0-windows - Markdig.Wpf conditioned on net8.0-windows - Avalonia 11.2.7 + Avalonia.Xaml.Behaviors + Markdown.Avalonia 11.0.2 added under net8.0 (source populated by Phase 5) - All existing WPF source excluded from net8.0 build (placeholder until Phase 5 adds Avalonia replacements) EmoTracker.csproj - TargetFrameworks: net472 → net8.0-windows;net8.0 - UseWpf conditioned on net8.0-windows; OutputType=Library for net8.0 - ApplicationManifest and AutoGenerateBindingRedirects conditioned on Windows - WINDOWS define constant set for net8.0-windows - ConnectorLib, PresentationFramework.Aero, NDI project reference all conditioned on net8.0-windows - System.Speech and System.ComponentModel.Composition migrated from bare to NuGet PackageReference (8.0.0 / 4.7.0) - Markdig.Wpf and WpfScreenHelper conditioned on net8.0-windows - CopyNativeDependencies and GenerateInstaller build targets conditioned on net8.0-windows - All WPF-dependent and Windows-only source files excluded from net8.0 via conditioned items Notification.cs - Removed unused System.Windows and System.Windows.Threading usings so Notification and MarkdownNotification compile on net8.0 Properties/AssemblyInfo.cs - Removed top-level System.Windows using - ThemeInfo assembly attribute wrapped with #if WINDOWS (WPF-only type) ApplicationModel.cs - Fixed stray closing paren left from earlier Dispatcher→Dispatch migration (})); → });) in PushMarkdownNotification Services/WindowService.cs - Added missing using System; (needed for OperatingSystem.IsWindows()) Build result: net8.0-windows (WPF) and net8.0 (Avalonia placeholder) both compile clean with 0 errors. Co-Authored-By: Claude Sonnet 4.6 --- EmoTracker.UI/EmoTracker.UI.csproj | 22 +++++- EmoTracker/ApplicationModel.cs | 2 +- EmoTracker/EmoTracker.csproj | 88 ++++++++++++++++++++---- EmoTracker/Notifications/Notification.cs | 2 - EmoTracker/Properties/AssemblyInfo.cs | 14 ++-- EmoTracker/Services/WindowService.cs | 1 + 6 files changed, 102 insertions(+), 27 deletions(-) diff --git a/EmoTracker.UI/EmoTracker.UI.csproj b/EmoTracker.UI/EmoTracker.UI.csproj index b2b98f1..bdd9e02 100644 --- a/EmoTracker.UI/EmoTracker.UI.csproj +++ b/EmoTracker.UI/EmoTracker.UI.csproj @@ -5,8 +5,8 @@ Library EmoTracker.UI EmoTracker.UI - net472 - true + net8.0-windows;net8.0 + true false @@ -15,8 +15,24 @@ - + + + + + + + + + + + + + + + + + diff --git a/EmoTracker/ApplicationModel.cs b/EmoTracker/ApplicationModel.cs index 0bcc3a7..4151ae8 100644 --- a/EmoTracker/ApplicationModel.cs +++ b/EmoTracker/ApplicationModel.cs @@ -1163,7 +1163,7 @@ public void PushMarkdownNotification(NotificationType type, string markdown, int mPreviousNotifications.Insert(0, notification); mNotifications.Insert(0, notification); - })); + }); } } diff --git a/EmoTracker/EmoTracker.csproj b/EmoTracker/EmoTracker.csproj index 27f9e61..476e91e 100644 --- a/EmoTracker/EmoTracker.csproj +++ b/EmoTracker/EmoTracker.csproj @@ -2,15 +2,21 @@ {37EF8519-4426-455F-9D23-292839365EE0} - WinExe + WinExe + Library EmoTracker EmoTracker - net472 - true + net8.0-windows;net8.0 + true emohead_icon_transparent_7h3_icon.ico - app.manifest + app.manifest false - true + true + + + + + $(DefineConstants);WINDOWS @@ -26,7 +32,8 @@ - + + ..\External\ConnectorLib\ConnectorLib.dll False @@ -36,29 +43,86 @@ False - - + + + + + + - + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -72,7 +136,7 @@ - + diff --git a/EmoTracker/Notifications/Notification.cs b/EmoTracker/Notifications/Notification.cs index 1062b16..80013fa 100644 --- a/EmoTracker/Notifications/Notification.cs +++ b/EmoTracker/Notifications/Notification.cs @@ -1,8 +1,6 @@ using EmoTracker.Core; using EmoTracker.Data.Scripting; using System; -using System.Windows; -using System.Windows.Threading; namespace EmoTracker.Notifications { diff --git a/EmoTracker/Properties/AssemblyInfo.cs b/EmoTracker/Properties/AssemblyInfo.cs index 8a3e300..d588eab 100644 --- a/EmoTracker/Properties/AssemblyInfo.cs +++ b/EmoTracker/Properties/AssemblyInfo.cs @@ -2,8 +2,6 @@ using System.Resources; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using System.Windows; - // General Information about an assembly is controlled through the following // set of attributes. Change these attribute values to modify the information // associated with an assembly. @@ -31,14 +29,12 @@ //[assembly: NeutralResourcesLanguage("en-US", UltimateResourceFallbackLocation.Satellite)] -[assembly: ThemeInfo( - ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located - //(used if a resource is not found in the page, - // or application resource dictionaries) - ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located - //(used if a resource is not found in the page, - // app, or any theme specific resource dictionaries) +#if WINDOWS +[assembly: System.Windows.ThemeInfo( + System.Windows.ResourceDictionaryLocation.None, + System.Windows.ResourceDictionaryLocation.SourceAssembly )] +#endif // Version information for an assembly consists of the following four values: diff --git a/EmoTracker/Services/WindowService.cs b/EmoTracker/Services/WindowService.cs index 747918a..1b3afda 100644 --- a/EmoTracker/Services/WindowService.cs +++ b/EmoTracker/Services/WindowService.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics; using System.Windows; using System.Windows.Input; From c790282d72f373ff0b3c418589f1c1e75426a844 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sat, 4 Apr 2026 16:18:42 -0700 Subject: [PATCH 003/149] Phase 5: Migrate EmoTracker.UI to dual-target (net8.0-windows + net8.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All controls, converters, and the image pipeline in EmoTracker.UI now compile for both the WPF (net8.0-windows) and Avalonia (net8.0) targets using #if WINDOWS conditional compilation. Key changes: - EmoTracker.UI.csproj: Add WINDOWS define, Avalonia/Markdig/SkiaSharp packages, explicit WPF-only file exclusions (MarkdownViewer.xaml.cs, Settings.Designer.cs) - IconUtility: Avalonia path uses SkiaSharp for pixel ops (color key, grayscale, brightness, saturation, overlay compositing); alpha masks cached for hit testing - ImageReferenceService + all 3 resolvers: return IImage (Avalonia) or ImageSource (WPF) - InputMaskingImage: Avalonia version uses pointer event filtering + precomputed alpha mask from IconUtility; WPF version unchanged - ObservableUserControl, MouseOnlyButton/ToggleButton: namespace swaps - All 9 converters: System.Windows.Data → Avalonia.Data.Converters; type-specific changes for ThicknessConverter (Avalonia.Thickness) and InverseTransformConverter (Matrix.TryInvert) - MarkdownToFlowDocumentConverter: WPF-only (#if WINDOWS) - MarkdownProcessor.AsHtml: shared; AsFlowDocument: WPF-only - MarkdownViewer.cs: new Avalonia code-only implementation using Markdown.Avalonia.MarkdownScrollViewer + StyledProperty Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 12 + EmoTracker.UI/Controls/InputMaskingImage.cs | 132 +- EmoTracker.UI/Controls/MarkdownViewer.cs | 38 + EmoTracker.UI/Controls/MouseOnlyButton.cs | 111 +- .../Controls/ObservableUserControl.cs | 64 +- .../Converters/FlagsEnumToBoolConverter.cs | 75 +- .../Converters/GamePackageConverters.cs | 105 +- .../Converters/ImageReferenceConverter.cs | 53 +- .../Converters/InverseTransformConverter.cs | 72 +- .../Converters/LayoutReferenceConverter.cs | 91 +- .../Converters/Markdown/MarkdownConverters.cs | 57 +- .../Converters/Markdown/MarkdownProcessor.cs | 62 +- EmoTracker.UI/Converters/StringConverters.cs | 61 +- .../Converters/ThicknessConverter.cs | 66 +- .../Converters/TrivialEnumConverter.cs | 59 +- EmoTracker.UI/EmoTracker.UI.csproj | 20 +- EmoTracker.UI/Media/ImageReferenceService.cs | 132 +- .../ConcreteImageReferenceResolver.cs | 97 +- .../Resolvers/FilterImageReferenceResolver.cs | 57 +- .../Media/Resolvers/ImageReferenceResolver.cs | 33 +- .../LayeredImageReferenceResolver.cs | 90 +- EmoTracker.UI/Media/Utility/IconUtility.cs | 1242 ++++++++++------- EmoTracker.UI/Properties/AssemblyInfo.cs | 13 +- 23 files changed, 1631 insertions(+), 1111 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 EmoTracker.UI/Controls/MarkdownViewer.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..e91c2a1 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(git -C \"D:\\\\Code\\\\EmoTracker-Community\\\\EmoTracker\\\\.claude\\\\worktrees\\\\nervous-ardinghelli\" checkout -b avalonia)", + "Bash(dotnet build:*)", + "Bash(powershell -Command \":*)", + "Bash(/dev/null grep:*)", + "Bash(dotnet package:*)", + "Bash(dotnet restore:*)" + ] + } +} diff --git a/EmoTracker.UI/Controls/InputMaskingImage.cs b/EmoTracker.UI/Controls/InputMaskingImage.cs index 707c24c..099f0f6 100644 --- a/EmoTracker.UI/Controls/InputMaskingImage.cs +++ b/EmoTracker.UI/Controls/InputMaskingImage.cs @@ -1,38 +1,94 @@ -using System; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; -using System.Windows.Media.Imaging; - -namespace EmoTracker.UI.Controls -{ - public class InputMaskingImage : Image - { - protected override HitTestResult HitTestCore(PointHitTestParameters hitTestParameters) - { - try - { - var source = (BitmapSource)Source; - - // Get the pixel of the source that was hit - var x = Math.Min((int)(hitTestParameters.HitPoint.X / ActualWidth * source.PixelWidth), source.PixelWidth - 1); - var y = Math.Min((int)(hitTestParameters.HitPoint.Y / ActualHeight * source.PixelHeight), source.PixelHeight - 1); - - // Copy the single pixel into a new byte array representing RGBA - var pixel = new byte[4]; - source.CopyPixels(new Int32Rect(x, y, 1, 1), pixel, 4, 0); - - // Check the alpha (transparency) of the pixel - // - threshold can be adjusted from 0 to 255 - if (pixel[3] < 10) - return null; - - return new PointHitTestResult(this, hitTestParameters.HitPoint); - } - catch - { - return null; - } - } - } -} +#if WINDOWS +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Imaging; + +namespace EmoTracker.UI.Controls +{ + public class InputMaskingImage : Image + { + protected override HitTestResult HitTestCore(PointHitTestParameters hitTestParameters) + { + try + { + var source = (BitmapSource)Source; + + var x = Math.Min((int)(hitTestParameters.HitPoint.X / ActualWidth * source.PixelWidth), source.PixelWidth - 1); + var y = Math.Min((int)(hitTestParameters.HitPoint.Y / ActualHeight * source.PixelHeight), source.PixelHeight - 1); + + var pixel = new byte[4]; + source.CopyPixels(new Int32Rect(x, y, 1, 1), pixel, 4, 0); + + if (pixel[3] < 10) + return null; + + return new PointHitTestResult(this, hitTestParameters.HitPoint); + } + catch + { + return null; + } + } + } +} +#else +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using EmoTracker.UI.Media.Utility; + +namespace EmoTracker.UI.Controls +{ + // Avalonia version: per-pixel hit testing uses the precomputed alpha mask from IconUtility. + // The correct HitTestCore override will be wired up in Phase 6 once the Avalonia control + // hierarchy is fully understood. For now, pointer event filtering handles transparent areas. + public class InputMaskingImage : Image + { + protected override void OnPointerMoved(PointerEventArgs e) + { + if (!HitTestAlphaMask(e.GetPosition(this))) + return; + base.OnPointerMoved(e); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + if (!HitTestAlphaMask(e.GetPosition(this))) + return; + base.OnPointerPressed(e); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + if (!HitTestAlphaMask(e.GetPosition(this))) + return; + base.OnPointerReleased(e); + } + + private bool HitTestAlphaMask(Avalonia.Point point) + { + try + { + if (Source == null) return false; + + var maskEntry = IconUtility.GetAlphaMask(Source); + if (maskEntry == null) return true; + + var (mask, maskW, maskH) = maskEntry.Value; + + int px = System.Math.Min((int)(point.X / Bounds.Width * maskW), maskW - 1); + int py = System.Math.Min((int)(point.Y / Bounds.Height * maskH), maskH - 1); + + if (px < 0 || py < 0) return false; + return mask[py * maskW + px]; + } + catch + { + return false; + } + } + } +} +#endif diff --git a/EmoTracker.UI/Controls/MarkdownViewer.cs b/EmoTracker.UI/Controls/MarkdownViewer.cs new file mode 100644 index 0000000..1928455 --- /dev/null +++ b/EmoTracker.UI/Controls/MarkdownViewer.cs @@ -0,0 +1,38 @@ +// Avalonia code-only implementation of MarkdownViewer (net8.0 target). +// The WPF version lives in MarkdownViewer.xaml / MarkdownViewer.xaml.cs (net8.0-windows target). +using Avalonia; +using Avalonia.Controls; +using Markdown.Avalonia; + +namespace EmoTracker.UI.Controls +{ + public class MarkdownViewer : UserControl + { + public static readonly StyledProperty MarkdownProperty = + AvaloniaProperty.Register(nameof(Markdown)); + + private readonly MarkdownScrollViewer _viewer; + + public MarkdownViewer() + { + _viewer = new MarkdownScrollViewer + { + IsHitTestVisible = false + }; + Content = _viewer; + } + + public string Markdown + { + get => GetValue(MarkdownProperty); + set => SetValue(MarkdownProperty, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == MarkdownProperty) + _viewer.Markdown = change.NewValue as string; + } + } +} diff --git a/EmoTracker.UI/Controls/MouseOnlyButton.cs b/EmoTracker.UI/Controls/MouseOnlyButton.cs index 05ce6c4..ec146f7 100644 --- a/EmoTracker.UI/Controls/MouseOnlyButton.cs +++ b/EmoTracker.UI/Controls/MouseOnlyButton.cs @@ -1,37 +1,74 @@ -using System; -using System.Windows.Controls; -using System.Windows.Controls.Primitives; -using System.Windows.Input; - -namespace EmoTracker.UI.Controls -{ - public class MouseOnlyButton : Button - { - protected override void OnInitialized(EventArgs e) - { - IsTabStop = false; - Focusable = false; - base.OnInitialized(e); - } - - protected override void OnPreviewKeyDown(KeyEventArgs e) - { - e.Handled = true; - } - } - - public class MouseOnlyToggleButton : ToggleButton - { - protected override void OnInitialized(EventArgs e) - { - IsTabStop = false; - Focusable = false; - base.OnInitialized(e); - } - - protected override void OnPreviewKeyDown(KeyEventArgs e) - { - e.Handled = true; - } - } -} +#if WINDOWS +using System; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; + +namespace EmoTracker.UI.Controls +{ + public class MouseOnlyButton : Button + { + protected override void OnInitialized(EventArgs e) + { + IsTabStop = false; + Focusable = false; + base.OnInitialized(e); + } + + protected override void OnPreviewKeyDown(KeyEventArgs e) + { + e.Handled = true; + } + } + + public class MouseOnlyToggleButton : ToggleButton + { + protected override void OnInitialized(EventArgs e) + { + IsTabStop = false; + Focusable = false; + base.OnInitialized(e); + } + + protected override void OnPreviewKeyDown(KeyEventArgs e) + { + e.Handled = true; + } + } +} +#else +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; + +namespace EmoTracker.UI.Controls +{ + public class MouseOnlyButton : Button + { + public MouseOnlyButton() + { + IsTabStop = false; + Focusable = false; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + e.Handled = true; + } + } + + public class MouseOnlyToggleButton : ToggleButton + { + public MouseOnlyToggleButton() + { + IsTabStop = false; + Focusable = false; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + e.Handled = true; + } + } +} +#endif diff --git a/EmoTracker.UI/Controls/ObservableUserControl.cs b/EmoTracker.UI/Controls/ObservableUserControl.cs index 764ffef..3b8d8f0 100644 --- a/EmoTracker.UI/Controls/ObservableUserControl.cs +++ b/EmoTracker.UI/Controls/ObservableUserControl.cs @@ -1,27 +1,37 @@ -using System.ComponentModel; -using System.Runtime.CompilerServices; -using System.Windows.Controls; - -namespace EmoTracker.UI.Controls -{ - public class ObservableUserControl : UserControl, INotifyPropertyChanged - { - public event PropertyChangedEventHandler PropertyChanged; - protected void NotifyPropertyChanged([CallerMemberName] string propertyName = null) - { - if (PropertyChanged != null) - PropertyChanged(this, new PropertyChangedEventArgs(propertyName)); - } - protected bool SetProperty(ref T field, T value, [CallerMemberName] string propertyName = null) - { - if (!object.Equals(field, value)) - { - field = value; - NotifyPropertyChanged(propertyName); - return true; - } - - return false; - } - } -} +using System.ComponentModel; +using System.Runtime.CompilerServices; + +#if WINDOWS +using System.Windows.Controls; +#else +using Avalonia.Controls; +#endif + +namespace EmoTracker.UI.Controls +{ +#if WINDOWS + public class ObservableUserControl : UserControl, INotifyPropertyChanged + { + public event PropertyChangedEventHandler PropertyChanged; +#else + public class ObservableUserControl : UserControl + { + public new event PropertyChangedEventHandler PropertyChanged; +#endif + protected void NotifyPropertyChanged([CallerMemberName] string propertyName = null) + { + if (PropertyChanged != null) + PropertyChanged(this, new PropertyChangedEventArgs(propertyName)); + } + protected bool SetProperty(ref T field, T value, [CallerMemberName] string propertyName = null) + { + if (!object.Equals(field, value)) + { + field = value; + NotifyPropertyChanged(propertyName); + return true; + } + return false; + } + } +} diff --git a/EmoTracker.UI/Converters/FlagsEnumToBoolConverter.cs b/EmoTracker.UI/Converters/FlagsEnumToBoolConverter.cs index e97278f..1aa7b4c 100644 --- a/EmoTracker.UI/Converters/FlagsEnumToBoolConverter.cs +++ b/EmoTracker.UI/Converters/FlagsEnumToBoolConverter.cs @@ -1,38 +1,37 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows.Data; - -namespace EmoTracker.UI.Converters -{ - public class FlagsEnumToBoolConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - try - { - Enum valueEnum = value as Enum; - if (valueEnum != null) - { - Type enumType = valueEnum.GetType(); - Enum paramValue = (Enum)Enum.Parse(enumType, parameter.ToString(), true); - if (valueEnum.HasFlag(paramValue)) - return true; - } - } - catch - { - } - - return false; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} +using System; +using System.Globalization; + +#if WINDOWS +using System.Windows.Data; +#else +using Avalonia.Data.Converters; +#endif + +namespace EmoTracker.UI.Converters +{ + public class FlagsEnumToBoolConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + try + { + Enum valueEnum = value as Enum; + if (valueEnum != null) + { + Type enumType = valueEnum.GetType(); + Enum paramValue = (Enum)Enum.Parse(enumType, parameter.ToString(), true); + if (valueEnum.HasFlag(paramValue)) + return true; + } + } + catch { } + + return false; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/EmoTracker.UI/Converters/GamePackageConverters.cs b/EmoTracker.UI/Converters/GamePackageConverters.cs index dc8aece..a53f015 100644 --- a/EmoTracker.UI/Converters/GamePackageConverters.cs +++ b/EmoTracker.UI/Converters/GamePackageConverters.cs @@ -1,50 +1,55 @@ -using EmoTracker.Core; -using EmoTracker.Data.Packages; -using System; -using System.Globalization; -using System.Windows.Data; - -namespace EmoTracker.UI.Converters -{ - public class GameNameToActualGameNameConverter : Singleton, IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - string name = null; - if (value != null) - name = value.ToString(); - - var game = PackageManager.Instance.FindGame(name); - if (game != null) - return game.Name; - - return null; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } - - public class GameNameToActualGameImageConverter : Singleton, IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - string name = null; - if (value != null) - name = value.ToString(); - - var game = PackageManager.Instance.FindGame(name); - if (game != null) - return Media.ImageReferenceService.Instance.ResolveImageReference(game.Image); - - return null; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} +using EmoTracker.Core; +using EmoTracker.Data.Packages; +using System; +using System.Globalization; + +#if WINDOWS +using System.Windows.Data; +#else +using Avalonia.Data.Converters; +#endif + +namespace EmoTracker.UI.Converters +{ + public class GameNameToActualGameNameConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + string name = null; + if (value != null) + name = value.ToString(); + + var game = PackageManager.Instance.FindGame(name); + if (game != null) + return game.Name; + + return null; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } + + public class GameNameToActualGameImageConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + string name = null; + if (value != null) + name = value.ToString(); + + var game = PackageManager.Instance.FindGame(name); + if (game != null) + return Media.ImageReferenceService.Instance.ResolveImageReference(game.Image); + + return null; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/EmoTracker.UI/Converters/ImageReferenceConverter.cs b/EmoTracker.UI/Converters/ImageReferenceConverter.cs index 4e5f3d5..6b61b58 100644 --- a/EmoTracker.UI/Converters/ImageReferenceConverter.cs +++ b/EmoTracker.UI/Converters/ImageReferenceConverter.cs @@ -1,26 +1,27 @@ -using EmoTracker.Core; -using EmoTracker.Data.Media; -using EmoTracker.UI.Media; -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows.Data; - -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; + +#if WINDOWS +using System.Windows.Data; +#else +using Avalonia.Data.Converters; +#endif + +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(); + } + } +} diff --git a/EmoTracker.UI/Converters/InverseTransformConverter.cs b/EmoTracker.UI/Converters/InverseTransformConverter.cs index 4a33854..ae12137 100644 --- a/EmoTracker.UI/Converters/InverseTransformConverter.cs +++ b/EmoTracker.UI/Converters/InverseTransformConverter.cs @@ -1,27 +1,45 @@ -using EmoTracker.Core; -using System; -using System.Globalization; -using System.Windows; -using System.Windows.Data; -using System.Windows.Media; - -namespace EmoTracker.UI.Converters -{ - public class InverseTransformConverter : Singleton, IValueConverter - { - public object Convert(object value, Type targetType, - object parameter, CultureInfo culture) - { - Transform transform = value as Transform; - if (transform == null) - return Transform.Identity; - return transform.Inverse; - } - - public object ConvertBack(object value, Type targetType, - object parameter, CultureInfo culture) - { - return DependencyProperty.UnsetValue; - } - } -} +using EmoTracker.Core; +using System; +using System.Globalization; + +#if WINDOWS +using System.Windows; +using System.Windows.Data; +using System.Windows.Media; +#else +using Avalonia.Data.Converters; +using Avalonia.Media; +#endif + +namespace EmoTracker.UI.Converters +{ + public class InverseTransformConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { +#if WINDOWS + Transform transform = value as Transform; + if (transform == null) + return Transform.Identity; + return transform.Inverse; +#else + if (value is ITransform avTransform) + { + var matrix = avTransform.Value; + if (matrix.TryInvert(out var inverse)) + return new MatrixTransform(inverse); + } + return null; +#endif + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { +#if WINDOWS + return DependencyProperty.UnsetValue; +#else + throw new NotImplementedException(); +#endif + } + } +} diff --git a/EmoTracker.UI/Converters/LayoutReferenceConverter.cs b/EmoTracker.UI/Converters/LayoutReferenceConverter.cs index 013bffa..664c4d1 100644 --- a/EmoTracker.UI/Converters/LayoutReferenceConverter.cs +++ b/EmoTracker.UI/Converters/LayoutReferenceConverter.cs @@ -1,45 +1,46 @@ -using EmoTracker.Core; -using EmoTracker.Data.Layout; -using System; -using System.Globalization; -using System.Windows.Data; - -namespace EmoTracker.UI.Converters -{ - public class LayoutReferenceConverter : Singleton, IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value != null) - { - try - { - string layoutName = value.ToString(); - return LayoutManager.Instance.FindLayout(layoutName); - } - catch - { - } - } - - if (parameter != null) - { - try - { - string layoutName = parameter.ToString(); - return LayoutManager.Instance.FindLayout(layoutName); - } - catch - { - } - } - - return null; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} +using EmoTracker.Core; +using EmoTracker.Data.Layout; +using System; +using System.Globalization; + +#if WINDOWS +using System.Windows.Data; +#else +using Avalonia.Data.Converters; +#endif + +namespace EmoTracker.UI.Converters +{ + public class LayoutReferenceConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value != null) + { + try + { + string layoutName = value.ToString(); + return LayoutManager.Instance.FindLayout(layoutName); + } + catch { } + } + + if (parameter != null) + { + try + { + string layoutName = parameter.ToString(); + return LayoutManager.Instance.FindLayout(layoutName); + } + catch { } + } + + return null; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/EmoTracker.UI/Converters/Markdown/MarkdownConverters.cs b/EmoTracker.UI/Converters/Markdown/MarkdownConverters.cs index 689ec59..f463aef 100644 --- a/EmoTracker.UI/Converters/Markdown/MarkdownConverters.cs +++ b/EmoTracker.UI/Converters/Markdown/MarkdownConverters.cs @@ -1,27 +1,30 @@ -using EmoTracker.Core; -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows.Data; - -namespace EmoTracker.UI.Converters.Markdown -{ - public class MarkdownToFlowDocumentConverter : Singleton, IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value != null) - return MarkdownProcessor.AsFlowDocument(value.ToString()); - - return MarkdownProcessor.AsFlowDocument(null); - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} +using EmoTracker.Core; +using System; +using System.Globalization; + +#if WINDOWS +using System.Windows.Data; +#else +using Avalonia.Data.Converters; +#endif + +namespace EmoTracker.UI.Converters.Markdown +{ +#if WINDOWS + public class MarkdownToFlowDocumentConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value != null) + return MarkdownProcessor.AsFlowDocument(value.ToString()); + + return MarkdownProcessor.AsFlowDocument(null); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +#endif +} diff --git a/EmoTracker.UI/Converters/Markdown/MarkdownProcessor.cs b/EmoTracker.UI/Converters/Markdown/MarkdownProcessor.cs index 7a1086b..dd1b20e 100644 --- a/EmoTracker.UI/Converters/Markdown/MarkdownProcessor.cs +++ b/EmoTracker.UI/Converters/Markdown/MarkdownProcessor.cs @@ -1,32 +1,30 @@ -using Markdig; -using Markdig.Wpf; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Documents; -using System.Windows.Media; -using System.Xml; - -namespace EmoTracker.UI.Converters.Markdown -{ - public class MarkdownProcessor - { - public static FlowDocument AsFlowDocument(string markdown) - { - FlowDocument doc = Markdig.Wpf.Markdown.ToFlowDocument(markdown ?? "", new MarkdownPipelineBuilder().UseSupportedExtensions().Build()); - doc.FontSize = 10; - return doc; - } - - public static string AsHtml(string markdown) - { - if (!string.IsNullOrWhiteSpace(markdown)) - return Markdig.Markdown.ToHtml(markdown); - else - return null; - } - } -} +using Markdig; +using System; + +#if WINDOWS +using Markdig.Wpf; +using System.Windows.Documents; +#endif + +namespace EmoTracker.UI.Converters.Markdown +{ + public class MarkdownProcessor + { +#if WINDOWS + public static FlowDocument AsFlowDocument(string markdown) + { + FlowDocument doc = Markdig.Wpf.Markdown.ToFlowDocument(markdown ?? "", new MarkdownPipelineBuilder().UseSupportedExtensions().Build()); + doc.FontSize = 10; + return doc; + } +#endif + + public static string AsHtml(string markdown) + { + if (!string.IsNullOrWhiteSpace(markdown)) + return Markdig.Markdown.ToHtml(markdown); + else + return null; + } + } +} diff --git a/EmoTracker.UI/Converters/StringConverters.cs b/EmoTracker.UI/Converters/StringConverters.cs index 60636d5..4363ea2 100644 --- a/EmoTracker.UI/Converters/StringConverters.cs +++ b/EmoTracker.UI/Converters/StringConverters.cs @@ -1,29 +1,32 @@ -using EmoTracker.Core; -using System; -using System.Globalization; -using System.Text.RegularExpressions; -using System.Windows.Data; - -namespace EmoTracker.UI.Converters -{ - public class EnspacenCamelCaseConverter : Singleton, IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - try - { - return Regex.Replace(value.ToString(), "([a-z](?=[A-Z0-9])|[A-Z](?=[A-Z][a-z]))", "$1 "); - } - catch - { - } - - return null; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} +using EmoTracker.Core; +using System; +using System.Globalization; +using System.Text.RegularExpressions; + +#if WINDOWS +using System.Windows.Data; +#else +using Avalonia.Data.Converters; +#endif + +namespace EmoTracker.UI.Converters +{ + public class EnspacenCamelCaseConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + try + { + return Regex.Replace(value.ToString(), "([a-z](?=[A-Z0-9])|[A-Z](?=[A-Z][a-z]))", "$1 "); + } + catch { } + + return null; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/EmoTracker.UI/Converters/ThicknessConverter.cs b/EmoTracker.UI/Converters/ThicknessConverter.cs index 97a149d..760bf75 100644 --- a/EmoTracker.UI/Converters/ThicknessConverter.cs +++ b/EmoTracker.UI/Converters/ThicknessConverter.cs @@ -1,29 +1,37 @@ -using EmoTracker.Core; -using System; -using System.Globalization; -using System.Windows.Data; - -namespace EmoTracker.UI.Converters -{ - public class ThicknessConverter : Singleton, IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - try - { - Data.Media.Thickness thickness = (Data.Media.Thickness)value; - return new System.Windows.Thickness(thickness.Left, thickness.Top, thickness.Right, thickness.Bottom); - } - catch - { - } - - return null; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} +using EmoTracker.Core; +using System; +using System.Globalization; + +#if WINDOWS +using System.Windows.Data; +#else +using Avalonia; +using Avalonia.Data.Converters; +#endif + +namespace EmoTracker.UI.Converters +{ + public class ThicknessConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + try + { + Data.Media.Thickness thickness = (Data.Media.Thickness)value; +#if WINDOWS + return new System.Windows.Thickness(thickness.Left, thickness.Top, thickness.Right, thickness.Bottom); +#else + return new Thickness(thickness.Left, thickness.Top, thickness.Right, thickness.Bottom); +#endif + } + catch { } + + return null; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/EmoTracker.UI/Converters/TrivialEnumConverter.cs b/EmoTracker.UI/Converters/TrivialEnumConverter.cs index fa0d73e..f0fa5b4 100644 --- a/EmoTracker.UI/Converters/TrivialEnumConverter.cs +++ b/EmoTracker.UI/Converters/TrivialEnumConverter.cs @@ -1,28 +1,31 @@ -using EmoTracker.Core; -using System; -using System.Globalization; -using System.Windows.Data; - -namespace EmoTracker.UI.Converters -{ - public class TrivialEnumConverter : Singleton, IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - try - { - return Enum.Parse(targetType, value.ToString()); - } - catch - { - } - - return null; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} +using EmoTracker.Core; +using System; +using System.Globalization; + +#if WINDOWS +using System.Windows.Data; +#else +using Avalonia.Data.Converters; +#endif + +namespace EmoTracker.UI.Converters +{ + public class TrivialEnumConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + try + { + return Enum.Parse(targetType, value.ToString()); + } + catch { } + + return null; + } + + 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 bdd9e02..250086b 100644 --- a/EmoTracker.UI/EmoTracker.UI.csproj +++ b/EmoTracker.UI/EmoTracker.UI.csproj @@ -10,6 +10,11 @@ false + + + $(DefineConstants);WINDOWS + + @@ -20,19 +25,28 @@ - + + + - + - + + + + + + + diff --git a/EmoTracker.UI/Media/ImageReferenceService.cs b/EmoTracker.UI/Media/ImageReferenceService.cs index 7e79219..c46d063 100644 --- a/EmoTracker.UI/Media/ImageReferenceService.cs +++ b/EmoTracker.UI/Media/ImageReferenceService.cs @@ -1,51 +1,81 @@ -using EmoTracker.Core; -using EmoTracker.Data; -using EmoTracker.Data.Media; -using EmoTracker.UI.Media.Resolvers; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows.Media; - -namespace EmoTracker.UI.Media -{ - public class ImageReferenceService : ObservableSingleton - { - Dictionary mCache = new Dictionary(); - - public void ClearImageCache() - { - mCache.Clear(); - } - - public ImageSource ResolveImageReference(ImageReference imageRef) - { - if (imageRef == null) - return null; - - ImageSource cachedSrc; - if (mCache.TryGetValue(imageRef, out cachedSrc)) - return cachedSrc; - - foreach (ImageReferenceResolver entry in TypedObjectRegistry.SupportRegistry) - { - if (entry.CanResolveReference(imageRef)) - { - ImageSource src = entry.ResolveReference(imageRef); - try - { - if (src != null && src.CanFreeze) - src.Freeze(); - } - catch { } - mCache[imageRef] = src; - return src; - } - } - - return null; - } - } -} +using EmoTracker.Core; +using EmoTracker.Data.Media; +using EmoTracker.UI.Media.Resolvers; +using System.Collections.Generic; + +#if WINDOWS +using System.Windows.Media; +#else +using Avalonia.Media; +#endif + +namespace EmoTracker.UI.Media +{ + public class ImageReferenceService : ObservableSingleton + { +#if WINDOWS + Dictionary mCache = new Dictionary(); + + public void ClearImageCache() + { + mCache.Clear(); + } + + public ImageSource ResolveImageReference(ImageReference imageRef) + { + if (imageRef == null) + return null; + + ImageSource cachedSrc; + if (mCache.TryGetValue(imageRef, out cachedSrc)) + return cachedSrc; + + foreach (ImageReferenceResolver entry in TypedObjectRegistry.SupportRegistry) + { + if (entry.CanResolveReference(imageRef)) + { + ImageSource src = entry.ResolveReference(imageRef); + try + { + if (src != null && src.CanFreeze) + src.Freeze(); + } + catch { } + mCache[imageRef] = src; + return src; + } + } + + return null; + } +#else + Dictionary mCache = new Dictionary(); + + public void ClearImageCache() + { + mCache.Clear(); + } + + public IImage ResolveImageReference(ImageReference imageRef) + { + if (imageRef == null) + return null; + + if (mCache.TryGetValue(imageRef, out IImage cachedSrc)) + return cachedSrc; + + foreach (ImageReferenceResolver entry in TypedObjectRegistry.SupportRegistry) + { + if (entry.CanResolveReference(imageRef)) + { + IImage src = entry.ResolveReference(imageRef); + mCache[imageRef] = src; + return src; + } + } + + return null; + } +#endif + } +} diff --git a/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs b/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs index b03e0ea..8f1333a 100644 --- a/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs +++ b/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs @@ -1,46 +1,51 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows.Media; -using EmoTracker.Data; -using EmoTracker.Data.Media; - -namespace EmoTracker.UI.Media.Resolvers -{ - public class ConcreteImageReferenceResolver : ImageReferenceResolver - { - public override bool CanResolveReference(ImageReference imageRef) - { - return imageRef as ConcreteImageReference != null; - } - - public override ImageSource 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.IO; + +#if WINDOWS +using System.Windows.Media; +#else +using Avalonia.Media; +#endif + +namespace EmoTracker.UI.Media.Resolvers +{ + public class ConcreteImageReferenceResolver : ImageReferenceResolver + { + public override bool CanResolveReference(ImageReference imageRef) + { + return imageRef as ConcreteImageReference != null; + } + +#if WINDOWS + public override ImageSource ResolveReference(ImageReference imageRef) +#else + public override IImage ResolveReference(ImageReference imageRef) +#endif + { + 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); + } + } +} diff --git a/EmoTracker.UI/Media/Resolvers/FilterImageReferenceResolver.cs b/EmoTracker.UI/Media/Resolvers/FilterImageReferenceResolver.cs index 6ca4b54..824a9e6 100644 --- a/EmoTracker.UI/Media/Resolvers/FilterImageReferenceResolver.cs +++ b/EmoTracker.UI/Media/Resolvers/FilterImageReferenceResolver.cs @@ -1,24 +1,33 @@ -using EmoTracker.Data; -using EmoTracker.Data.Media; -using System.Windows.Media; - -namespace EmoTracker.UI.Media.Resolvers -{ - class FilterImageReferenceResolver : ImageReferenceResolver - { - public override bool CanResolveReference(ImageReference imageRef) - { - return imageRef as FilterImageReference != null; - } - - public override ImageSource ResolveReference(ImageReference imageRef) - { - FilterImageReference concreteRef = imageRef as FilterImageReference; - if (concreteRef == null) - return null; - - ImageSource baseImg = ImageReferenceService.Instance.ResolveImageReference(concreteRef.Reference); - return Utility.IconUtility.ApplyFilterSpecToImage(Tracker.Instance.ActiveGamePackage, baseImg, concreteRef.Filter); - } - } -} +using EmoTracker.Data; +using EmoTracker.Data.Media; + +#if WINDOWS +using System.Windows.Media; +#else +using Avalonia.Media; +#endif + +namespace EmoTracker.UI.Media.Resolvers +{ + class FilterImageReferenceResolver : ImageReferenceResolver + { + public override bool CanResolveReference(ImageReference imageRef) + { + return imageRef as FilterImageReference != null; + } + +#if WINDOWS + public override ImageSource ResolveReference(ImageReference imageRef) +#else + public override IImage ResolveReference(ImageReference imageRef) +#endif + { + 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); + } + } +} diff --git a/EmoTracker.UI/Media/Resolvers/ImageReferenceResolver.cs b/EmoTracker.UI/Media/Resolvers/ImageReferenceResolver.cs index 219f6c7..d37d7cd 100644 --- a/EmoTracker.UI/Media/Resolvers/ImageReferenceResolver.cs +++ b/EmoTracker.UI/Media/Resolvers/ImageReferenceResolver.cs @@ -1,12 +1,21 @@ -using EmoTracker.Data.Media; -using System.Windows.Media; - -namespace EmoTracker.UI.Media.Resolvers -{ - public abstract class ImageReferenceResolver - { - public abstract bool CanResolveReference(ImageReference imageRef); - - public abstract ImageSource ResolveReference(ImageReference imageRef); - } -} +using EmoTracker.Data.Media; + +#if WINDOWS +using System.Windows.Media; +#else +using Avalonia.Media; +#endif + +namespace EmoTracker.UI.Media.Resolvers +{ + public abstract class ImageReferenceResolver + { + public abstract bool CanResolveReference(ImageReference imageRef); + +#if WINDOWS + public abstract ImageSource ResolveReference(ImageReference imageRef); +#else + public abstract IImage ResolveReference(ImageReference imageRef); +#endif + } +} diff --git a/EmoTracker.UI/Media/Resolvers/LayeredImageReferenceResolver.cs b/EmoTracker.UI/Media/Resolvers/LayeredImageReferenceResolver.cs index 9efbe87..76d1288 100644 --- a/EmoTracker.UI/Media/Resolvers/LayeredImageReferenceResolver.cs +++ b/EmoTracker.UI/Media/Resolvers/LayeredImageReferenceResolver.cs @@ -1,40 +1,50 @@ -using EmoTracker.Data.Media; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows.Media; - -namespace EmoTracker.UI.Media.Resolvers -{ - class LayeredImageReferenceResolver : ImageReferenceResolver - { - public override bool CanResolveReference(ImageReference imageRef) - { - return imageRef as LayeredImageReference != null; - } - - public override ImageSource ResolveReference(ImageReference imageRef) - { - LayeredImageReference concreteRef = imageRef as LayeredImageReference; - if (concreteRef == null) - return null; - - if (concreteRef.Layers.Count == 0) - return null; - - ImageSource img = null; - foreach (ImageReference layerRef in concreteRef.Layers) - { - ImageSource layerImg = ImageReferenceService.Instance.ResolveImageReference(layerRef); - img = Utility.IconUtility.ApplyOverlayImage(img, layerImg); - } - - if (img != null) - img.Freeze(); - - return img; - } - } -} +using EmoTracker.Data.Media; + +#if WINDOWS +using System.Windows.Media; +#else +using Avalonia.Media; +#endif + +namespace EmoTracker.UI.Media.Resolvers +{ + class LayeredImageReferenceResolver : ImageReferenceResolver + { + public override bool CanResolveReference(ImageReference imageRef) + { + return imageRef as LayeredImageReference != null; + } + +#if WINDOWS + public override ImageSource ResolveReference(ImageReference imageRef) +#else + public override IImage ResolveReference(ImageReference imageRef) +#endif + { + LayeredImageReference concreteRef = imageRef as LayeredImageReference; + if (concreteRef == null) + return null; + + if (concreteRef.Layers.Count == 0) + return null; + +#if WINDOWS + ImageSource img = null; +#else + IImage img = null; +#endif + foreach (ImageReference layerRef in concreteRef.Layers) + { + var layerImg = ImageReferenceService.Instance.ResolveImageReference(layerRef); + img = Utility.IconUtility.ApplyOverlayImage(img, layerImg); + } + +#if WINDOWS + if (img != null) + img.Freeze(); +#endif + + return img; + } + } +} diff --git a/EmoTracker.UI/Media/Utility/IconUtility.cs b/EmoTracker.UI/Media/Utility/IconUtility.cs index 56acc6b..f7391c9 100644 --- a/EmoTracker.UI/Media/Utility/IconUtility.cs +++ b/EmoTracker.UI/Media/Utility/IconUtility.cs @@ -1,494 +1,748 @@ -using EmoTracker.Core; -using EmoTracker.Data; -using System; -using System.IO; -using System.Linq; -using System.Net.Cache; -using System.Windows.Media; -using System.Windows.Media.Imaging; - -namespace EmoTracker.UI.Media.Utility -{ - public class IconUtility : ObservableSingleton - { - private bool mbEnableDpiConversion = true; - - public bool EnableDpiConversion - { - get { return mbEnableDpiConversion; } - set { SetProperty(ref mbEnableDpiConversion, value); } - } - - - private static RequestCachePolicy RawImageRequestPolicy = new RequestCachePolicy(RequestCacheLevel.BypassCache); - - public static ImageSource GetImageRaw(Uri uri) - { - try - { - return new BitmapImage(uri) - { - UriCachePolicy = RawImageRequestPolicy - }; - } - catch - { - return null; - } - } - - public static ImageSource GetImage(Uri uri) - { - try - { - FormatConvertedBitmap srcImg = new FormatConvertedBitmap(); - srcImg.BeginInit(); - srcImg.DestinationFormat = PixelFormats.Bgra32; - srcImg.Source = new BitmapImage(uri); - srcImg.EndInit(); - - WriteableBitmap bmp = new WriteableBitmap(srcImg); - - byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; - bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); - - for (int y = 0; y < bmp.PixelHeight; ++y) - { - for (int x = 0; x < bmp.PixelWidth; ++x) - { - byte b = buffer[(y * bmp.BackBufferStride + x * 4) + 0]; - byte g = buffer[(y * bmp.BackBufferStride + x * 4) + 1]; - byte r = buffer[(y * bmp.BackBufferStride + x * 4) + 2]; - - if (r == 255 && g == 0 && b == 255) - { - buffer[(y * bmp.BackBufferStride + x * 4) + 3] = 0; - } - } - } - - if (IconUtility.Instance.EnableDpiConversion) - { - // Neutralize all images to 96dpi, which is the internal WPF standard - BitmapSource result = BitmapSource.Create(bmp.PixelWidth, bmp.PixelHeight, 96, 96, bmp.Format, bmp.Palette, buffer, bmp.BackBufferStride); - if (result != null) - result.Freeze(); - - return result; - } - else - { - bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); - bmp.Freeze(); - return bmp; - } - } - catch - { - return null; - } - - } - - public static ImageSource GetImage(Stream stream) - { - if (stream == null) - return null; - - try - { - BitmapImage baseImage = new BitmapImage(); - baseImage.BeginInit(); - baseImage.StreamSource = stream; - baseImage.CacheOption = BitmapCacheOption.OnLoad; - baseImage.EndInit(); - baseImage.Freeze(); - - FormatConvertedBitmap srcImg = new FormatConvertedBitmap(); - srcImg.BeginInit(); - srcImg.DestinationFormat = PixelFormats.Bgra32; - srcImg.Source = baseImage; - srcImg.EndInit(); - - WriteableBitmap bmp = new WriteableBitmap(srcImg); - - byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; - bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); - - for (int y = 0; y < bmp.PixelHeight; ++y) - { - for (int x = 0; x < bmp.PixelWidth; ++x) - { - byte b = buffer[(y * bmp.BackBufferStride + x * 4) + 0]; - byte g = buffer[(y * bmp.BackBufferStride + x * 4) + 1]; - byte r = buffer[(y * bmp.BackBufferStride + x * 4) + 2]; - - if (r == 255 && g == 0 && b == 255) - { - buffer[(y * bmp.BackBufferStride + x * 4) + 3] = 0; - } - } - } - - if (IconUtility.Instance.EnableDpiConversion) - { - // Neutralize all images to 96dpi, which is the internal WPF standard - BitmapSource result = BitmapSource.Create(bmp.PixelWidth, bmp.PixelHeight, 96, 96, bmp.Format, bmp.Palette, buffer, bmp.BackBufferStride); - if (result != null) - result.Freeze(); - - return result; - } - else - { - bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); - bmp.Freeze(); - return bmp; - } - } - catch - { - return null; - } - } - - public static ImageSource ApplyOverlayImage(IGamePackage package, ImageSource image, params string[] args) - { - if (package == null) - return image; - - if (args.Length >= 1) - { - ImageSource overlay = GetImage(package.Open(args[0])); - if (overlay != null) - return ApplyOverlayImage(image, overlay); - } - - return image; - } - - public static ImageSource ApplyOverlayImage(ImageSource image, ImageSource overlay) - { - if (overlay == null) - return image; - - if (image == null) - return overlay; - - if (image == null && overlay == null) - return null; - - WriteableBitmap bmp = new WriteableBitmap((BitmapSource)image); - WriteableBitmap overlayBMP = new WriteableBitmap((BitmapSource)overlay); - - if (overlayBMP.PixelWidth != bmp.PixelWidth || overlayBMP.PixelHeight != bmp.PixelHeight) - { - ScriptManager.Instance.OutputError("Not applying overlay to base image because dimensions don't match."); - return image; - } - - byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; - bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); - - byte[] overlayBuffer = new byte[overlayBMP.PixelHeight * overlayBMP.PixelWidth * 4]; - overlayBMP.CopyPixels(overlayBuffer, bmp.PixelWidth * 4, 0); - - for (int y = 0; y < bmp.PixelHeight; ++y) - { - for (int x = 0; x < bmp.PixelWidth; ++x) - { - byte b = buffer[(y * bmp.BackBufferStride + x * 4) + 0]; - byte g = buffer[(y * bmp.BackBufferStride + x * 4) + 1]; - byte r = buffer[(y * bmp.BackBufferStride + x * 4) + 2]; - byte srcAlpha = buffer[(y * bmp.BackBufferStride + x * 4) + 3]; - - byte ob = overlayBuffer[(y * overlayBMP.BackBufferStride + x * 4) + 0]; - byte og = overlayBuffer[(y * overlayBMP.BackBufferStride + x * 4) + 1]; - byte or = overlayBuffer[(y * overlayBMP.BackBufferStride + x * 4) + 2]; - byte oa = overlayBuffer[(y * overlayBMP.BackBufferStride + x * 4) + 3]; - - float alpha = oa / 255.0f; - float invAlpha = 1.0f - alpha; - - b = (byte)Math.Min(Math.Max((uint)((uint)ob * alpha + (uint)b * invAlpha), 0), 255); - g = (byte)Math.Min(Math.Max((uint)((uint)og * alpha + (uint)g * invAlpha), 0), 255); - r = (byte)Math.Min(Math.Max((uint)((uint)or * alpha + (uint)r * invAlpha), 0), 255); - - buffer[(y * bmp.BackBufferStride + x * 4) + 0] = b; - buffer[(y * bmp.BackBufferStride + x * 4) + 1] = g; - buffer[(y * bmp.BackBufferStride + x * 4) + 2] = r; - buffer[(y * bmp.BackBufferStride + x * 4) + 3] = Math.Max(srcAlpha, oa); - } - } - - bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); - bmp.Freeze(); - - return bmp; - } - - private static byte Lerp(byte a, byte b, float factor) - { - if (factor <= 0.0f) - return a; - - if (factor >= 1.0f) - return b; - - float raw = ((float)a * (1.0f - factor)) + ((float)b * factor); - byte value = (byte)(raw + 0.5f); - - return value; - } - - public enum LuminanceMode - { - Avg, - Average, - Max, - Blue, - Green, - Red, - BT709, - BT601 - } - - public static ImageSource MakeImageGrayscale(ImageSource image, LuminanceMode mode = LuminanceMode.Average, float saturation = 0.0f) - { - if (image == null) - return null; - - WriteableBitmap bmp = new WriteableBitmap((BitmapSource)image); - - byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; - bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); - - for (int y = 0; y < bmp.PixelHeight; ++y) - { - for (int x = 0; x < bmp.PixelWidth; ++x) - { - byte b = buffer[(y * bmp.BackBufferStride + x * 4) + 0]; - byte g = buffer[(y * bmp.BackBufferStride + x * 4) + 1]; - byte r = buffer[(y * bmp.BackBufferStride + x * 4) + 2]; - - byte bo = b; - byte go = g; - byte ro = r; - - switch (mode) - { - case LuminanceMode.Avg: - case LuminanceMode.Average: - b = g = r = (byte)((b + g + r) / 3.0); - break; - - case LuminanceMode.Max: - b = g = r = Math.Max(Math.Max(b, g), r); - break; - - case LuminanceMode.Blue: - g = r = b; - break; - - case LuminanceMode.Green: - b = r = g; - break; - - case LuminanceMode.Red: - b = g = r; - break; - - case LuminanceMode.BT709: - b = g = r = (byte)(((uint)r + (uint)r + (uint)b + (uint)g + (uint)g + (uint)g) / 6); - break; - - case LuminanceMode.BT601: - b = g = r = (byte)(((uint)r + (uint)r + (uint)b + (uint)g + (uint)g + (uint)g) >> 3); - break; - } - - - - // b = g = r = Math.Max(Math.Max(b, g), r); - // - - b = Lerp(b, bo, saturation); - g = Lerp(g, go, saturation); - r = Lerp(r, ro, saturation); - - buffer[(y * bmp.BackBufferStride + x * 4) + 0] = b; - buffer[(y * bmp.BackBufferStride + x * 4) + 1] = g; - buffer[(y * bmp.BackBufferStride + x * 4) + 2] = r; - } - } - - bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); - bmp.Freeze(); - - return bmp; - } - - public static ImageSource AdjustSaturation(IGamePackage package, ImageSource 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 ImageSource AdjustBrightness(IGamePackage package, ImageSource 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); - - WriteableBitmap bmp = new WriteableBitmap((BitmapSource)image); - - byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; - bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); - - for (int y = 0; y < bmp.PixelHeight; ++y) - { - for (int x = 0; x < bmp.PixelWidth; ++x) - { - byte b = (byte)(Math.Min(255.0f, Math.Max(0.0f, (buffer[(y * bmp.BackBufferStride + x * 4) + 0] * brightness)))); - byte g = (byte)(Math.Min(255.0f, Math.Max(0.0f, (buffer[(y * bmp.BackBufferStride + x * 4) + 1] * brightness)))); - byte r = (byte)(Math.Min(255.0f, Math.Max(0.0f, (buffer[(y * bmp.BackBufferStride + x * 4) + 2] * brightness)))); - - buffer[(y * bmp.BackBufferStride + x * 4) + 0] = b; - buffer[(y * bmp.BackBufferStride + x * 4) + 1] = g; - buffer[(y * bmp.BackBufferStride + x * 4) + 2] = r; - } - } - - bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); - bmp.Freeze(); - - return bmp; - } - } - - return image; - } - - public static ImageSource MakeImageDim(ImageSource image, int divisor = 2) - { - if (image == null) - return null; - - WriteableBitmap bmp = new WriteableBitmap((BitmapSource)image); - - byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; - bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); - - for (int y = 0; y < bmp.PixelHeight; ++y) - { - for (int x = 0; x < bmp.PixelWidth; ++x) - { - byte b = (byte)(buffer[(y * bmp.BackBufferStride + x * 4) + 0] - buffer[(y * bmp.BackBufferStride + x * 4) + 0] / divisor); - byte g = (byte)(buffer[(y * bmp.BackBufferStride + x * 4) + 1] - buffer[(y * bmp.BackBufferStride + x * 4) + 1] / divisor); - byte r = (byte)(buffer[(y * bmp.BackBufferStride + x * 4) + 2] - buffer[(y * bmp.BackBufferStride + x * 4) + 2] / divisor); - - buffer[(y * bmp.BackBufferStride + x * 4) + 0] = b; - buffer[(y * bmp.BackBufferStride + x * 4) + 1] = g; - buffer[(y * bmp.BackBufferStride + x * 4) + 2] = r; - } - } - - bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); - bmp.Freeze(); - - return bmp; - } - - public static ImageSource ApplyFilterSpecToImage(IGamePackage package, ImageSource 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); - } - } - } - } - - if (image != null) - image.Freeze(); - - return image; - } - - 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; - } - } -} +using EmoTracker.Core; +using EmoTracker.Data; +using System; +using System.IO; +using System.Linq; + +#if WINDOWS +using System.Net.Cache; +using System.Windows.Media; +using System.Windows.Media.Imaging; +#else +using Avalonia.Media; +using Avalonia.Media.Imaging; +using SkiaSharp; +using System.Collections.Generic; +#endif + +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; + } + +#if WINDOWS + private static RequestCachePolicy RawImageRequestPolicy = new RequestCachePolicy(RequestCacheLevel.BypassCache); + + public static ImageSource GetImageRaw(Uri uri) + { + try + { + return new BitmapImage(uri) + { + UriCachePolicy = RawImageRequestPolicy + }; + } + catch + { + return null; + } + } + + public static ImageSource GetImage(Uri uri) + { + try + { + FormatConvertedBitmap srcImg = new FormatConvertedBitmap(); + srcImg.BeginInit(); + srcImg.DestinationFormat = PixelFormats.Bgra32; + srcImg.Source = new BitmapImage(uri); + srcImg.EndInit(); + + WriteableBitmap bmp = new WriteableBitmap(srcImg); + + byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; + bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); + + for (int y = 0; y < bmp.PixelHeight; ++y) + { + for (int x = 0; x < bmp.PixelWidth; ++x) + { + byte b = buffer[(y * bmp.BackBufferStride + x * 4) + 0]; + byte g = buffer[(y * bmp.BackBufferStride + x * 4) + 1]; + byte r = buffer[(y * bmp.BackBufferStride + x * 4) + 2]; + + if (r == 255 && g == 0 && b == 255) + buffer[(y * bmp.BackBufferStride + x * 4) + 3] = 0; + } + } + + if (IconUtility.Instance.EnableDpiConversion) + { + BitmapSource result = BitmapSource.Create(bmp.PixelWidth, bmp.PixelHeight, 96, 96, bmp.Format, bmp.Palette, buffer, bmp.BackBufferStride); + if (result != null) + result.Freeze(); + return result; + } + else + { + bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); + bmp.Freeze(); + return bmp; + } + } + catch + { + return null; + } + } + + public static ImageSource GetImage(Stream stream) + { + if (stream == null) + return null; + + try + { + BitmapImage baseImage = new BitmapImage(); + baseImage.BeginInit(); + baseImage.StreamSource = stream; + baseImage.CacheOption = BitmapCacheOption.OnLoad; + baseImage.EndInit(); + baseImage.Freeze(); + + FormatConvertedBitmap srcImg = new FormatConvertedBitmap(); + srcImg.BeginInit(); + srcImg.DestinationFormat = PixelFormats.Bgra32; + srcImg.Source = baseImage; + srcImg.EndInit(); + + WriteableBitmap bmp = new WriteableBitmap(srcImg); + + byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; + bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); + + for (int y = 0; y < bmp.PixelHeight; ++y) + { + for (int x = 0; x < bmp.PixelWidth; ++x) + { + byte b = buffer[(y * bmp.BackBufferStride + x * 4) + 0]; + byte g = buffer[(y * bmp.BackBufferStride + x * 4) + 1]; + byte r = buffer[(y * bmp.BackBufferStride + x * 4) + 2]; + + if (r == 255 && g == 0 && b == 255) + buffer[(y * bmp.BackBufferStride + x * 4) + 3] = 0; + } + } + + if (IconUtility.Instance.EnableDpiConversion) + { + BitmapSource result = BitmapSource.Create(bmp.PixelWidth, bmp.PixelHeight, 96, 96, bmp.Format, bmp.Palette, buffer, bmp.BackBufferStride); + if (result != null) + result.Freeze(); + return result; + } + else + { + bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); + bmp.Freeze(); + return bmp; + } + } + catch + { + return null; + } + } + + public static ImageSource ApplyOverlayImage(IGamePackage package, ImageSource image, params string[] args) + { + if (package == null) + return image; + + if (args.Length >= 1) + { + ImageSource overlay = GetImage(package.Open(args[0])); + if (overlay != null) + return ApplyOverlayImage(image, overlay); + } + + return image; + } + + public static ImageSource ApplyOverlayImage(ImageSource image, ImageSource overlay) + { + if (overlay == null) + return image; + + if (image == null) + return overlay; + + WriteableBitmap bmp = new WriteableBitmap((BitmapSource)image); + WriteableBitmap overlayBMP = new WriteableBitmap((BitmapSource)overlay); + + if (overlayBMP.PixelWidth != bmp.PixelWidth || overlayBMP.PixelHeight != bmp.PixelHeight) + { + ScriptManager.Instance.OutputError("Not applying overlay to base image because dimensions don't match."); + return image; + } + + byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; + bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); + + byte[] overlayBuffer = new byte[overlayBMP.PixelHeight * overlayBMP.PixelWidth * 4]; + overlayBMP.CopyPixels(overlayBuffer, bmp.PixelWidth * 4, 0); + + for (int y = 0; y < bmp.PixelHeight; ++y) + { + for (int x = 0; x < bmp.PixelWidth; ++x) + { + byte b = buffer[(y * bmp.BackBufferStride + x * 4) + 0]; + byte g = buffer[(y * bmp.BackBufferStride + x * 4) + 1]; + byte r = buffer[(y * bmp.BackBufferStride + x * 4) + 2]; + byte srcAlpha = buffer[(y * bmp.BackBufferStride + x * 4) + 3]; + + byte ob = overlayBuffer[(y * overlayBMP.BackBufferStride + x * 4) + 0]; + byte og = overlayBuffer[(y * overlayBMP.BackBufferStride + x * 4) + 1]; + byte or = overlayBuffer[(y * overlayBMP.BackBufferStride + x * 4) + 2]; + byte oa = overlayBuffer[(y * overlayBMP.BackBufferStride + x * 4) + 3]; + + float alpha = oa / 255.0f; + float invAlpha = 1.0f - alpha; + + b = (byte)Math.Min(Math.Max((uint)((uint)ob * alpha + (uint)b * invAlpha), 0), 255); + g = (byte)Math.Min(Math.Max((uint)((uint)og * alpha + (uint)g * invAlpha), 0), 255); + r = (byte)Math.Min(Math.Max((uint)((uint)or * alpha + (uint)r * invAlpha), 0), 255); + + buffer[(y * bmp.BackBufferStride + x * 4) + 0] = b; + buffer[(y * bmp.BackBufferStride + x * 4) + 1] = g; + buffer[(y * bmp.BackBufferStride + x * 4) + 2] = r; + buffer[(y * bmp.BackBufferStride + x * 4) + 3] = Math.Max(srcAlpha, oa); + } + } + + bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); + bmp.Freeze(); + return bmp; + } + + 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 ImageSource MakeImageGrayscale(ImageSource image, LuminanceMode mode = LuminanceMode.Average, float saturation = 0.0f) + { + if (image == null) + return null; + + WriteableBitmap bmp = new WriteableBitmap((BitmapSource)image); + + byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; + bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); + + for (int y = 0; y < bmp.PixelHeight; ++y) + { + for (int x = 0; x < bmp.PixelWidth; ++x) + { + byte b = buffer[(y * bmp.BackBufferStride + x * 4) + 0]; + byte g = buffer[(y * bmp.BackBufferStride + x * 4) + 1]; + byte r = buffer[(y * bmp.BackBufferStride + x * 4) + 2]; + + byte bo = b, go = g, ro = r; + + switch (mode) + { + case LuminanceMode.Avg: + case LuminanceMode.Average: b = g = r = (byte)((b + g + r) / 3.0); break; + case LuminanceMode.Max: b = g = r = Math.Max(Math.Max(b, g), r); break; + case LuminanceMode.Blue: g = r = b; break; + case LuminanceMode.Green: b = r = g; break; + case LuminanceMode.Red: b = g = r; break; + case LuminanceMode.BT709: b = g = r = (byte)(((uint)r + (uint)r + (uint)b + (uint)g + (uint)g + (uint)g) / 6); break; + case LuminanceMode.BT601: b = g = r = (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); + + buffer[(y * bmp.BackBufferStride + x * 4) + 0] = b; + buffer[(y * bmp.BackBufferStride + x * 4) + 1] = g; + buffer[(y * bmp.BackBufferStride + x * 4) + 2] = r; + } + } + + bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); + bmp.Freeze(); + return bmp; + } + + public static ImageSource AdjustSaturation(IGamePackage package, ImageSource 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 ImageSource AdjustBrightness(IGamePackage package, ImageSource 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); + + WriteableBitmap bmp = new WriteableBitmap((BitmapSource)image); + byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; + bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); + + for (int y = 0; y < bmp.PixelHeight; ++y) + { + for (int x = 0; x < bmp.PixelWidth; ++x) + { + buffer[(y * bmp.BackBufferStride + x * 4) + 0] = (byte)(Math.Min(255.0f, Math.Max(0.0f, buffer[(y * bmp.BackBufferStride + x * 4) + 0] * brightness))); + buffer[(y * bmp.BackBufferStride + x * 4) + 1] = (byte)(Math.Min(255.0f, Math.Max(0.0f, buffer[(y * bmp.BackBufferStride + x * 4) + 1] * brightness))); + buffer[(y * bmp.BackBufferStride + x * 4) + 2] = (byte)(Math.Min(255.0f, Math.Max(0.0f, buffer[(y * bmp.BackBufferStride + x * 4) + 2] * brightness))); + } + } + + bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); + bmp.Freeze(); + return bmp; + } + } + + return image; + } + + public static ImageSource MakeImageDim(ImageSource image, int divisor = 2) + { + if (image == null) + return null; + + WriteableBitmap bmp = new WriteableBitmap((BitmapSource)image); + byte[] buffer = new byte[bmp.PixelHeight * bmp.PixelWidth * 4]; + bmp.CopyPixels(buffer, bmp.PixelWidth * 4, 0); + + for (int y = 0; y < bmp.PixelHeight; ++y) + { + for (int x = 0; x < bmp.PixelWidth; ++x) + { + buffer[(y * bmp.BackBufferStride + x * 4) + 0] = (byte)(buffer[(y * bmp.BackBufferStride + x * 4) + 0] - buffer[(y * bmp.BackBufferStride + x * 4) + 0] / divisor); + buffer[(y * bmp.BackBufferStride + x * 4) + 1] = (byte)(buffer[(y * bmp.BackBufferStride + x * 4) + 1] - buffer[(y * bmp.BackBufferStride + x * 4) + 1] / divisor); + buffer[(y * bmp.BackBufferStride + x * 4) + 2] = (byte)(buffer[(y * bmp.BackBufferStride + x * 4) + 2] - buffer[(y * bmp.BackBufferStride + x * 4) + 2] / divisor); + } + } + + bmp.WritePixels(new System.Windows.Int32Rect(0, 0, bmp.PixelWidth, bmp.PixelHeight), buffer, bmp.PixelWidth * 4, 0); + bmp.Freeze(); + return bmp; + } + + public static ImageSource ApplyFilterSpecToImage(IGamePackage package, ImageSource 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); + } + } + } + + if (image != null) + image.Freeze(); + + return image; + } + +#else + // ── Avalonia / SkiaSharp image pipeline ────────────────────────────────── + + // Alpha masks keyed by IImage: bool[] of length (width * height), true = opaque + private static readonly Dictionary sAlphaMasks = new(); + + /// 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. + private static SKBitmap ToSkBitmap(IImage image) + { + if (image is not Avalonia.Media.Imaging.Bitmap avBitmap) + return null; + try + { + using var ms = new MemoryStream(); + avBitmap.Save(ms); + ms.Position = 0; + return SKBitmap.Decode(ms); + } + 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); + using var ms = new MemoryStream(encoded.ToArray()); + var avBitmap = new Avalonia.Media.Imaging.Bitmap(ms); + + 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; + } + + public static IImage GetImageRaw(Uri uri) + { + try + { + if (uri.IsFile) + return new Avalonia.Media.Imaging.Bitmap(uri.LocalPath); + return null; + } + catch { return null; } + } + + public static IImage GetImage(Uri uri) + { + try + { + if (!uri.IsFile) return null; + using var stream = File.OpenRead(uri.LocalPath); + return GetImage(stream); + } + catch { return null; } + } + + public static IImage GetImage(Stream stream) + { + if (stream == null) + return null; + try + { + SKBitmap bmp = SKBitmap.Decode(stream); + if (bmp == null) return null; + + // 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; + } +#endif + } +} diff --git a/EmoTracker.UI/Properties/AssemblyInfo.cs b/EmoTracker.UI/Properties/AssemblyInfo.cs index 693fccb..33ecf6b 100644 --- a/EmoTracker.UI/Properties/AssemblyInfo.cs +++ b/EmoTracker.UI/Properties/AssemblyInfo.cs @@ -2,7 +2,6 @@ using System.Resources; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using System.Windows; // General Information about an assembly is controlled through the following // set of attributes. Change these attribute values to modify the information @@ -31,14 +30,12 @@ //[assembly: NeutralResourcesLanguage("en-US", UltimateResourceFallbackLocation.Satellite)] -[assembly:ThemeInfo( - ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located - //(used if a resource is not found in the page, - // or application resource dictionaries) - ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located - //(used if a resource is not found in the page, - // app, or any theme specific resource dictionaries) +#if WINDOWS +[assembly: System.Windows.ThemeInfo( + System.Windows.ResourceDictionaryLocation.None, + System.Windows.ResourceDictionaryLocation.SourceAssembly )] +#endif // Version information for an assembly consists of the following four values: From 7d4022181ade50dce71f20a0d8225c2e2e05db2d Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sat, 4 Apr 2026 17:06:27 -0700 Subject: [PATCH 004/149] Phase 6: Migrate main EmoTracker app to Avalonia (net8.0 target compiles clean) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Avalonia 11.2.7 packages and Program.cs entry point for net8.0 - Add App.axaml / App.axaml.cs using OnFrameworkInitializationCompleted - Add MainWindow.axaml / MainWindow.axaml.cs (Avalonia Window, custom chrome) - Port all 16 UI AXAML controls: TrackableItemControl, LayoutControl, LocationControl, LocationMapControl, ChestListControl, CapturableItemControl, NoteTakingIconPopup, NoteTakingSiteView, MarkdownTextNoteControl, OverrideExportDialog, PackageManagerWindow, DeveloperConsole, AppUpdateWindow (stub), GroupedLocationListControl, ItemGridControl, TwitchStatusIndicator, VariantSwitcherControl - Update DispatchService / DialogService / WindowService with #if WINDOWS guards - Guard ApplicationModel ShowDialog calls and ListCollectionView for net8.0 - Add NullToFalseConverter, NonZeroToBoolConverter, BoolInverseConverter, InverseBoolConverter to EmoTracker.UI.Converters - Fix LayoutControl.axaml: DataTemplates→UserControl.DataTemplates, GroupBox→Border - Fix AppUpdateWindow.axaml: remove EmoTracker.Update namespace reference - Fix CapturableItemControl.axaml: StrokeDashArray comma syntax, PlacementMode Both net8.0 and net8.0-windows targets now build with 0 errors. Co-Authored-By: Claude Sonnet 4.6 --- .../Converters/VisibilityConverters.cs | 66 +++ EmoTracker/App.axaml | 10 + EmoTracker/App.axaml.cs | 102 ++++ EmoTracker/ApplicationModel.cs | 77 +++- EmoTracker/EmoTracker.csproj | 48 +- .../Extensions/Twitch/TwitchExtension.cs | 18 +- .../Twitch/TwitchStatusIndicator.axaml | 22 + .../Twitch/TwitchStatusIndicator.axaml.cs | 63 +++ .../VariantSwitcherControl.axaml | 47 ++ .../VariantSwitcherControl.axaml.cs | 34 ++ EmoTracker/MainWindow.axaml | 180 ++++++++ EmoTracker/MainWindow.axaml.cs | 230 ++++++++++ EmoTracker/Program.cs | 21 + EmoTracker/Services/DialogService.cs | 24 +- EmoTracker/Services/DispatchService.cs | 54 ++- EmoTracker/Services/WindowService.cs | 56 ++- EmoTracker/UI/AppUpdateWindow.axaml | 15 + EmoTracker/UI/AppUpdateWindow.axaml.cs | 23 + EmoTracker/UI/CapturableItemControl.axaml | 98 ++++ EmoTracker/UI/CapturableItemControl.axaml.cs | 132 ++++++ EmoTracker/UI/ChestListControl.axaml | 54 +++ EmoTracker/UI/ChestListControl.axaml.cs | 167 +++++++ EmoTracker/UI/DeveloperConsole.axaml | 51 ++ EmoTracker/UI/DeveloperConsole.axaml.cs | 72 +++ .../UI/GroupedLocationListControl.axaml | 74 +++ .../UI/GroupedLocationListControl.axaml.cs | 61 +++ EmoTracker/UI/ItemGridControl.axaml | 39 ++ EmoTracker/UI/ItemGridControl.axaml.cs | 15 + EmoTracker/UI/LayoutControl.axaml | 192 ++++++++ EmoTracker/UI/LayoutControl.axaml.cs | 15 + EmoTracker/UI/LocationControl.axaml | 143 ++++++ EmoTracker/UI/LocationControl.axaml.cs | 97 ++++ EmoTracker/UI/LocationMapControl.axaml | 115 +++++ EmoTracker/UI/LocationMapControl.axaml.cs | 275 +++++++++++ EmoTracker/UI/NoteTakingIconPopup.axaml | 45 ++ EmoTracker/UI/NoteTakingIconPopup.axaml.cs | 50 ++ EmoTracker/UI/NoteTakingSiteView.axaml | 168 +++++++ EmoTracker/UI/NoteTakingSiteView.axaml.cs | 95 ++++ .../UI/Notes/MarkdownTextNoteControl.axaml | 52 +++ .../UI/Notes/MarkdownTextNoteControl.axaml.cs | 146 ++++++ EmoTracker/UI/OverrideExportDialog.axaml | 99 ++++ EmoTracker/UI/OverrideExportDialog.axaml.cs | 178 +++++++ EmoTracker/UI/PackageManagerWindow.axaml | 434 ++++++++++++++++++ EmoTracker/UI/PackageManagerWindow.axaml.cs | 179 ++++++++ EmoTracker/UI/TrackableItemControl.axaml | 47 ++ EmoTracker/UI/TrackableItemControl.axaml.cs | 197 ++++++++ 46 files changed, 4321 insertions(+), 59 deletions(-) create mode 100644 EmoTracker.UI/Converters/VisibilityConverters.cs create mode 100644 EmoTracker/App.axaml create mode 100644 EmoTracker/App.axaml.cs create mode 100644 EmoTracker/Extensions/Twitch/TwitchStatusIndicator.axaml create mode 100644 EmoTracker/Extensions/Twitch/TwitchStatusIndicator.axaml.cs create mode 100644 EmoTracker/Extensions/VariantSwitcher/VariantSwitcherControl.axaml create mode 100644 EmoTracker/Extensions/VariantSwitcher/VariantSwitcherControl.axaml.cs create mode 100644 EmoTracker/MainWindow.axaml create mode 100644 EmoTracker/MainWindow.axaml.cs create mode 100644 EmoTracker/Program.cs create mode 100644 EmoTracker/UI/AppUpdateWindow.axaml create mode 100644 EmoTracker/UI/AppUpdateWindow.axaml.cs create mode 100644 EmoTracker/UI/CapturableItemControl.axaml create mode 100644 EmoTracker/UI/CapturableItemControl.axaml.cs create mode 100644 EmoTracker/UI/ChestListControl.axaml create mode 100644 EmoTracker/UI/ChestListControl.axaml.cs create mode 100644 EmoTracker/UI/DeveloperConsole.axaml create mode 100644 EmoTracker/UI/DeveloperConsole.axaml.cs create mode 100644 EmoTracker/UI/GroupedLocationListControl.axaml create mode 100644 EmoTracker/UI/GroupedLocationListControl.axaml.cs create mode 100644 EmoTracker/UI/ItemGridControl.axaml create mode 100644 EmoTracker/UI/ItemGridControl.axaml.cs create mode 100644 EmoTracker/UI/LayoutControl.axaml create mode 100644 EmoTracker/UI/LayoutControl.axaml.cs create mode 100644 EmoTracker/UI/LocationControl.axaml create mode 100644 EmoTracker/UI/LocationControl.axaml.cs create mode 100644 EmoTracker/UI/LocationMapControl.axaml create mode 100644 EmoTracker/UI/LocationMapControl.axaml.cs create mode 100644 EmoTracker/UI/NoteTakingIconPopup.axaml create mode 100644 EmoTracker/UI/NoteTakingIconPopup.axaml.cs create mode 100644 EmoTracker/UI/NoteTakingSiteView.axaml create mode 100644 EmoTracker/UI/NoteTakingSiteView.axaml.cs create mode 100644 EmoTracker/UI/Notes/MarkdownTextNoteControl.axaml create mode 100644 EmoTracker/UI/Notes/MarkdownTextNoteControl.axaml.cs create mode 100644 EmoTracker/UI/OverrideExportDialog.axaml create mode 100644 EmoTracker/UI/OverrideExportDialog.axaml.cs create mode 100644 EmoTracker/UI/PackageManagerWindow.axaml create mode 100644 EmoTracker/UI/PackageManagerWindow.axaml.cs create mode 100644 EmoTracker/UI/TrackableItemControl.axaml create mode 100644 EmoTracker/UI/TrackableItemControl.axaml.cs diff --git a/EmoTracker.UI/Converters/VisibilityConverters.cs b/EmoTracker.UI/Converters/VisibilityConverters.cs new file mode 100644 index 0000000..dc58118 --- /dev/null +++ b/EmoTracker.UI/Converters/VisibilityConverters.cs @@ -0,0 +1,66 @@ +using EmoTracker.Core; +using System; +using System.Globalization; + +#if WINDOWS +using System.Windows.Data; +#else +using Avalonia.Data.Converters; +#endif + +namespace EmoTracker.UI.Converters +{ + /// + /// Returns true when the value is non-null, false when null. + /// + public class NullToFalseConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value != null; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Returns true when the integer/uint value is non-zero. + /// + public class NonZeroToBoolConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is int i) return i != 0; + if (value is uint u) return u != 0; + if (value is long l) return l != 0; + if (value is double d) return d != 0; + return false; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Inverts a value. + /// + public class BoolInverseConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b ? !b : value; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b ? !b : value; + } + + /// + /// Alias for — inverts a value. + /// + public class InverseBoolConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b ? !b : value; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b ? !b : value; + } +} diff --git a/EmoTracker/App.axaml b/EmoTracker/App.axaml new file mode 100644 index 0000000..aa0a109 --- /dev/null +++ b/EmoTracker/App.axaml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/EmoTracker/App.axaml.cs b/EmoTracker/App.axaml.cs new file mode 100644 index 0000000..baa9ddd --- /dev/null +++ b/EmoTracker/App.axaml.cs @@ -0,0 +1,102 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using EmoTracker.Core; +using EmoTracker.Services; +using Serilog; +using Serilog.Events; +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace EmoTracker +{ + public partial class App : Avalonia.Application + { + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { +#if WINDOWS + ConfigurePlatformDllPaths(); +#endif + + Data.Core.Transactions.TransactionProcessor.SetTransactionProcessor( + new Data.Core.Transactions.Processors.LocalTransactionProcessorWithUndo()); + + Core.Services.Backends.LogService.SetServiceBackend(new Services.LogService()); + Core.Services.Backends.DispatchService.SetServiceBackend(new Services.DispatchService()); + + try + { + string logDirectory = Path.Combine(UserDirectory.Path, "logs"); + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .Enrich.FromLogContext() + .WriteTo.File(Path.Combine(logDirectory, "emotracker_log.txt"), + rollingInterval: RollingInterval.Day, + buffered: true, + flushToDiskInterval: TimeSpan.FromSeconds(5)) + .WriteTo.Console(restrictedToMinimumLevel: LogEventLevel.Information) + .WriteTo.DeveloperConsole() + .CreateLogger(); + } + catch (Exception) + { + } + + // Load application settings + Data.ApplicationSettings.CreateInstance(); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow(); + + desktop.Exit += (s, e) => + { + try + { + if (e.ApplicationExitCode == 0) + Extensions.ExtensionManager.Instance.OnApplicationClosing(); + +#if WINDOWS + if (Data.ApplicationSettings.Instance.EnableDiscordRichPresence) + { + try { DiscordRpc.ClearPresence(); DiscordRpc.Shutdown(); } + catch { } + } +#endif + } + catch { } + }; + } + + base.OnFrameworkInitializationCompleted(); + } + +#if WINDOWS + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetDllDirectory(string lpPathName); + + private static void ConfigurePlatformDllPaths() + { + try + { + string processorAssemblyPath = Environment.Is64BitProcess ? "x64" : "x86"; + string privateBinPath = Path.Combine( + AppDomain.CurrentDomain.SetupInformation.ApplicationBase, + processorAssemblyPath + "\\"); + SetDllDirectory(privateBinPath); + } + catch + { + throw new InvalidOperationException("Failed to set platform DLL search directory."); + } + } +#endif + } +} diff --git a/EmoTracker/ApplicationModel.cs b/EmoTracker/ApplicationModel.cs index 4151ae8..43e72c5 100644 --- a/EmoTracker/ApplicationModel.cs +++ b/EmoTracker/ApplicationModel.cs @@ -21,9 +21,11 @@ using System.ComponentModel; using System.IO; using System.Linq; +#if WINDOWS using System.Windows; using System.Windows.Controls.Primitives; using System.Windows.Data; +#endif namespace EmoTracker { @@ -129,14 +131,18 @@ public ApplicationModel() { InitializeNotifications(); +#if WINDOWS if (Application.Current is App) { +#endif // Force initialize core managers PackageManager.CreateInstance(); PackageManager.Instance.Initialize(); InitializePackageManagerViews(); +#if WINDOWS } +#endif Tracker.Instance.OnPackageLoadStarting += Tracker_OnPackageLoadStarting; Tracker.Instance.OnPackageLoadComplete += Tracker_OnPackageLoadComplete; @@ -186,16 +192,20 @@ public void Initialize() private void ShowBroadcastView(object obj) { +#if WINDOWS MainWindow appWindow = Application.Current.MainWindow as MainWindow; if (appWindow != null) appWindow.ShowBroadcastView(); +#endif } private void ShowDevleoperConsole(object obj) { +#if WINDOWS MainWindow appWindow = Application.Current.MainWindow as MainWindow; if (appWindow != null) appWindow.ShowDeveloperConsole(); +#endif } private void InstallPackage(object obj) @@ -318,27 +328,45 @@ private void ExportPackageOverrideHandler(object obj) } else { - OverrideExportDialog dialog = new OverrideExportDialog() - { - Owner = Application.Current.MainWindow - }; + OverrideExportDialog dialog = new OverrideExportDialog(); +#if WINDOWS + dialog.Owner = Application.Current.MainWindow; dialog.ShowDialog(); +#else + _ = dialog.ShowDialog( + (Avalonia.Application.Current?.ApplicationLifetime as + Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.MainWindow); +#endif } } } private void ShowPackManagerHandler(object obj) { - UI.PackageManagerWindow window = new UI.PackageManagerWindow() { Owner = Application.Current.MainWindow }; + UI.PackageManagerWindow window = new UI.PackageManagerWindow(); +#if WINDOWS + window.Owner = Application.Current.MainWindow; window.ShowDialog(); +#else + _ = window.ShowDialog( + (Avalonia.Application.Current?.ApplicationLifetime as + Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.MainWindow); +#endif WindowService.Instance.FocusMainWindow(); } private void CheckForUpdateHandler(object obj) { - UI.AppUpdateWindow window = new UI.AppUpdateWindow(false) { Owner = Application.Current.MainWindow }; + UI.AppUpdateWindow window = new UI.AppUpdateWindow(false); +#if WINDOWS + window.Owner = Application.Current.MainWindow; window.ShowDialog(); +#else + _ = window.ShowDialog( + (Avalonia.Application.Current?.ApplicationLifetime as + Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.MainWindow); +#endif WindowService.Instance.FocusMainWindow(); } @@ -752,7 +780,13 @@ public AvailablePackageViewFilterType AvailablePackageViewFilter set { if (SetProperty(ref mAvailablePackagesViewFilter, value)) + { +#if WINDOWS AvailablePackagesView.Refresh(); +#else + NotifyPropertyChanged(nameof(AvailablePackagesView)); +#endif + } } } @@ -777,6 +811,7 @@ public void SetAvailablePackageViewFilter(object param) } } +#if WINDOWS ListCollectionView mAvailablePackagesView; ListCollectionView mInstalledPackagesView; @@ -788,6 +823,15 @@ public CollectionView InstalledPackagesView { get { return mInstalledPackagesView; } } +#else + public IEnumerable AvailablePackagesView => + (PackageManager.Instance.AvailablePackages ?? Enumerable.Empty()) + .Where(PackageFilter) + .OrderBy(e => e.Game).ThenBy(e => e.Name); + + public IEnumerable InstalledPackagesView => + PackageManager.Instance.InstalledPackages ?? Enumerable.Empty(); +#endif void InitializePackageManagerViews() { @@ -795,9 +839,10 @@ void InitializePackageManagerViews() PackageManager.Instance.OnGameListDownloaded += PackageManager_OnGameListDownloaded; +#if WINDOWS mAvailablePackagesView = new ListCollectionView(PackageManager.Instance.AvailablePackages as IList); mAvailablePackagesView.CustomSort = new RepoEntryGameNameSort(); - mAvailablePackagesView.Filter = new Predicate(PackageFilter); + mAvailablePackagesView.Filter = new Predicate(PackageFilter); mAvailablePackagesView.GroupDescriptions.Add(new PropertyGroupDescription("Game", UI.Converters.GameNameToActualGameNameConverter.Instance)); mInstalledPackagesView = new ListCollectionView(PackageManager.Instance.InstalledPackages as IList); @@ -806,6 +851,7 @@ void InitializePackageManagerViews() AvailablePackagesView.Refresh(); InstalledPackagesView.Refresh(); +#endif // Configure auto-refresh for the package manager System.Timers.Timer timer = new System.Timers.Timer(TimeSpan.FromMinutes(30).TotalMilliseconds); @@ -828,8 +874,13 @@ private void OnRefreshPackageRepositoriesTimer(object sender, EventArgs e) private void PackageManager_OnGameListDownloaded(object sender, EventArgs e) { +#if WINDOWS AvailablePackagesView.Refresh(); InstalledPackagesView.Refresh(); +#else + NotifyPropertyChanged(nameof(AvailablePackagesView)); + NotifyPropertyChanged(nameof(InstalledPackagesView)); +#endif } private string mPackFilterText; @@ -847,7 +898,11 @@ public string PackFilterText private void RefreshPackageCollectionView() { +#if WINDOWS mAvailablePackagesView.Refresh(); +#else + NotifyPropertyChanged(nameof(AvailablePackagesView)); +#endif } private bool PackageFilter(object obj) @@ -1098,14 +1153,15 @@ private void NotificationExpirationTimer_Tick(object sender, EventArgs e) foreach (Notification n in mNotifications) { - FrameworkElement container = null; +#if WINDOWS + System.Windows.FrameworkElement container = null; { MainWindow appWindow = Application.Current.MainWindow as MainWindow; if (appWindow != null) { // This is bit lame, but WPF has some unfortunate limitations with respect to // handling completed events for animations triggered from DataTriggers - container = appWindow.NotificationsHost.ItemContainerGenerator.ContainerFromItem(n) as FrameworkElement; + container = appWindow.NotificationsHost.ItemContainerGenerator.ContainerFromItem(n) as System.Windows.FrameworkElement; } } @@ -1114,6 +1170,7 @@ private void NotificationExpirationTimer_Tick(object sender, EventArgs e) n.ExpirationTime = now; continue; } +#endif if (n.ExpirationTime <= now || n.Expired) { @@ -1123,11 +1180,13 @@ private void NotificationExpirationTimer_Tick(object sender, EventArgs e) { toRemove.Add(n); } +#if WINDOWS else if (container != null) { if (container.RenderSize.Height == 0) toRemove.Add(n); } +#endif } } diff --git a/EmoTracker/EmoTracker.csproj b/EmoTracker/EmoTracker.csproj index 476e91e..0c63762 100644 --- a/EmoTracker/EmoTracker.csproj +++ b/EmoTracker/EmoTracker.csproj @@ -3,7 +3,7 @@ {37EF8519-4426-455F-9D23-292839365EE0} WinExe - Library + Exe EmoTracker EmoTracker net8.0-windows;net8.0 @@ -64,6 +64,14 @@ + + + + + + + + @@ -76,23 +84,17 @@ - + - - - - + + - - - - @@ -104,13 +106,14 @@ - - - - + + + + + - - + + @@ -121,6 +124,17 @@ + + + + + + + + + + + diff --git a/EmoTracker/Extensions/Twitch/TwitchExtension.cs b/EmoTracker/Extensions/Twitch/TwitchExtension.cs index d61c72f..1878b3c 100644 --- a/EmoTracker/Extensions/Twitch/TwitchExtension.cs +++ b/EmoTracker/Extensions/Twitch/TwitchExtension.cs @@ -10,8 +10,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Windows; -using System.Windows.Media; using TwitchLib.Client; using TwitchLib.Client.Events; using TwitchLib.Client.Models; @@ -257,8 +255,8 @@ private void OnConnectionError(object sender, OnConnectionErrorArgs e) Dispatch.BeginInvoke(() => { if (e.Error != null) - MessageBox.Show(e.Error.Message, "Twitch Connection Error"); - MessageBox.Show(e.ToString(), "Twitch Connection Error"); + Services.DialogService.Instance.ShowOK("Twitch Connection Error", e.Error.Message); + Services.DialogService.Instance.ShowOK("Twitch Connection Error", e.ToString()); Disconnect(DisconnectReason.Error); }); @@ -414,11 +412,13 @@ private void HandleCommand(string[] args, ChatMessage src) mLastVFXCommandTime = System.DateTime.Now; - MainWindow main = Application.Current.MainWindow as MainWindow; +#if WINDOWS + MainWindow main = System.Windows.Application.Current.MainWindow as MainWindow; if (main != null && main.BroadcastView != null) { main.BroadcastView.Flush(); } +#endif }); return; } @@ -435,12 +435,13 @@ private void HandleCommand(string[] args, ChatMessage src) mLastVFXCommandTime = System.DateTime.Now; - MainWindow main = Application.Current.MainWindow as MainWindow; +#if WINDOWS + MainWindow main = System.Windows.Application.Current.MainWindow as MainWindow; if (main != null && main.BroadcastView != null) { if (args.Length > 1 && !string.IsNullOrWhiteSpace(args[1])) { - ImageSource img = IconUtility.GetImage(new Uri(Path.Combine(ExtensionManager.GetExtensionPath(this), string.Format("images/{0}.png", args[1])))); + System.Windows.Media.ImageSource img = IconUtility.GetImage(new Uri(Path.Combine(ExtensionManager.GetExtensionPath(this), string.Format("images/{0}.png", args[1])))); if (img == null) { Uri imageUri = new Uri(string.Format("pack://application:,,,/EmoTracker;component/Resources/{0}.png", args[1])); @@ -451,6 +452,7 @@ private void HandleCommand(string[] args, ChatMessage src) main.BroadcastView.Rain(img); } } +#endif }); return; } @@ -581,7 +583,7 @@ private void LoadPermissions() } catch { - MessageBox.Show("Your Documents\\EmoTracker\\extensions\\twitch_chat_hud\\user_permissions.json file has invalid JSON. Please correct it and restart the tracker.\n\n", "JSON Load Error", MessageBoxButton.OK); + Services.DialogService.Instance.ShowOK("JSON Load Error", "Your Documents\\EmoTracker\\extensions\\twitch_chat_hud\\user_permissions.json file has invalid JSON. Please correct it and restart the tracker."); } finally { diff --git a/EmoTracker/Extensions/Twitch/TwitchStatusIndicator.axaml b/EmoTracker/Extensions/Twitch/TwitchStatusIndicator.axaml new file mode 100644 index 0000000..9b10046 --- /dev/null +++ b/EmoTracker/Extensions/Twitch/TwitchStatusIndicator.axaml @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/EmoTracker/Extensions/Twitch/TwitchStatusIndicator.axaml.cs b/EmoTracker/Extensions/Twitch/TwitchStatusIndicator.axaml.cs new file mode 100644 index 0000000..11b8e39 --- /dev/null +++ b/EmoTracker/Extensions/Twitch/TwitchStatusIndicator.axaml.cs @@ -0,0 +1,63 @@ +using Avalonia.Controls; +using Avalonia.Media; +using EmoTracker.Data.Settings; +using System; +using System.ComponentModel; + +namespace EmoTracker.Extensions.Twitch +{ + public partial class TwitchStatusIndicator : UserControl + { + public TwitchStatusIndicator() + { + InitializeComponent(); + DataContextChanged += OnDataContextChanged; + } + + private TwitchExtension _extension; + + private void OnDataContextChanged(object sender, EventArgs e) + { + if (_extension != null) + _extension.PropertyChanged -= Extension_PropertyChanged; + + _extension = DataContext as TwitchExtension; + + if (_extension != null) + _extension.PropertyChanged += Extension_PropertyChanged; + + UpdateStatusColor(); + } + + private void Extension_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(TwitchExtension.ConnectionState)) + UpdateStatusColor(); + } + + private void UpdateStatusColor() + { + if (this.FindControl("StatusIcon") is not TextBlock icon) + return; + + if (_extension == null) + { + icon.Foreground = Brushes.WhiteSmoke; + return; + } + + switch (_extension.ConnectionState) + { + case ConnectionState.Connected: + icon.Foreground = SolidColorBrush.Parse(ApplicationColors.Instance.Status_Generic_Success); + break; + case ConnectionState.Connecting: + icon.Foreground = SolidColorBrush.Parse(ApplicationColors.Instance.Status_Generic_Warning); + break; + default: + icon.Foreground = Brushes.WhiteSmoke; + break; + } + } + } +} diff --git a/EmoTracker/Extensions/VariantSwitcher/VariantSwitcherControl.axaml b/EmoTracker/Extensions/VariantSwitcher/VariantSwitcherControl.axaml new file mode 100644 index 0000000..24e4306 --- /dev/null +++ b/EmoTracker/Extensions/VariantSwitcher/VariantSwitcherControl.axaml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EmoTracker/Extensions/VariantSwitcher/VariantSwitcherControl.axaml.cs b/EmoTracker/Extensions/VariantSwitcher/VariantSwitcherControl.axaml.cs new file mode 100644 index 0000000..60994e6 --- /dev/null +++ b/EmoTracker/Extensions/VariantSwitcher/VariantSwitcherControl.axaml.cs @@ -0,0 +1,34 @@ +using Avalonia.Controls; +using EmoTracker.Data; +using System; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; + +namespace EmoTracker.Extensions.VariantSwitcher +{ + public partial class VariantSwitcherControl : UserControl + { + public VariantSwitcherControl() + { + InitializeComponent(); + Tracker.Instance.PropertyChanged += Tracker_PropertyChanged; + UpdateFolderIconVisibility(); + } + + private void Tracker_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Tracker.ActiveGamePackage)) + UpdateFolderIconVisibility(); + } + + private void UpdateFolderIconVisibility() + { + if (this.FindControl("FolderIcon") is not TextBlock icon) + return; + + var count = Tracker.Instance.ActiveGamePackage?.AvailableVariants?.Count() ?? 0; + icon.IsVisible = count > 0; + } + } +} diff --git a/EmoTracker/MainWindow.axaml b/EmoTracker/MainWindow.axaml new file mode 100644 index 0000000..5a6e7c1 --- /dev/null +++ b/EmoTracker/MainWindow.axaml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EmoTracker/UI/NoteTakingSiteView.axaml.cs b/EmoTracker/UI/NoteTakingSiteView.axaml.cs new file mode 100644 index 0000000..89112f5 --- /dev/null +++ b/EmoTracker/UI/NoteTakingSiteView.axaml.cs @@ -0,0 +1,95 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data.Converters; +using Avalonia.Interactivity; +using Avalonia.VisualTree; +using EmoTracker.Data; +using System; +using System.Globalization; + +namespace EmoTracker.UI +{ + /// + /// Interaction logic for NoteTakingSiteView.axaml + /// + public partial class NoteTakingSiteView : UserControl + { + /// + /// Returns false when the value is null, true otherwise. + /// Used to hide the Items column when Items is null. + /// + public static readonly IValueConverter NullToFalseConverter = + new FuncValueConverter(v => v != null); + + public NoteTakingSiteView() + { + InitializeComponent(); + } + + private void DeleteNoteButton_Click(object? sender, RoutedEventArgs e) + { + if (sender is Control elem) + { + Data.Notes.Note? note = elem.DataContext as Data.Notes.Note; + if (note != null) + { + INoteTaking? noteContainer = DataContext as INoteTaking; + noteContainer?.RemoveNote(note); + } + } + } + + private void AddNoteButton_Click(object? sender, RoutedEventArgs e) + { + INoteTaking? noteContainer = DataContext as INoteTaking; + if (noteContainer != null) + { + var note = new Data.Notes.MarkdownTextWithItemsNote(); + noteContainer.AddNote(note); + + // Scroll the newly added note into view. + // ItemsControl doesn't expose ContainerFromItem directly in Avalonia; + // use the ListBox equivalent if the template is ever changed to a ListBox. + // For ItemsControl, we find the last visual child and bring it into view. + if (NotesItemsControl.ItemCount > 0) + { + // Trigger a layout pass so the new container exists, then scroll. + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + var panel = NotesItemsControl.ItemsPanelRoot; + if (panel != null && panel.Children.Count > 0) + { + var last = panel.Children[panel.Children.Count - 1]; + last.BringIntoView(); + } + }, Avalonia.Threading.DispatcherPriority.Loaded); + } + } + } + + private T? FindParentDataContextOfType(Visual? child) where T : class + { + if (child == null) + return null; + + if (child is StyledElement se && se.DataContext is T result) + return result; + + Visual? parent = child.GetVisualParent(); + return FindParentDataContextOfType(parent); + } + + private void RemoveNoteItemButton_Click(object? sender, RoutedEventArgs e) + { + if (sender is Control elem) + { + ITrackableItem? item = elem.DataContext as ITrackableItem; + if (item != null) + { + IItemCollection? items = FindParentDataContextOfType(elem); + items?.RemoveItem(item); + } + } + } + } +} diff --git a/EmoTracker/UI/Notes/MarkdownTextNoteControl.axaml b/EmoTracker/UI/Notes/MarkdownTextNoteControl.axaml new file mode 100644 index 0000000..3ac58bd --- /dev/null +++ b/EmoTracker/UI/Notes/MarkdownTextNoteControl.axaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + diff --git a/EmoTracker/UI/Notes/MarkdownTextNoteControl.axaml.cs b/EmoTracker/UI/Notes/MarkdownTextNoteControl.axaml.cs new file mode 100644 index 0000000..c1ab9b3 --- /dev/null +++ b/EmoTracker/UI/Notes/MarkdownTextNoteControl.axaml.cs @@ -0,0 +1,146 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using EmoTracker.UI.Controls; + +namespace EmoTracker.UI.Notes +{ + /// + /// Interaction logic for MarkdownTextNoteControl.axaml + /// + public partial class MarkdownTextNoteControl : ObservableUserControl + { + public MarkdownTextNoteControl() + { + InitializeComponent(); + + MarkdownSourceEditor.KeyDown += MarkdownSourceEditor_KeyDown; + MarkdownSourceEditor.LostFocus += MarkdownSourceEditor_LostFocus; + + // Show/hide the edit button based on edit mode and whether the source is empty. + // The edit button is visible when: + // - The control is enabled, AND + // - We are NOT in edit mode, AND + // - (the mouse is over EditorContainer OR MarkdownSourceEmpty is true) + // This is managed in code-behind since Avalonia does not support MultiDataTrigger in XAML. + UpdateEditButtonVisibility(); + } + + bool mbIsEditModeEnabled = false; + + /// + /// Gets or sets whether the markdown source editor is active. + /// Switching from edit mode back to view mode commits the binding. + /// + public bool IsEditModeEnabled + { + get => mbIsEditModeEnabled; + set + { + if (SetProperty(ref mbIsEditModeEnabled, value)) + { + // When leaving edit mode the TextBox binding uses UpdateSourceTrigger=LostFocus, + // so the source is already updated when focus leaves the editor. + // We only need to ensure the editor loses focus when edit mode is turned off + // programmatically (e.g. Escape key). + if (!mbIsEditModeEnabled && MarkdownSourceEditor.IsFocused) + { + // Moving focus away triggers the LostFocus binding update. + TopLevel.GetTopLevel(this)?.FocusManager?.ClearFocus(); + } + + UpdateEditButtonVisibility(); + } + } + } + + // ------------------------------------------------------------------ + // Edit-button visibility helper + // ------------------------------------------------------------------ + + private bool mbMarkdownSourceEmpty = true; + + /// + /// True when the bound MarkdownSource is null or empty. + /// The XAML DataContext should raise PropertyChanged for this property, + /// or the edit button visibility can be refreshed explicitly. + /// + public bool MarkdownSourceEmpty + { + get => mbMarkdownSourceEmpty; + set + { + if (SetProperty(ref mbMarkdownSourceEmpty, value)) + UpdateEditButtonVisibility(); + } + } + + private bool mbEditorContainerIsPointerOver = false; + + private void UpdateEditButtonVisibility() + { + if (EditButton == null) + return; + + bool showEdit = IsEnabled + && !mbIsEditModeEnabled + && (mbEditorContainerIsPointerOver || mbMarkdownSourceEmpty); + + EditButton.IsVisible = showEdit; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + EditorContainer.PointerEntered += EditorContainer_PointerEntered; + EditorContainer.PointerExited += EditorContainer_PointerExited; + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + + EditorContainer.PointerEntered -= EditorContainer_PointerEntered; + EditorContainer.PointerExited -= EditorContainer_PointerExited; + } + + private void EditorContainer_PointerEntered(object? sender, PointerEventArgs e) + { + mbEditorContainerIsPointerOver = true; + UpdateEditButtonVisibility(); + } + + private void EditorContainer_PointerExited(object? sender, PointerEventArgs e) + { + mbEditorContainerIsPointerOver = false; + UpdateEditButtonVisibility(); + } + + // ------------------------------------------------------------------ + // Event handlers + // ------------------------------------------------------------------ + + private void MarkdownSourceEditor_LostFocus(object? sender, RoutedEventArgs e) + { + // The binding (UpdateSourceTrigger=LostFocus) has already committed the value. + IsEditModeEnabled = false; + } + + private void MarkdownSourceEditor_KeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + IsEditModeEnabled = false; + e.Handled = true; + } + } + + private void EditButton_Click(object? sender, RoutedEventArgs e) + { + IsEditModeEnabled = true; + MarkdownSourceEditor.Focus(); + } + } +} diff --git a/EmoTracker/UI/OverrideExportDialog.axaml b/EmoTracker/UI/OverrideExportDialog.axaml new file mode 100644 index 0000000..b951d2a --- /dev/null +++ b/EmoTracker/UI/OverrideExportDialog.axaml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EmoTracker/UI/OverrideExportDialog.axaml.cs b/EmoTracker/UI/OverrideExportDialog.axaml.cs new file mode 100644 index 0000000..b98a78b --- /dev/null +++ b/EmoTracker/UI/OverrideExportDialog.axaml.cs @@ -0,0 +1,178 @@ +using Avalonia.Controls; +using Avalonia.Data.Converters; +using Avalonia.Interactivity; +using Avalonia.Media; +using EmoTracker.Core; +using EmoTracker.Data; +using EmoTracker.UI.Media.Utility; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace EmoTracker.UI +{ + /// + /// Interaction logic for OverrideExportDialog.axaml + /// + public partial class OverrideExportDialog : Window + { + // ------------------------------------------------------------------ + // Inner record type + // ------------------------------------------------------------------ + + public class FileRecord : EmoTracker.Core.ObservableObject + { + string mPath; + bool mbExportOverride = false; + + public bool ExportOverride + { + get => mbExportOverride; + set => SetProperty(ref mbExportOverride, value); + } + + public string Path => mPath; + + public FileRecord(string path) + { + mPath = path; + } + + /// + /// Visual preview control for the file (e.g. an ). + /// + public Control? Preview { get; set; } + + /// + /// Tooltip string shown next to the path. + /// + public string? ToolTip { get; set; } + } + + // ------------------------------------------------------------------ + // Converters + // ------------------------------------------------------------------ + + /// + /// Returns a highlight background when ExportOverride is true. + /// + public static readonly IValueConverter ExportOverrideToBackgroundConverter = + new FuncValueConverter(v => + v ? new SolidColorBrush(Color.Parse("#353535")) : Brushes.Transparent); + + /// + /// Returns bright-green foreground when ExportOverride is true. + /// + public static readonly IValueConverter ExportOverrideToForegroundConverter = + new FuncValueConverter(v => + v ? new SolidColorBrush(Color.Parse("#00ff00")) : Brushes.WhiteSmoke); + + // ------------------------------------------------------------------ + // State + // ------------------------------------------------------------------ + + private readonly List mFileRecords; + private readonly ObservableCollection mFilteredRecords; + + // Backing store for FilterText so we can refresh on change. + private string? mFilterText; + + public ObservableCollection Records => mFilteredRecords; + + public string? FilterText + { + get => mFilterText; + set + { + mFilterText = value; + RefreshFilter(); + } + } + + // ------------------------------------------------------------------ + // Constructor + // ------------------------------------------------------------------ + + public OverrideExportDialog() + { + mFileRecords = new List(); + + if (Tracker.Instance.ActiveGamePackage != null) + { + foreach (string file in Tracker.Instance.ActiveGamePackage.Source.Files) + { + Control? preview = null; + string? tooltipText = null; + + IImage? img = IconUtility.GetImage( + Tracker.Instance.ActiveGamePackage.Open(file, true, true)); + + bool bIsImagePreview = img != null; + + if (img == null) + { + // Not a pack image — try getting a filetype icon from resources. + string ext = System.IO.Path.GetExtension(file) + .TrimStart('.') + .ToLowerInvariant(); + img = IconUtility.GetImage( + new Uri($"avares://EmoTracker/Resources/filetype_{ext}.png")); + } + + if (img != null) + { + preview = new Image { Source = img }; + + if (bIsImagePreview) + tooltipText = file; + } + + mFileRecords.Add(new FileRecord(file) + { + Preview = preview, + ToolTip = tooltipText + }); + } + } + + mFilteredRecords = new ObservableCollection(mFileRecords); + + InitializeComponent(); + DataContext = this; + } + + // ------------------------------------------------------------------ + // Filter logic + // ------------------------------------------------------------------ + + private void RefreshFilter() + { + var filtered = string.IsNullOrWhiteSpace(mFilterText) + ? mFileRecords + : mFileRecords.Where(r => + r.Path.Contains(mFilterText, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + mFilteredRecords.Clear(); + foreach (var record in filtered) + mFilteredRecords.Add(record); + } + + // ------------------------------------------------------------------ + // Button handler + // ------------------------------------------------------------------ + + private void ExportOverridesButton_Click(object? sender, RoutedEventArgs e) + { + foreach (FileRecord record in mFileRecords) + { + if (record.ExportOverride) + { + Tracker.Instance.ActiveGamePackage?.ExportUserOverride(record.Path); + Close(); + } + } + } + } +} diff --git a/EmoTracker/UI/PackageManagerWindow.axaml b/EmoTracker/UI/PackageManagerWindow.axaml new file mode 100644 index 0000000..4b0e1de --- /dev/null +++ b/EmoTracker/UI/PackageManagerWindow.axaml @@ -0,0 +1,434 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EmoTracker/UI/TrackableItemControl.axaml.cs b/EmoTracker/UI/TrackableItemControl.axaml.cs new file mode 100644 index 0000000..9bba317 --- /dev/null +++ b/EmoTracker/UI/TrackableItemControl.axaml.cs @@ -0,0 +1,197 @@ +using Avalonia; +using Avalonia.Controls; +using EmoTracker.Data; +using EmoTracker.Data.Core.Transactions; +using System; +using System.Windows.Input; + +namespace EmoTracker.UI +{ + /// + /// Interaction logic for TrackableItemControl.axaml + /// + public partial class TrackableItemControl : UserControl + { + public interface IClickHandler + { + bool OnLeftClick(ITrackableItem item); + bool OnRightClick(ITrackableItem item); + } + + public TrackableItemControl() + { + mProgressCmd = new LeftClickCommand(this); + mRegressCmd = new RightClickCommand(this); + + InitializeComponent(); + } + + // ---- IconWidth ---- + public static readonly StyledProperty IconWidthProperty = + AvaloniaProperty.Register(nameof(IconWidth), defaultValue: 32.0); + + public double IconWidth + { + get => GetValue(IconWidthProperty); + set => SetValue(IconWidthProperty, value); + } + + // ---- IconHeight ---- + public static readonly StyledProperty IconHeightProperty = + AvaloniaProperty.Register(nameof(IconHeight), defaultValue: 32.0); + + public double IconHeight + { + get => GetValue(IconHeightProperty); + set => SetValue(IconHeightProperty, value); + } + + // ---- DisplayPotentialIcon (attached, inherits) ---- + public static readonly AttachedProperty DisplayPotentialIconProperty = + AvaloniaProperty.RegisterAttached( + "DisplayPotentialIcon", defaultValue: false, inherits: true); + + public static bool GetDisplayPotentialIcon(AvaloniaObject obj) => + obj.GetValue(DisplayPotentialIconProperty); + + public static void SetDisplayPotentialIcon(AvaloniaObject obj, bool value) => + obj.SetValue(DisplayPotentialIconProperty, value); + + // ---- DisplayCapturableOnly (attached, inherits) ---- + public static readonly AttachedProperty DisplayCapturableOnlyProperty = + AvaloniaProperty.RegisterAttached( + "DisplayCapturableOnly", defaultValue: false, inherits: true); + + public static bool GetDisplayCapturableOnly(AvaloniaObject obj) => + obj.GetValue(DisplayCapturableOnlyProperty); + + public static void SetDisplayCapturableOnly(AvaloniaObject obj, bool value) => + obj.SetValue(DisplayCapturableOnlyProperty, value); + + // ---- BadgeFontSize (attached, inherits) ---- + public static readonly AttachedProperty BadgeFontSizeProperty = + AvaloniaProperty.RegisterAttached( + "BadgeFontSize", defaultValue: 12.0, inherits: true); + + public static double GetBadgeFontSize(AvaloniaObject obj) => + obj.GetValue(BadgeFontSizeProperty); + + public static void SetBadgeFontSize(AvaloniaObject obj, double value) => + obj.SetValue(BadgeFontSizeProperty, value); + + // ---- ClickHandler (attached, inherits) ---- + public static readonly AttachedProperty ClickHandlerProperty = + AvaloniaProperty.RegisterAttached( + "ClickHandler", defaultValue: null, inherits: true); + + public static IClickHandler? GetClickHandler(AvaloniaObject obj) => + obj.GetValue(ClickHandlerProperty); + + public static void SetClickHandler(AvaloniaObject obj, IClickHandler? value) => + obj.SetValue(ClickHandlerProperty, value); + + #region --- Commands --- + + private class LeftClickCommand : ICommand + { + private readonly AvaloniaObject mOwner; + + public LeftClickCommand(AvaloniaObject owner) + { + mOwner = owner; + } + + public event EventHandler? CanExecuteChanged; + + public void NotifyCanExecutedChanged() + { + CanExecuteChanged?.Invoke(this, EventArgs.Empty); + } + + public bool CanExecute(object? parameter) => true; + + public void Execute(object? parameter) + { + try + { + LocationDatabase.Instance.SuspendRefresh = true; + + ITrackableItem? item = parameter as ITrackableItem; + if (item != null) + { + IClickHandler? interrupt = GetClickHandler(mOwner); + if (interrupt != null && interrupt.OnLeftClick(item)) + return; + + if (!item.IgnoreUserInput) + { + using (TransactionProcessor.Current.OpenTransaction()) + { + item.OnLeftClick(); + } + } + } + } + finally + { + LocationDatabase.Instance.SuspendRefresh = false; + } + } + } + + private class RightClickCommand : ICommand + { + private readonly AvaloniaObject mOwner; + + public RightClickCommand(AvaloniaObject owner) + { + mOwner = owner; + } + + public event EventHandler? CanExecuteChanged; + + public void NotifyCanExecutedChanged() + { + CanExecuteChanged?.Invoke(this, EventArgs.Empty); + } + + public bool CanExecute(object? parameter) => true; + + public void Execute(object? parameter) + { + try + { + LocationDatabase.Instance.SuspendRefresh = true; + + ITrackableItem? item = parameter as ITrackableItem; + if (item != null) + { + IClickHandler? interrupt = GetClickHandler(mOwner); + if (interrupt != null && interrupt.OnRightClick(item)) + return; + + if (!item.IgnoreUserInput) + { + using (TransactionProcessor.Current.OpenTransaction()) + { + item.OnRightClick(); + } + } + } + } + finally + { + LocationDatabase.Instance.SuspendRefresh = false; + } + } + } + + public ICommand OnLeftClickCommand => mProgressCmd; + public ICommand OnRightClickCommand => mRegressCmd; + + private readonly LeftClickCommand mProgressCmd; + private readonly RightClickCommand mRegressCmd; + + #endregion + } +} From 4e91e81de9072c0f3307d7cf159850d7967540a8 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sat, 4 Apr 2026 17:17:58 -0700 Subject: [PATCH 005/149] Phase 7 & 8: Async dialogs (Avalonia) and publish profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 7 — ApplicationModel async dialogs: - Add async method variants to IDialogService (ShowYesNoCancelAsync, ShowYesNoAsync, ShowOKAsync, OpenFileAsync, SaveFileAsync) - Add AvaloniaDialogService using MsBox.Avalonia 3.0.0-rc2 for message boxes and Avalonia StorageProvider for file pickers - WpfDialogService gains async impls wrapping sync via Task.FromResult - ApplicationModel: convert 6 command handlers to async void, using await on dialog calls (InstallPackage, UninstallPackage, RefreshHandler, ResetUserDataHandler, OpenHandler, SaveAsHandler) - Add MsBox.Avalonia 3.0.0-rc2 package reference for net8.0 target Phase 8 — Publish profiles: - Add Properties/PublishProfiles/ with four publish profiles: win-x64 (net8.0-windows, self-contained, single-file) osx-x64, osx-arm64, linux-x64 (net8.0, self-contained, single-file) - Usage: dotnet publish /p:PublishProfile= Both net8.0 and net8.0-windows targets build with 0 errors. Co-Authored-By: Claude Sonnet 4.6 --- EmoTracker/ApplicationModel.cs | 32 ++--- EmoTracker/EmoTracker.csproj | 1 + .../PublishProfiles/linux-x64.pubxml | 19 +++ .../PublishProfiles/osx-arm64.pubxml | 19 +++ .../Properties/PublishProfiles/osx-x64.pubxml | 19 +++ .../Properties/PublishProfiles/win-x64.pubxml | 19 +++ EmoTracker/Services/DialogService.cs | 127 +++++++++++++++++- EmoTracker/Services/IDialogService.cs | 33 ++--- 8 files changed, 230 insertions(+), 39 deletions(-) create mode 100644 EmoTracker/Properties/PublishProfiles/linux-x64.pubxml create mode 100644 EmoTracker/Properties/PublishProfiles/osx-arm64.pubxml create mode 100644 EmoTracker/Properties/PublishProfiles/osx-x64.pubxml create mode 100644 EmoTracker/Properties/PublishProfiles/win-x64.pubxml diff --git a/EmoTracker/ApplicationModel.cs b/EmoTracker/ApplicationModel.cs index 43e72c5..63754d7 100644 --- a/EmoTracker/ApplicationModel.cs +++ b/EmoTracker/ApplicationModel.cs @@ -208,7 +208,7 @@ private void ShowDevleoperConsole(object obj) #endif } - private void InstallPackage(object obj) + private async void InstallPackage(object obj) { var package = (PackageRepositoryEntry)obj; @@ -220,7 +220,7 @@ private void InstallPackage(object obj) { string msg = $"You have user overrides in place for {package.Name} which may cause issues after updating. Do you want to backup and disable your overrides prior to updating?"; string caption = "Uninstall Package"; - bool? res = DialogService.Instance.ShowYesNoCancel(caption, msg); + bool? res = await DialogService.Instance.ShowYesNoCancelAsync(caption, msg); switch (res) { @@ -238,7 +238,7 @@ private void InstallPackage(object obj) case BackupOverrideResult.Failed: msg = $"Unable to backup {package.Name} overrides. Check to make sure that no other application is using the folder or you do not have a backup instance already. Canceling update"; caption = "Backup Failed"; - DialogService.Instance.ShowOK(caption, msg); + await DialogService.Instance.ShowOKAsync(caption, msg); return; case BackupOverrideResult.Success: @@ -253,13 +253,13 @@ private void InstallPackage(object obj) package.Install(); } - private void UninstallPackage(object obj) + private async void UninstallPackage(object obj) { var package = (PackageRepositoryEntry)obj; string msg = $"You are about to uninstall \"{package.Name}\". This will remove all the files associated with the package as well as the overrides. Do you wish to continue?"; string caption = "Uninstall Package"; - bool res = DialogService.Instance.ShowYesNo(caption, msg); + bool res = await DialogService.Instance.ShowYesNoAsync(caption, msg); if(!res) { return; } @@ -271,12 +271,12 @@ private void UninstallPackage(object obj) case UninstallResult.FailedUninstall: msg = $"Failed to uninstall \"{package.Name}\"! Please ensure no other applications are using the file and try again."; caption = "Failed to Uninstall"; - DialogService.Instance.ShowOK(caption, msg); + await DialogService.Instance.ShowOKAsync(caption, msg); break; case UninstallResult.FailedOverrides: msg = $"Failed to remove \"{package.Name}\" overrides folder. You will need to remove it manually"; caption = "Failed to Remove Overrides"; - DialogService.Instance.ShowOK(caption, msg); + await DialogService.Instance.ShowOKAsync(caption, msg); break; } } @@ -371,11 +371,11 @@ private void CheckForUpdateHandler(object obj) WindowService.Instance.FocusMainWindow(); } - private void RefreshHandler(object param) + private async void RefreshHandler(object param) { if (ApplicationSettings.Instance.PromptOnRefreshClose) { - bool result = DialogService.Instance.ShowYesNo("Warning!", "Refreshing will cause you to lose all unsaved progress. Are you sure you want to refresh?", defaultYes: false); + bool result = await DialogService.Instance.ShowYesNoAsync("Warning!", "Refreshing will cause you to lose all unsaved progress. Are you sure you want to refresh?", defaultYes: false); if (!result) return; } @@ -384,13 +384,13 @@ private void RefreshHandler(object param) WindowService.Instance.FocusMainWindow(); } - private void ResetUserDataHandler(object param) + private async void ResetUserDataHandler(object param) { if (Tracker.Instance.ActiveGamePackage != null) { if (ApplicationSettings.Instance.PromptOnRefreshClose) { - bool result = DialogService.Instance.ShowYesNo("Warning!", "Clearing overrides will cause you to lose all unsaved progress. Are you sure you want to continue?", defaultYes: false); + bool result = await DialogService.Instance.ShowYesNoAsync("Warning!", "Clearing overrides will cause you to lose all unsaved progress. Are you sure you want to continue?", defaultYes: false); if (!result) return; } @@ -456,18 +456,18 @@ public void ResetLayoutScale(object obj = null) string mCurrentSavePath; - private void OpenHandler(object obj) + private async void OpenHandler(object obj) { string defaultSaveDataPath = Path.Combine(UserDirectory.Path, "saves"); - string filename = DialogService.Instance.OpenFile("EmoTracker Save File (*.json)|*.json", defaultSaveDataPath); + string filename = await DialogService.Instance.OpenFileAsync("EmoTracker Save File (*.json)|*.json", defaultSaveDataPath); if (filename != null) { if (!LoadProgress(filename)) { Reload(); - DialogService.Instance.ShowOK("Failed to load save data...", + await DialogService.Instance.ShowOKAsync("Failed to load save data...", "Failed to load the requested save file. Possible reasons include:\n\n" + "• The original pack or variant no longer exists\n" + "• The save data has been corruped\n" + @@ -499,7 +499,7 @@ private void SaveHandler(object obj) } } - private void SaveAsHandler(object obj) + private async void SaveAsHandler(object obj) { string defaultSaveDataPath = Path.Combine(UserDirectory.Path, "saves"); @@ -518,7 +518,7 @@ private void SaveAsHandler(object obj) ); } - string filename = DialogService.Instance.SaveFile("EmoTracker Save File (*.json)|*.json", defaultSaveDataPath); + string filename = await DialogService.Instance.SaveFileAsync("EmoTracker Save File (*.json)|*.json", defaultSaveDataPath); if (filename != null) { Directory.CreateDirectory(Path.GetDirectoryName(filename)); diff --git a/EmoTracker/EmoTracker.csproj b/EmoTracker/EmoTracker.csproj index 0c63762..520356b 100644 --- a/EmoTracker/EmoTracker.csproj +++ b/EmoTracker/EmoTracker.csproj @@ -70,6 +70,7 @@ + diff --git a/EmoTracker/Properties/PublishProfiles/linux-x64.pubxml b/EmoTracker/Properties/PublishProfiles/linux-x64.pubxml new file mode 100644 index 0000000..dddde92 --- /dev/null +++ b/EmoTracker/Properties/PublishProfiles/linux-x64.pubxml @@ -0,0 +1,19 @@ + + + + + Release + Any CPU + publish\linux-x64\ + FileSystem + net8.0 + linux-x64 + true + true + true + true + + diff --git a/EmoTracker/Properties/PublishProfiles/osx-arm64.pubxml b/EmoTracker/Properties/PublishProfiles/osx-arm64.pubxml new file mode 100644 index 0000000..07c85f5 --- /dev/null +++ b/EmoTracker/Properties/PublishProfiles/osx-arm64.pubxml @@ -0,0 +1,19 @@ + + + + + Release + Any CPU + publish\osx-arm64\ + FileSystem + net8.0 + osx-arm64 + true + true + true + true + + diff --git a/EmoTracker/Properties/PublishProfiles/osx-x64.pubxml b/EmoTracker/Properties/PublishProfiles/osx-x64.pubxml new file mode 100644 index 0000000..7f2faf0 --- /dev/null +++ b/EmoTracker/Properties/PublishProfiles/osx-x64.pubxml @@ -0,0 +1,19 @@ + + + + + Release + Any CPU + publish\osx-x64\ + FileSystem + net8.0 + osx-x64 + true + true + true + true + + diff --git a/EmoTracker/Properties/PublishProfiles/win-x64.pubxml b/EmoTracker/Properties/PublishProfiles/win-x64.pubxml new file mode 100644 index 0000000..3d6f1b7 --- /dev/null +++ b/EmoTracker/Properties/PublishProfiles/win-x64.pubxml @@ -0,0 +1,19 @@ + + + + + Release + Any CPU + publish\win-x64\ + FileSystem + net8.0-windows + win-x64 + true + true + true + true + + diff --git a/EmoTracker/Services/DialogService.cs b/EmoTracker/Services/DialogService.cs index c1d1620..ac978a0 100644 --- a/EmoTracker/Services/DialogService.cs +++ b/EmoTracker/Services/DialogService.cs @@ -1,6 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; #if WINDOWS using Microsoft.Win32; using System.Windows; +#else +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; +using MessageBox.Avalonia.Enums; +using MsBox.Avalonia; +using MsBox.Avalonia.Enums; #endif namespace EmoTracker.Services @@ -11,7 +22,7 @@ public static class DialogService #if WINDOWS new WpfDialogService(); #else - new HeadlessDialogService(); + new AvaloniaDialogService(); #endif public static IDialogService Instance => mInstance; public static void SetBackend(IDialogService service) { mInstance = service; } @@ -60,19 +71,127 @@ public string SaveFile(string filter, string initialDirectory) }; return dialog.ShowDialog() == true ? dialog.FileName : null; } + + public Task ShowYesNoCancelAsync(string title, string message) + => Task.FromResult(ShowYesNoCancel(title, message)); + + public Task ShowYesNoAsync(string title, string message, bool defaultYes = true) + => Task.FromResult(ShowYesNo(title, message, defaultYes)); + + public Task ShowOKAsync(string title, string message) + { + ShowOK(title, message); + return Task.CompletedTask; + } + + public Task OpenFileAsync(string filter, string initialDirectory) + => Task.FromResult(OpenFile(filter, initialDirectory)); + + public Task SaveFileAsync(string filter, string initialDirectory) + => Task.FromResult(SaveFile(filter, initialDirectory)); } #else /// - /// Fallback dialog service for the cross-platform Avalonia target. - /// Replaced by a proper Avalonia StorageProvider + MsBox implementation in Phase 7. + /// Avalonia dialog service using MsBox.Avalonia for message boxes and + /// Avalonia StorageProvider for file pickers. /// - public class HeadlessDialogService : IDialogService + public class AvaloniaDialogService : IDialogService { + private static Window? GetMainWindow() => + (Avalonia.Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow; + + // Sync methods — delegate to async versions; safe only when not on UI thread. + // Command handlers should use the Async variants instead. public bool? ShowYesNoCancel(string title, string message) => true; public bool ShowYesNo(string title, string message, bool defaultYes = true) => defaultYes; public void ShowOK(string title, string message) { } public string OpenFile(string filter, string initialDirectory) => null; public string SaveFile(string filter, string initialDirectory) => null; + + public async Task ShowYesNoCancelAsync(string title, string message) + { + var box = MessageBoxManager.GetMessageBoxStandard(title, message, ButtonEnum.YesNoCancel); + var result = await box.ShowWindowDialogAsync(GetMainWindow()); + return result switch + { + ButtonResult.Yes => (bool?)true, + ButtonResult.No => false, + _ => null + }; + } + + public async Task ShowYesNoAsync(string title, string message, bool defaultYes = true) + { + var box = MessageBoxManager.GetMessageBoxStandard(title, message, ButtonEnum.YesNo); + var result = await box.ShowWindowDialogAsync(GetMainWindow()); + return result == ButtonResult.Yes; + } + + public async Task ShowOKAsync(string title, string message) + { + var box = MessageBoxManager.GetMessageBoxStandard(title, message, ButtonEnum.Ok); + await box.ShowWindowDialogAsync(GetMainWindow()); + } + + public async Task OpenFileAsync(string filter, string initialDirectory) + { + var window = GetMainWindow(); + if (window == null) return null; + var topLevel = TopLevel.GetTopLevel(window); + if (topLevel == null) return null; + + IStorageFolder startFolder = null; + try { startFolder = await topLevel.StorageProvider.TryGetFolderFromPathAsync(new Uri(initialDirectory)); } + catch { /* ignore invalid path */ } + + var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Open File", + AllowMultiple = false, + SuggestedStartLocation = startFolder, + FileTypeFilter = ParseFilter(filter) + }); + return files.Count > 0 ? files[0].Path.LocalPath : null; + } + + public async Task SaveFileAsync(string filter, string initialDirectory) + { + var window = GetMainWindow(); + if (window == null) return null; + var topLevel = TopLevel.GetTopLevel(window); + if (topLevel == null) return null; + + IStorageFolder startFolder = null; + try { startFolder = await topLevel.StorageProvider.TryGetFolderFromPathAsync(new Uri(initialDirectory)); } + catch { /* ignore invalid path */ } + + var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Save File", + SuggestedStartLocation = startFolder, + FileTypeChoices = ParseFilter(filter) + }); + return file?.Path.LocalPath; + } + + /// + /// Converts a WPF-style filter string ("Description|*.ext|Description2|*.ext2") + /// to a list of objects for Avalonia's StorageProvider. + /// + private static IReadOnlyList ParseFilter(string wpfFilter) + { + var types = new List(); + if (string.IsNullOrEmpty(wpfFilter)) return types; + + var parts = wpfFilter.Split('|'); + for (int i = 0; i + 1 < parts.Length; i += 2) + { + var name = parts[i]; + var patterns = parts[i + 1].Split(';').Select(p => p.Trim()).ToList(); + types.Add(new FilePickerFileType(name) { Patterns = patterns }); + } + return types; + } } #endif } diff --git a/EmoTracker/Services/IDialogService.cs b/EmoTracker/Services/IDialogService.cs index 4cf0598..823f894 100644 --- a/EmoTracker/Services/IDialogService.cs +++ b/EmoTracker/Services/IDialogService.cs @@ -1,30 +1,25 @@ +using System.Threading.Tasks; + namespace EmoTracker.Services { public interface IDialogService { - /// - /// Shows a Yes/No/Cancel dialog. Returns true=Yes, false=No, null=Cancel. - /// + /// Shows a Yes/No/Cancel dialog. Returns true=Yes, false=No, null=Cancel. bool? ShowYesNoCancel(string title, string message); - - /// - /// Shows a Yes/No dialog. Returns true if the user chose Yes. - /// + /// Shows a Yes/No dialog. Returns true if the user chose Yes. bool ShowYesNo(string title, string message, bool defaultYes = true); - - /// - /// Shows a dialog with an OK button (used for errors and informational messages). - /// + /// Shows an OK dialog (errors/info). void ShowOK(string title, string message); - - /// - /// Shows an open-file picker. Returns the chosen path, or null if cancelled. - /// + /// Shows an open-file picker. Returns the chosen path, or null if cancelled. string OpenFile(string filter, string initialDirectory); - - /// - /// Shows a save-file picker. Returns the chosen path, or null if cancelled. - /// + /// Shows a save-file picker. Returns the chosen path, or null if cancelled. string SaveFile(string filter, string initialDirectory); + + // Async variants — preferred for Avalonia targets + System.Threading.Tasks.Task ShowYesNoCancelAsync(string title, string message); + System.Threading.Tasks.Task ShowYesNoAsync(string title, string message, bool defaultYes = true); + System.Threading.Tasks.Task ShowOKAsync(string title, string message); + System.Threading.Tasks.Task OpenFileAsync(string filter, string initialDirectory); + System.Threading.Tasks.Task SaveFileAsync(string filter, string initialDirectory); } } From dbbe9e879cd4f426c2d90a3956ebafd4fce7cedb Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sat, 4 Apr 2026 17:23:50 -0700 Subject: [PATCH 006/149] Add avalonia-win-x64 publish profile for testing Avalonia build on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targets net8.0 (Avalonia) with win-x64 RID — same OS as the WPF build but exercises the cross-platform code path without Windows-only extensions. Co-Authored-By: Claude Sonnet 4.6 --- .../PublishProfiles/avalonia-win-x64.pubxml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 EmoTracker/Properties/PublishProfiles/avalonia-win-x64.pubxml diff --git a/EmoTracker/Properties/PublishProfiles/avalonia-win-x64.pubxml b/EmoTracker/Properties/PublishProfiles/avalonia-win-x64.pubxml new file mode 100644 index 0000000..f1a01f2 --- /dev/null +++ b/EmoTracker/Properties/PublishProfiles/avalonia-win-x64.pubxml @@ -0,0 +1,21 @@ + + + + + Release + Any CPU + publish\avalonia-win-x64\ + FileSystem + net8.0 + win-x64 + true + true + true + true + + From eb6c325df06197224f4205398c9b75bfcaa524d3 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sat, 4 Apr 2026 17:40:35 -0700 Subject: [PATCH 007/149] Fix Avalonia startup crashes: pack:// URIs, missing resources, FontAwesome - Register pack: URI scheme in Program.cs so PackageManager/LocationDatabase field initializers (new Uri("pack://application:,,,/...")) don't throw UriFormatException on .NET 8 - Add avares:// and pack:// translation support to IconUtility.GetImage(Uri) so embedded resources are loaded via Avalonia AssetLoader - Add items to EmoTracker.csproj for net8.0 target so icons and Resources/** are embedded as Avalonia assets (not WPF ) - Define FontAwesome5Free/FontAwesome5Brands FontFamily resources in App.axaml to fix InvalidCastException from unresolved StaticResource keys - Fix MainWindow.axaml icon source to use avares:// URI - Add public parameterless constructor to AppUpdateWindow (AVLN3001) - Replace deprecated PlacementMode= with Placement= in two Popup declarations - Suppress NU1701 for WebSocketSharp (no net8.0 target, compat shim is fine) Co-Authored-By: Claude Sonnet 4.6 --- EmoTracker.UI/Media/Utility/IconUtility.cs | 44 +++++++++++++++++++--- EmoTracker/App.axaml | 5 +++ EmoTracker/EmoTracker.csproj | 14 +++++-- EmoTracker/MainWindow.axaml | 2 +- EmoTracker/Program.cs | 6 +++ EmoTracker/UI/AppUpdateWindow.axaml.cs | 2 + EmoTracker/UI/CapturableItemControl.axaml | 2 +- EmoTracker/UI/NoteTakingIconPopup.axaml | 2 +- 8 files changed, 66 insertions(+), 11 deletions(-) diff --git a/EmoTracker.UI/Media/Utility/IconUtility.cs b/EmoTracker.UI/Media/Utility/IconUtility.cs index f7391c9..cb02f66 100644 --- a/EmoTracker.UI/Media/Utility/IconUtility.cs +++ b/EmoTracker.UI/Media/Utility/IconUtility.cs @@ -467,12 +467,37 @@ private static IImage SkToAvalonia(SKBitmap bmp, bool storeMask = false) 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 { - if (uri.IsFile) - return new Avalonia.Media.Imaging.Bitmap(uri.LocalPath); + 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); return null; } catch { return null; } @@ -482,9 +507,18 @@ public static IImage GetImage(Uri uri) { try { - if (!uri.IsFile) return null; - using var stream = File.OpenRead(uri.LocalPath); - return GetImage(stream); + 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; } } diff --git a/EmoTracker/App.axaml b/EmoTracker/App.axaml index aa0a109..16df376 100644 --- a/EmoTracker/App.axaml +++ b/EmoTracker/App.axaml @@ -7,4 +7,9 @@ + + avares://EmoTracker/Resources/Fonts#Font Awesome 5 Free Solid + avares://EmoTracker/Resources/Fonts#Font Awesome 5 Brands Regular + + diff --git a/EmoTracker/EmoTracker.csproj b/EmoTracker/EmoTracker.csproj index 520356b..4dffcba 100644 --- a/EmoTracker/EmoTracker.csproj +++ b/EmoTracker/EmoTracker.csproj @@ -24,14 +24,22 @@ - - + + + + + + + + + + @@ -80,7 +88,7 @@ - + diff --git a/EmoTracker/Program.cs b/EmoTracker/Program.cs index e02bbec..cc78dab 100644 --- a/EmoTracker/Program.cs +++ b/EmoTracker/Program.cs @@ -8,6 +8,12 @@ internal class Program [STAThread] public static void Main(string[] args) { + // Register the WPF pack:// URI scheme so that pack://application:,,,/... URIs + // constructed in EmoTracker.Data (PackageManager, LocationDatabase) don't throw + // UriFormatException on .NET 8 where this scheme is not registered by default. + if (!UriParser.IsKnownScheme("pack")) + UriParser.Register(new GenericUriParser(GenericUriParserOptions.GenericAuthority), "pack", -1); + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); } diff --git a/EmoTracker/UI/AppUpdateWindow.axaml.cs b/EmoTracker/UI/AppUpdateWindow.axaml.cs index ec6c64b..80054d9 100644 --- a/EmoTracker/UI/AppUpdateWindow.axaml.cs +++ b/EmoTracker/UI/AppUpdateWindow.axaml.cs @@ -7,6 +7,8 @@ public partial class AppUpdateWindow : Window { private readonly bool _autoClose; + public AppUpdateWindow() : this(false) { } + public AppUpdateWindow(bool autoClose) { _autoClose = autoClose; diff --git a/EmoTracker/UI/CapturableItemControl.axaml b/EmoTracker/UI/CapturableItemControl.axaml index 3958f01..b669826 100644 --- a/EmoTracker/UI/CapturableItemControl.axaml +++ b/EmoTracker/UI/CapturableItemControl.axaml @@ -72,7 +72,7 @@ --> diff --git a/EmoTracker/UI/NoteTakingIconPopup.axaml b/EmoTracker/UI/NoteTakingIconPopup.axaml index bf589e8..83e9c5a 100644 --- a/EmoTracker/UI/NoteTakingIconPopup.axaml +++ b/EmoTracker/UI/NoteTakingIconPopup.axaml @@ -9,7 +9,7 @@ The popup is managed in code-behind via PopupInstance (a Popup control whose IsOpen is toggled by the toggle button click handler). --> From 3052b3c174885a668380d73930db670f102d5628 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sat, 4 Apr 2026 17:53:17 -0700 Subject: [PATCH 008/149] Fix Avalonia main window: pack display, drag, title bar clipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NoPackagePlaceholder was always visible (no IsVisible binding), covering the tracker content — add IsVisible binding via NullToTrueConverter on Tracker.Instance.ActiveGamePackage - LayoutControl DataContext binding used RelativeSource on MainWindow.ActiveLayout but the shadowed INotifyPropertyChanged event broke change notifications — replace with direct TrackerLayout.DataContext assignment in RefreshTrackerLayout() - Window dragging not implemented — add TitleBar_PointerPressed handler that calls BeginMoveDrag(e) on left-click of non-Button areas - ExtendClientAreaToDecorationsHint caused Avalonia to inset content by the OS title bar height, clipping the custom chrome buttons — remove the two extend client area attributes; SystemDecorations="BorderOnly" provides the resize border - Change Window Background from debug magenta (#ff00ff) to #111111 - Add NullToTrueConverter to VisibilityConverters.cs (returns true when null) Co-Authored-By: Claude Sonnet 4.6 --- .../Converters/VisibilityConverters.cs | 13 +++++ EmoTracker/MainWindow.axaml | 18 +++---- EmoTracker/MainWindow.axaml.cs | 48 ++++++++++--------- 3 files changed, 47 insertions(+), 32 deletions(-) diff --git a/EmoTracker.UI/Converters/VisibilityConverters.cs b/EmoTracker.UI/Converters/VisibilityConverters.cs index dc58118..3dc0016 100644 --- a/EmoTracker.UI/Converters/VisibilityConverters.cs +++ b/EmoTracker.UI/Converters/VisibilityConverters.cs @@ -10,6 +10,19 @@ namespace EmoTracker.UI.Converters { + /// + /// Returns true when the value is null, false when non-null. + /// Use for IsVisible bindings where the element should appear when no data is present. + /// + public class NullToTrueConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value == null; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + /// /// Returns true when the value is non-null, false when null. /// diff --git a/EmoTracker/MainWindow.axaml b/EmoTracker/MainWindow.axaml index f19b7d9..da4a6af 100644 --- a/EmoTracker/MainWindow.axaml +++ b/EmoTracker/MainWindow.axaml @@ -9,22 +9,22 @@ xmlns:ui="clr-namespace:EmoTracker.UI" xmlns:controls="clr-namespace:EmoTracker.UI.Controls;assembly=EmoTracker.UI" xmlns:media_utility="clr-namespace:EmoTracker.UI.Media.Utility;assembly=EmoTracker.UI" + xmlns:converters="clr-namespace:EmoTracker.UI.Converters;assembly=EmoTracker.UI" x:Class="EmoTracker.MainWindow" mc:Ignorable="d" Name="HostWindow" - Background="#ff00ff" + Background="#111111" MinWidth="220" MinHeight="61" Width="1280" Height="960" Title="{Binding MainWindowTitle, Source={x:Static local:ApplicationModel.Instance}}" Topmost="{Binding AlwaysOnTop, Source={x:Static data:ApplicationSettings.Instance}}" - ExtendClientAreaToDecorationsHint="True" - ExtendClientAreaChromeHints="NoChrome" SystemDecorations="BorderOnly"> - - + + @@ -117,12 +117,12 @@ ScaleX="{Binding MainLayoutScaleFactor, Source={x:Static local:ApplicationModel.Instance}}" ScaleY="{Binding MainLayoutScaleFactor, Source={x:Static local:ApplicationModel.Instance}}"/> - + - - + + Bounds.Width; + var layout = vertical + ? ApplicationModel.Instance.TrackerVerticalLayout + : ApplicationModel.Instance.TrackerHorizontalLayout; + if (TrackerLayout != null) + TrackerLayout.DataContext = layout; } protected override void OnSizeChanged(SizeChangedEventArgs e) @@ -196,21 +208,11 @@ protected override void OnSizeChanged(SizeChangedEventArgs e) bool bNewAspect = e.NewSize.Height > e.NewSize.Width; if (bOldAspect != bNewAspect) - { - NotifyPropertyChanged("UseVerticalOrientation"); - NotifyPropertyChanged("ActiveLayout"); - } + RefreshTrackerLayout(); base.OnSizeChanged(e); } - public bool UseVerticalOrientation => Bounds.Height > Bounds.Width; - - public Data.Layout.Layout ActiveLayout => - UseVerticalOrientation - ? ApplicationModel.Instance.TrackerVerticalLayout - : ApplicationModel.Instance.TrackerHorizontalLayout; - public UI.DeveloperConsole DeveloperConsole { get; private set; } public void ShowDeveloperConsole() From 86fd3206fa439f7999ba69754d0de73e58049520 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sat, 4 Apr 2026 18:23:50 -0700 Subject: [PATCH 009/149] Avalonia: fix layout rendering to match WPF visually MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LayoutControl.axaml: - Add ItemContainerTheme to DockPanel template so DockPanel.Dock is forwarded from each item's DockLocation string via StringToDockConverter - Add ItemContainerTheme to CanvasPanel template so Canvas.Left/Top/ZIndex are forwarded from each item's CanvasX/Y/Depth via CanvasPositionConverter and CanvasZIndexConverter - Add Width/Height (NegativeToNaN) and IsHitTestVisible bindings to all DataTemplate Grid wrappers, matching WPF LayoutItemStyle behaviour - Move GroupBox DataTemplate before Container: Avalonia uses first-match inheritance lookup, so the more-derived type must be declared first - Rewrite GroupBox DataTemplate as two-row Grid (header bar + content area) with HeaderBackground/Background colour bindings, matching WPF LayoutGroupBox LocationMapControl.axaml: - Add Background binding to map-location Border using new AccessibilityLevelToBrushConverter so accessibility colours are shown - Add IsVisible="{Binding Location.HasVisibleSections}" to location Grid VisibilityConverters.cs: - Add StringToDockConverter (string → Dock, case-insensitive) - Add NegativeToNaNDoubleConverter (−1 → NaN for Width/Height) - Add CanvasPositionConverter (≤0 → 0.0 for Canvas.Left/Top) - Add CanvasZIndexConverter (≤0 → 0 for Canvas.ZIndex) - Add StringToBrushConverter (colour name/hex string → IBrush) - Add AccessibilityLevelToBrushConverter (AccessibilityLevel → IBrush from ApplicationColors.Instance) Co-Authored-By: Claude Sonnet 4.6 --- .../Converters/VisibilityConverters.cs | 143 +++++++++++++++ EmoTracker/UI/LayoutControl.axaml | 167 +++++++++++++----- EmoTracker/UI/LocationMapControl.axaml | 6 +- 3 files changed, 269 insertions(+), 47 deletions(-) diff --git a/EmoTracker.UI/Converters/VisibilityConverters.cs b/EmoTracker.UI/Converters/VisibilityConverters.cs index 3dc0016..4f73f2f 100644 --- a/EmoTracker.UI/Converters/VisibilityConverters.cs +++ b/EmoTracker.UI/Converters/VisibilityConverters.cs @@ -1,11 +1,17 @@ using EmoTracker.Core; +using EmoTracker.Data.Locations; +using EmoTracker.Data.Settings; using System; using System.Globalization; #if WINDOWS +using System.Windows.Controls; using System.Windows.Data; +using System.Windows.Media; #else +using Avalonia.Controls; using Avalonia.Data.Converters; +using Avalonia.Media; #endif namespace EmoTracker.UI.Converters @@ -53,6 +59,143 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu => throw new NotSupportedException(); } + /// + /// Converts a dock location string ("left","right","top","bottom") to a enum value. + /// Returns for null/empty/unrecognised strings. + /// + public class StringToDockConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string s && !string.IsNullOrEmpty(s) && + Enum.TryParse(s, ignoreCase: true, out var result)) + return result; + return Dock.Left; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Returns when the value is negative; otherwise returns the value as-is. + /// Use for Width/Height bindings where -1 means "unset / auto". + /// + public class NegativeToNaNDoubleConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is double d) return d < 0 ? double.NaN : d; + return double.NaN; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Returns 0.0 when the value is negative or zero; otherwise returns the value as-is. + /// Use for Canvas.Left / Canvas.Top bindings where -1 means "default / unset". + /// + public class CanvasPositionConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is double d) return d <= 0 ? 0.0 : d; + return 0.0; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Returns 0 when the value is negative or zero; otherwise returns (int)value. + /// Use for Canvas.ZIndex bindings where -1 means "default". + /// + public class CanvasZIndexConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is double d) return d <= 0 ? 0 : (int)d; + return 0; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Converts a colour name or hex string (e.g. "#ff3030", "DarkOrange") to an . + /// Returns null on failure so that FallbackValue can kick in. + /// + public class StringToBrushConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string s && !string.IsNullOrWhiteSpace(s)) + { + try + { +#if WINDOWS + return (Brush)new BrushConverter().ConvertFromString(s); +#else + return Brush.Parse(s); +#endif + } + catch { } + } + return null; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Converts an to the matching colour + /// taken from . + /// + public class AccessibilityLevelToBrushConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + string colorStr = "#333333"; + if (value is AccessibilityLevel level) + { + var c = ApplicationColors.Instance; + colorStr = level switch + { + AccessibilityLevel.Normal => c.AccessibilityColor_Normal, + AccessibilityLevel.Cleared => c.AccessibilityColor_Cleared, + AccessibilityLevel.None => c.AccessibilityColor_None, + AccessibilityLevel.Partial => c.AccessibilityColor_Partial, + AccessibilityLevel.Inspect => c.AccessibilityColor_Inspect, + AccessibilityLevel.SequenceBreak => c.AccessibilityColor_SequenceBreak, + AccessibilityLevel.Glitch => c.AccessibilityColor_Glitch, + AccessibilityLevel.Unlockable => c.AccessibilityColor_Unlockable, + _ => "#333333" + }; + } + + try + { +#if WINDOWS + return (Brush)new BrushConverter().ConvertFromString(colorStr); +#else + return Brush.Parse(colorStr); +#endif + } + catch + { + return null; + } + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + /// /// Inverts a value. /// diff --git a/EmoTracker/UI/LayoutControl.axaml b/EmoTracker/UI/LayoutControl.axaml index 09a5f8f..9c7fafe 100644 --- a/EmoTracker/UI/LayoutControl.axaml +++ b/EmoTracker/UI/LayoutControl.axaml @@ -8,55 +8,86 @@ xmlns:layout="clr-namespace:EmoTracker.Data.Layout;assembly=EmoTracker.Data" xmlns:local="clr-namespace:EmoTracker.UI"> - + - + VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" + IsHitTestVisible="{Binding HitTestVisible}"> - + VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" + IsHitTestVisible="{Binding HitTestVisible}"> - + VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" + IsHitTestVisible="{Binding HitTestVisible}"> + + + + + - + VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" + IsHitTestVisible="{Binding HitTestVisible}"> + + + + + + + - + VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" + IsHitTestVisible="{Binding HitTestVisible}"> @@ -64,9 +95,12 @@ - + VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" + IsHitTestVisible="{Binding HitTestVisible}"> @@ -74,25 +108,63 @@ - + VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" + IsHitTestVisible="{Binding HitTestVisible}"> + + + + + + + + + + + + + + + + + + - + VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" + IsHitTestVisible="{Binding HitTestVisible}"> - + VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" + IsHitTestVisible="{Binding HitTestVisible}"> @@ -100,32 +172,24 @@ - - - - - - - - - - - + VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" + IsHitTestVisible="{Binding HitTestVisible}"> - + VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" + IsHitTestVisible="{Binding HitTestVisible}"> @@ -133,17 +197,23 @@ - + VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" + IsHitTestVisible="{Binding HitTestVisible}"> - + VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" + IsHitTestVisible="{Binding HitTestVisible}"> @@ -160,9 +230,12 @@ - + VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" + IsHitTestVisible="{Binding HitTestVisible}"> @@ -175,13 +248,17 @@ - + VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" + IsHitTestVisible="{Binding HitTestVisible}"> + diff --git a/EmoTracker/UI/LocationMapControl.axaml b/EmoTracker/UI/LocationMapControl.axaml index 9a87730..ae035b0 100644 --- a/EmoTracker/UI/LocationMapControl.axaml +++ b/EmoTracker/UI/LocationMapControl.axaml @@ -52,7 +52,8 @@ - + + BorderBrush="#212121" + Background="{Binding Location.AccessibilityLevel, Converter={x:Static converters:AccessibilityLevelToBrushConverter.Instance}}"> Date: Sat, 4 Apr 2026 18:43:09 -0700 Subject: [PATCH 010/149] Fix 5 Avalonia UI issues: map borders, backgrounds, title bar, package manager, right-click - Add DoubleToThicknessConverter; use it in LocationMapControl for map marker BorderThickness (fixes invisible outer border on map location squares) - Add Background binding to ArrayPanel/DockPanel/CanvasPanel/Container/ScrollPanel DataTemplate Grids in LayoutControl.axaml (fixes wrong/missing backgrounds on panels) - Add Padding="0" to all 6 title bar chrome buttons in MainWindow.axaml (fixes icon clipping in the 25px title bar row) - Add PackageGroup class + AvailablePackagesGroupedView property in ApplicationModel; update PackageManagerWindow.axaml to bind to it instead of AvailablePackagesView.Groups (fixes package manager showing no packages in Avalonia build) - Remove Button.ContextMenu from TrackableItemControl; add Grid_PointerReleased handler that executes mRegressCmd on right-click directly (fixes empty context menu instead of right-click action) - Fix ArrayPanel DataTemplate to respect Orientation (vertical/horizontal) via StackPanel bound to DataContext.Orientation through RelativeSource on ItemsControl Co-Authored-By: Claude Sonnet 4.6 --- .../Converters/VisibilityConverters.cs | 20 +++++++++++++ EmoTracker/ApplicationModel.cs | 29 +++++++++++++++---- EmoTracker/MainWindow.axaml | 12 ++++---- EmoTracker/UI/LayoutControl.axaml | 17 ++++++++++- EmoTracker/UI/LocationMapControl.axaml | 2 +- EmoTracker/UI/PackageManagerWindow.axaml | 2 +- EmoTracker/UI/TrackableItemControl.axaml | 8 +---- EmoTracker/UI/TrackableItemControl.axaml.cs | 9 ++++++ 8 files changed, 77 insertions(+), 22 deletions(-) diff --git a/EmoTracker.UI/Converters/VisibilityConverters.cs b/EmoTracker.UI/Converters/VisibilityConverters.cs index 4f73f2f..fe8baeb 100644 --- a/EmoTracker.UI/Converters/VisibilityConverters.cs +++ b/EmoTracker.UI/Converters/VisibilityConverters.cs @@ -125,6 +125,26 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu => throw new NotSupportedException(); } + /// + /// Converts a to a uniform Thickness. + /// Use for BorderThickness bindings where the data model stores a single double. + /// + public class DoubleToThicknessConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + double d = value is double dv ? dv : 0.0; +#if WINDOWS + return new System.Windows.Thickness(d); +#else + return new Avalonia.Thickness(d); +#endif + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + /// /// Converts a colour name or hex string (e.g. "#ff3030", "DarkOrange") to an . /// Returns null on failure so that FallbackValue can kick in. diff --git a/EmoTracker/ApplicationModel.cs b/EmoTracker/ApplicationModel.cs index 63754d7..c2608b8 100644 --- a/EmoTracker/ApplicationModel.cs +++ b/EmoTracker/ApplicationModel.cs @@ -784,7 +784,7 @@ public AvailablePackageViewFilterType AvailablePackageViewFilter #if WINDOWS AvailablePackagesView.Refresh(); #else - NotifyPropertyChanged(nameof(AvailablePackagesView)); + NotifyPropertyChanged(nameof(AvailablePackagesGroupedView)); #endif } } @@ -824,13 +824,30 @@ public CollectionView InstalledPackagesView get { return mInstalledPackagesView; } } #else - public IEnumerable AvailablePackagesView => + /// + /// Groups available packages by game name for display in the Avalonia package manager. + /// Each entry has a Name (game name) and Items (packages in that group). + /// + public IEnumerable AvailablePackagesGroupedView => (PackageManager.Instance.AvailablePackages ?? Enumerable.Empty()) - .Where(PackageFilter) - .OrderBy(e => e.Game).ThenBy(e => e.Name); + .Where(e => PackageFilter(e)) + .OrderBy(e => e.Game).ThenBy(e => e.Name) + .GroupBy(e => e.Game) + .Select(g => new PackageGroup(g.Key, g)); public IEnumerable InstalledPackagesView => PackageManager.Instance.InstalledPackages ?? Enumerable.Empty(); + + public class PackageGroup + { + public string Name { get; } + public IEnumerable Items { get; } + public PackageGroup(string name, IEnumerable items) + { + Name = name; + Items = items; + } + } #endif void InitializePackageManagerViews() @@ -878,7 +895,7 @@ private void PackageManager_OnGameListDownloaded(object sender, EventArgs e) AvailablePackagesView.Refresh(); InstalledPackagesView.Refresh(); #else - NotifyPropertyChanged(nameof(AvailablePackagesView)); + NotifyPropertyChanged(nameof(AvailablePackagesGroupedView)); NotifyPropertyChanged(nameof(InstalledPackagesView)); #endif } @@ -901,7 +918,7 @@ private void RefreshPackageCollectionView() #if WINDOWS mAvailablePackagesView.Refresh(); #else - NotifyPropertyChanged(nameof(AvailablePackagesView)); + NotifyPropertyChanged(nameof(AvailablePackagesGroupedView)); #endif } diff --git a/EmoTracker/MainWindow.axaml b/EmoTracker/MainWindow.axaml index da4a6af..a7e1723 100644 --- a/EmoTracker/MainWindow.axaml +++ b/EmoTracker/MainWindow.axaml @@ -58,7 +58,7 @@ diff --git a/EmoTracker/UI/LayoutControl.axaml b/EmoTracker/UI/LayoutControl.axaml index 9c7fafe..abd7f41 100644 --- a/EmoTracker/UI/LayoutControl.axaml +++ b/EmoTracker/UI/LayoutControl.axaml @@ -24,10 +24,21 @@ - + + + + + + + + @@ -35,6 +46,7 @@ @@ -58,6 +70,7 @@ @@ -151,6 +164,7 @@ @@ -162,6 +176,7 @@ diff --git a/EmoTracker/UI/LocationMapControl.axaml b/EmoTracker/UI/LocationMapControl.axaml index ae035b0..c2a4fd3 100644 --- a/EmoTracker/UI/LocationMapControl.axaml +++ b/EmoTracker/UI/LocationMapControl.axaml @@ -54,7 +54,7 @@ - - diff --git a/EmoTracker/UI/TrackableItemControl.axaml b/EmoTracker/UI/TrackableItemControl.axaml index e975920..d037388 100644 --- a/EmoTracker/UI/TrackableItemControl.axaml +++ b/EmoTracker/UI/TrackableItemControl.axaml @@ -4,7 +4,7 @@ xmlns:local="clr-namespace:EmoTracker.UI" xmlns:controls="clr-namespace:EmoTracker.UI.Controls;assembly=EmoTracker.UI" xmlns:converters="clr-namespace:EmoTracker.UI.Converters;assembly=EmoTracker.UI"> - + diff --git a/EmoTracker/UI/TrackableItemControl.axaml.cs b/EmoTracker/UI/TrackableItemControl.axaml.cs index 9bba317..b189774 100644 --- a/EmoTracker/UI/TrackableItemControl.axaml.cs +++ b/EmoTracker/UI/TrackableItemControl.axaml.cs @@ -193,5 +193,14 @@ public void Execute(object? parameter) private readonly RightClickCommand mRegressCmd; #endregion + + private void Grid_PointerReleased(object sender, Avalonia.Input.PointerReleasedEventArgs e) + { + if (e.InitialPressMouseButton == Avalonia.Input.MouseButton.Right) + { + mRegressCmd.Execute(DataContext); + e.Handled = true; + } + } } } From 28e8ac9b3b2930f9f1d64ef30881c0559407a8e7 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 5 Apr 2026 01:23:58 -0700 Subject: [PATCH 011/149] Fix color key and alpha masking for item images (issue 5b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SKBitmap.Decode() may return Rgb888x (no alpha channel) for RGB PNG or 24-bit BMP sources. For that color type, SetPixel(Transparent) is a silent no-op — the alpha byte stays forced to 255 — so magenta color-key pixels became opaque black instead of transparent, and the SkToAvalonia alpha mask was all-true (hit testing broken). Fix: promote the decoded bitmap to Bgra8888 before the color-key loop in GetImage(). Apply the same promotion in ToSkBitmap() so ApplyOverlayImage always composites with a real alpha channel (otherwise Max(base.Alpha, overlay.Alpha) would be 255 everywhere and transparent regions in stacked/layered item images couldn't be preserved). Co-Authored-By: Claude Sonnet 4.6 --- EmoTracker.UI/Media/Utility/IconUtility.cs | 36 ++++++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/EmoTracker.UI/Media/Utility/IconUtility.cs b/EmoTracker.UI/Media/Utility/IconUtility.cs index cb02f66..624ec94 100644 --- a/EmoTracker.UI/Media/Utility/IconUtility.cs +++ b/EmoTracker.UI/Media/Utility/IconUtility.cs @@ -433,6 +433,8 @@ public static (bool[] mask, int w, int h)? GetAlphaMask(IImage image) } /// Convert an Avalonia IImage back to an SKBitmap for pixel processing. + /// 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) @@ -442,7 +444,19 @@ private static SKBitmap ToSkBitmap(IImage image) using var ms = new MemoryStream(); avBitmap.Save(ms); ms.Position = 0; - return SKBitmap.Decode(ms); + SKBitmap decoded = SKBitmap.Decode(ms); + if (decoded == null) return null; + + // Promote to BGRA8888 so overlay compositing always has a real alpha channel. + // Rgb888x alpha is forced to 255 — Max(base.Alpha, overlay.Alpha) would then be + // 255 for every pixel and transparent regions couldn't be preserved. + if (decoded.ColorType != SKColorType.Bgra8888) + { + SKBitmap promoted = decoded.Copy(SKColorType.Bgra8888); + decoded.Dispose(); + return promoted; + } + return decoded; } catch { return null; } } @@ -529,8 +543,24 @@ public static IImage GetImage(Stream stream) return null; try { - SKBitmap bmp = SKBitmap.Decode(stream); - if (bmp == null) return null; + SKBitmap decoded = SKBitmap.Decode(stream); + if (decoded == null) return null; + + // Promote to BGRA8888 before the color-key pass. + // SKBitmap.Decode may return Rgb888x (no alpha channel, e.g. RGB PNG or 24-bit BMP). + // For Rgb888x, SetPixel(Transparent) is a silent no-op for the alpha byte — it stays + // forced to 255 — so magenta pixels would render as opaque black instead of transparent. + SKBitmap bmp; + if (decoded.ColorType != SKColorType.Bgra8888) + { + bmp = decoded.Copy(SKColorType.Bgra8888); + decoded.Dispose(); + if (bmp == null) return null; + } + else + { + bmp = decoded; + } // Apply color key: magenta (R=255, G=0, B=255) → transparent for (int y = 0; y < bmp.Height; y++) From c8ee2c32a7371e4babcb5201578af8ecade0eb3b Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 5 Apr 2026 02:13:07 -0700 Subject: [PATCH 012/149] Fix overlay compositing by caching Skia PNG bytes to bypass Bitmap.Save alpha loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avalonia's Bitmap.Save(Stream) may strip the alpha channel on some platforms. ToSkBitmap used it to convert IImage back to SKBitmap for overlay blending. When alpha was stripped, every pixel appeared fully opaque (alpha=255), making the overlay formula treat the entire overlay as a solid mask — the base image was completely obliterated and only the last layer of stacked items was visible. Fix: SkToAvalonia now always stores the raw Skia-encoded PNG bytes in sPngCache alongside the alpha mask. ToSkBitmap reads from that cache first, bypassing Bitmap.Save entirely. The Bitmap.Save path is kept as a fallback only for images that did not originate from SkToAvalonia (e.g. GetImageRaw file/avares bitmaps). Co-Authored-By: Claude Sonnet 4.6 --- EmoTracker.UI/Media/Utility/IconUtility.cs | 64 ++++++++++++++++------ 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/EmoTracker.UI/Media/Utility/IconUtility.cs b/EmoTracker.UI/Media/Utility/IconUtility.cs index 624ec94..6f6a1f7 100644 --- a/EmoTracker.UI/Media/Utility/IconUtility.cs +++ b/EmoTracker.UI/Media/Utility/IconUtility.cs @@ -424,6 +424,12 @@ public static ImageSource ApplyFilterSpecToImage(IGamePackage package, ImageSour // 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(); + /// Returns the precomputed alpha mask for an image, or null if not available. public static (bool[] mask, int w, int h)? GetAlphaMask(IImage image) { @@ -433,30 +439,50 @@ public static (bool[] mask, int w, int h)? GetAlphaMask(IImage image) } /// Convert an Avalonia IImage back to an SKBitmap for pixel processing. - /// Always returns a Bgra8888 bitmap so downstream pixel loops can read and write - /// alpha correctly, regardless of the source image's original colour type. + /// + /// 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 { - using var ms = new MemoryStream(); - avBitmap.Save(ms); - ms.Position = 0; - SKBitmap decoded = SKBitmap.Decode(ms); - if (decoded == null) return null; + 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; + } - // Promote to BGRA8888 so overlay compositing always has a real alpha channel. - // Rgb888x alpha is forced to 255 — Max(base.Alpha, overlay.Alpha) would then be - // 255 for every pixel and transparent regions couldn't be preserved. - if (decoded.ColorType != SKColorType.Bgra8888) + using (pngStream) { - SKBitmap promoted = decoded.Copy(SKColorType.Bgra8888); - decoded.Dispose(); - return promoted; + SKBitmap decoded = SKBitmap.Decode(pngStream); + if (decoded == null) return null; + + // Promote to Bgra8888 so SetPixel/GetPixel operate on a real alpha channel. + // Rgb888x forces alpha to 255 — Max(base.Alpha, overlay.Alpha) would then be + // 255 for every pixel and transparent regions couldn't be preserved. + if (decoded.ColorType != SKColorType.Bgra8888) + { + SKBitmap promoted = decoded.Copy(SKColorType.Bgra8888); + decoded.Dispose(); + return promoted; + } + return decoded; } - return decoded; } catch { return null; } } @@ -466,8 +492,12 @@ private static IImage SkToAvalonia(SKBitmap bmp, bool storeMask = false) { using var skImg = SKImage.FromBitmap(bmp); using var encoded = skImg.Encode(SKEncodedImageFormat.Png, 100); - using var ms = new MemoryStream(encoded.ToArray()); - var avBitmap = new Avalonia.Media.Imaging.Bitmap(ms); + 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) { From 7bce8239fffa6ba3998659dee4a91e8a35d2b218 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 5 Apr 2026 03:59:17 -0700 Subject: [PATCH 013/149] Fix location map UI: popups, pinned locations, chest list, and HTTP images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LocationMapControl: replace IsLightDismissEnabled with manual popup management; use DoubleTapped event for pin (not PointerPressed ClickCount); set LocationControl DataContext imperatively to bypass OverlayLayer ElementName binding limitation; add badge hover popup - LayoutControl: move RecentPinnedLocations DataTemplate before ArrayPanel so the derived type's template wins Avalonia's first-match lookup - LocationControl: add compact-mode layout (SectionsItemsPanel, SectionHorizontalAlignment, SectionItemMargin computed properties); bind ChestListControl.Compact; use AccessibilityLevelToBrushConverter for section name foreground; prevent width stretch in pinned panel - ChestListControl: pre-compute per-slot images in ObservableCollection instead of WPF MultiDataTrigger; add CurrentCompactImage StyledProperty; cache all display-relevant StyledProperty values in fields so UpdateChests never calls GetValue() during visual tree teardown; only trigger UpdateChests for the 7 display-relevant properties to avoid crash when Avalonia fires inherited property changes on popup overlay close - IconUtility: fix alpha channel loss (SKAlphaType.Opaque → Premul via SKCanvas copy); add async HTTP image loading with ConcurrentDictionary cache and HttpImageLoaded event - ApplicationModel: subscribe to HttpImageLoaded with debounced refresh Co-Authored-By: Claude Sonnet 4.6 --- EmoTracker.UI/Media/Utility/IconUtility.cs | 76 ++++++++++++++++++---- EmoTracker/ApplicationModel.cs | 24 +++++++ EmoTracker/UI/ChestListControl.axaml | 8 ++- EmoTracker/UI/ChestListControl.axaml.cs | 63 +++++++++++++++--- EmoTracker/UI/LayoutControl.axaml | 39 ++++++----- EmoTracker/UI/LocationControl.axaml | 20 +++--- EmoTracker/UI/LocationControl.axaml.cs | 30 +++++++++ EmoTracker/UI/LocationMapControl.axaml | 27 +++++++- EmoTracker/UI/LocationMapControl.axaml.cs | 52 ++++++++++----- 9 files changed, 271 insertions(+), 68 deletions(-) diff --git a/EmoTracker.UI/Media/Utility/IconUtility.cs b/EmoTracker.UI/Media/Utility/IconUtility.cs index 6f6a1f7..98cedf5 100644 --- a/EmoTracker.UI/Media/Utility/IconUtility.cs +++ b/EmoTracker.UI/Media/Utility/IconUtility.cs @@ -430,6 +430,16 @@ public static ImageSource ApplyFilterSpecToImage(IGamePackage package, ImageSour // 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) { @@ -472,12 +482,19 @@ private static SKBitmap ToSkBitmap(IImage image) SKBitmap decoded = SKBitmap.Decode(pngStream); if (decoded == null) return null; - // Promote to Bgra8888 so SetPixel/GetPixel operate on a real alpha channel. - // Rgb888x forces alpha to 255 — Max(base.Alpha, overlay.Alpha) would then be - // 255 for every pixel and transparent regions couldn't be preserved. - if (decoded.ColorType != SKColorType.Bgra8888) + // 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) { - SKBitmap promoted = decoded.Copy(SKColorType.Bgra8888); + 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; } @@ -542,11 +559,42 @@ public static IImage GetImageRaw(Uri uri) } 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 @@ -576,16 +624,22 @@ public static IImage GetImage(Stream stream) SKBitmap decoded = SKBitmap.Decode(stream); if (decoded == null) return null; - // Promote to BGRA8888 before the color-key pass. + // 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). - // For Rgb888x, SetPixel(Transparent) is a silent no-op for the alpha byte — it stays - // forced to 255 — so magenta pixels would render as opaque black instead of transparent. + // 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) + if (decoded.ColorType != SKColorType.Bgra8888 || decoded.AlphaType == SKAlphaType.Opaque) { - bmp = decoded.Copy(SKColorType.Bgra8888); + 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(); - if (bmp == null) return null; } else { diff --git a/EmoTracker/ApplicationModel.cs b/EmoTracker/ApplicationModel.cs index c2608b8..d2bcbac 100644 --- a/EmoTracker/ApplicationModel.cs +++ b/EmoTracker/ApplicationModel.cs @@ -168,8 +168,32 @@ public ApplicationModel() InstallPackageCommand = new DelegateCommand(InstallPackage); UninstallPackageCommand = new DelegateCommand(UninstallPackage, CanUninstallPackage); +#if !WINDOWS + // When HTTP game images finish downloading, refresh the package list once. + // Multiple images often load near-simultaneously, so we coalesce the refreshes: + // the first completion schedules a single Background-priority update; subsequent + // completions that arrive before it runs are folded into that one refresh. + EmoTracker.UI.Media.Utility.IconUtility.HttpImageLoaded += OnHttpImageLoaded; +#endif } +#if !WINDOWS + private bool _httpRefreshScheduled = false; + + private void OnHttpImageLoaded(object? sender, EventArgs e) + { + // Coalesce multiple near-simultaneous completions into one Background-priority refresh. + if (_httpRefreshScheduled) return; + _httpRefreshScheduled = true; + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + _httpRefreshScheduled = false; + EmoTracker.UI.Media.ImageReferenceService.Instance.ClearImageCache(); + NotifyPropertyChanged(nameof(AvailablePackagesGroupedView)); + }, Avalonia.Threading.DispatcherPriority.Background); + } +#endif + public void Initialize() { // Load and start extensions diff --git a/EmoTracker/UI/ChestListControl.axaml b/EmoTracker/UI/ChestListControl.axaml index 0bb9820..1882c84 100644 --- a/EmoTracker/UI/ChestListControl.axaml +++ b/EmoTracker/UI/ChestListControl.axaml @@ -13,8 +13,9 @@ --> + + Source="{Binding CurrentCompactImage, RelativeSource={RelativeSource AncestorType=local:ChestListControl}}" /> + @@ -33,9 +35,9 @@ - + + Source="{Binding}" /> diff --git a/EmoTracker/UI/ChestListControl.axaml.cs b/EmoTracker/UI/ChestListControl.axaml.cs index 8007f73..062796d 100644 --- a/EmoTracker/UI/ChestListControl.axaml.cs +++ b/EmoTracker/UI/ChestListControl.axaml.cs @@ -101,9 +101,20 @@ public bool Compact #endregion - private readonly ObservableCollection mChestStates = new ObservableCollection(); + // Each entry is the pre-resolved IImage for that chest slot (computed in UpdateChests). + private readonly ObservableCollection mChestImages = new ObservableCollection(); - public IEnumerable Chests => mChestStates; + public IEnumerable Chests => mChestImages; + + // The single image shown in compact mode. + public static readonly StyledProperty CurrentCompactImageProperty = + AvaloniaProperty.Register(nameof(CurrentCompactImage)); + + public IImage? CurrentCompactImage + { + get => GetValue(CurrentCompactImageProperty); + private set => SetValue(CurrentCompactImageProperty, value); + } /// /// Returns the DataTemplate to use for the ContentPresenter. @@ -114,6 +125,18 @@ public bool Compact ? Resources.TryGetValue("CompactTemplate", out var ct) ? ct as IDataTemplate : null : Resources.TryGetValue("FullTemplate", out var ft) ? ft as IDataTemplate : null; + // Cached copies of StyledProperty values used in UpdateChests. + // Accessing GetValue() during popup teardown can throw because bindings transiently + // set values to UnsetValue, causing style-resolution traversal on a broken visual tree. + // We cache the values here so UpdateChests() never calls GetValue() at all. + private IImage? _closedChest; + private IImage? _openChest; + private IImage? _unavailableClosedChest; + private IImage? _unavailableOpenChest; + private bool _accessible = true; // mirrors AccessibleProperty default + private uint _count = 5u; // mirrors CountProperty default + private uint _available = 3u; // mirrors AvailableProperty default + public ChestListControl() { InitializeComponent(); @@ -122,19 +145,43 @@ public ChestListControl() protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); + + // Keep cached values in sync and only call UpdateChests for the properties + // that actually affect display. Calling it for every property change (including + // inherited layout/visual properties) causes crashes during popup teardown when + // Avalonia fires property-changed notifications while the overlay's visual root + // is already detached from the window. + if (change.Property == ClosedChestProperty) _closedChest = change.GetNewValue(); + else if (change.Property == OpenChestProperty) _openChest = change.GetNewValue(); + else if (change.Property == UnavailableClosedChestProperty) _unavailableClosedChest = change.GetNewValue(); + else if (change.Property == UnavailableOpenChestProperty) _unavailableOpenChest = change.GetNewValue(); + else if (change.Property == AccessibleProperty) _accessible = change.GetNewValue(); + else if (change.Property == CountProperty) _count = change.GetNewValue(); + else if (change.Property == AvailableProperty) _available = change.GetNewValue(); + else return; + UpdateChests(); } private void UpdateChests() { - while (mChestStates.Count > Count) - mChestStates.RemoveAt(0); + while (mChestImages.Count > _count) + mChestImages.RemoveAt(0); + while (mChestImages.Count < _count) + mChestImages.Add(null); - while (mChestStates.Count < Count) - mChestStates.Add(true); + for (int i = 0; i < (int)_count; ++i) + { + bool available = i < (int)_available; + mChestImages[i] = available + ? (_accessible ? _closedChest : _unavailableClosedChest) + : (_accessible ? _openChest : _unavailableOpenChest); + } - for (int i = 0; i < Count; ++i) - mChestStates[i] = i < Available; + // Compact mode: single image — open chest when all cleared, closed otherwise + CurrentCompactImage = _available == 0 + ? (_accessible ? _openChest : _unavailableOpenChest) + : (_accessible ? _closedChest : _unavailableClosedChest); } protected override void OnPointerPressed(PointerPressedEventArgs e) diff --git a/EmoTracker/UI/LayoutControl.axaml b/EmoTracker/UI/LayoutControl.axaml index abd7f41..a66d0dd 100644 --- a/EmoTracker/UI/LayoutControl.axaml +++ b/EmoTracker/UI/LayoutControl.axaml @@ -20,6 +20,27 @@ + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + - + @@ -97,10 +94,10 @@ - + Foreground="{Binding AccessibilityLevel, Converter={x:Static converters:AccessibilityLevelToBrushConverter.Instance}}" /> + @@ -120,7 +117,8 @@ Count="{Binding ChestCount}" Available="{Binding AvailableChestCount}" ClearAsGroup="{Binding ClearAsGroup}" - HorizontalAlignment="Right" + Compact="{Binding Compact, RelativeSource={RelativeSource AncestorType=local:LocationControl}}" + HorizontalAlignment="{Binding SectionHorizontalAlignment, RelativeSource={RelativeSource AncestorType=local:LocationControl}}" OpenChest="{Binding OpenChestImage, Converter={x:Static converters:ImageReferenceConverter.Instance}}" ClosedChest="{Binding ClosedChestImage, Converter={x:Static converters:ImageReferenceConverter.Instance}}" UnavailableOpenChest="{Binding UnavailableOpenChestImage, Converter={x:Static converters:ImageReferenceConverter.Instance}}" diff --git a/EmoTracker/UI/LocationControl.axaml.cs b/EmoTracker/UI/LocationControl.axaml.cs index 24c5c93..731d5e1 100644 --- a/EmoTracker/UI/LocationControl.axaml.cs +++ b/EmoTracker/UI/LocationControl.axaml.cs @@ -1,6 +1,8 @@ using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Templates; using Avalonia.Interactivity; +using Avalonia.Layout; using Avalonia.VisualTree; using EmoTracker.Data; using EmoTracker.UI.Controls; @@ -19,6 +21,12 @@ public enum PreserveDimension /// public partial class LocationControl : ObservableUserControl { + // Static panel templates reused across all LocationControl instances. + private static readonly ITemplate sCompactSectionsPanel = + new FuncTemplate(() => new WrapPanel { Orientation = Orientation.Horizontal }); + private static readonly ITemplate sFullSectionsPanel = + new FuncTemplate(() => new WrapPanel { Orientation = Orientation.Vertical, HorizontalAlignment = HorizontalAlignment.Right }); + public LocationControl() { InitializeComponent(); @@ -45,6 +53,28 @@ public PreserveDimension PreserveDimension set => SetValue(PreserveDimensionProperty, value); } + // ---- Compact-dependent layout helpers (used by AXAML bindings) ---- + + public ITemplate SectionsItemsPanel => + Compact ? sCompactSectionsPanel : sFullSectionsPanel; + + public HorizontalAlignment SectionHorizontalAlignment => + Compact ? HorizontalAlignment.Left : HorizontalAlignment.Right; + + public Thickness SectionItemMargin => + Compact ? new Thickness(5, 0, 5, 0) : new Thickness(0); + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == CompactProperty) + { + NotifyPropertyChanged(nameof(SectionsItemsPanel)); + NotifyPropertyChanged(nameof(SectionHorizontalAlignment)); + NotifyPropertyChanged(nameof(SectionItemMargin)); + } + } + private void DeleteNoteButton_Click(object? sender, RoutedEventArgs e) { if (sender is StyledElement elem) diff --git a/EmoTracker/UI/LocationMapControl.axaml b/EmoTracker/UI/LocationMapControl.axaml index c2a4fd3..6cc6eb4 100644 --- a/EmoTracker/UI/LocationMapControl.axaml +++ b/EmoTracker/UI/LocationMapControl.axaml @@ -26,8 +26,31 @@ - - + + + + + + + + + + + + + + + + + + + + + + LocationDetails.IsOpen = false; + BadgeDetails.Closed += (s, e) => BadgeDetails.IsOpen = false; + BadgeItemsControl.ItemsSource = mBadgeImages; } private void LocationMapControl_Loaded(object? sender, VisualTreeAttachmentEventArgs e) @@ -81,11 +84,15 @@ public Control? DetailsTarget get => mDetailsTarget; set { - if (SetProperty(ref mDetailsTarget, value)) + SetProperty(ref mDetailsTarget, value); + if (mDetailsTarget != null) { - // In Avalonia, popup management is done in code-behind - // Trigger property notification so bindings update - NotifyPropertyChanged(nameof(DetailsTarget)); + BadgeDetails.IsOpen = false; + // Set DataContext imperatively — ElementName bindings don't work inside + // Popup's OverlayLayer because it renders outside the normal visual tree. + LocationDetailsContent.DataContext = mDetailsLocation; + LocationDetails.PlacementTarget = mDetailsTarget; + LocationDetails.IsOpen = true; } } } @@ -110,7 +117,16 @@ public Control? BadgesTarget { if (SetProperty(ref mBadgesTarget, value)) { - NotifyPropertyChanged(nameof(BadgesTarget)); + if (mBadgesTarget != null) + { + BadgeDetails.PlacementTarget = mBadgesTarget; + BadgeDetails.IsOpen = false; + BadgeDetails.IsOpen = true; + } + else + { + BadgeDetails.IsOpen = false; + } } } } @@ -216,6 +232,11 @@ protected override void OnPointerMoved(PointerEventArgs e) protected override void OnPointerPressed(PointerPressedEventArgs e) { + // Always close the popup on any press; it will reopen below if a location was clicked. + // This avoids using IsLightDismissEnabled (which creates an overlay layer that swallows + // the second click of a double-click before OnPointerPressed sees ClickCount=2). + LocationDetails.IsOpen = false; + var props = e.GetCurrentPoint(this).Properties; if (props.IsLeftButtonPressed) @@ -226,6 +247,9 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) MapLocation? data = element.DataContext as MapLocation; if (data != null) { + // Open the details popup on every press. + // Double-click is handled separately by LocationMapControl_DoubleTapped, + // which fires on PointerReleased and doesn't rely on ClickCount. DetailsLocation = data.Location; DetailsTarget = element; e.Handled = true; @@ -256,20 +280,18 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) base.OnPointerPressed(e); } - private void LocationMapControl_DoubleTapped(object sender, TappedEventArgs e) + private void LocationMapControl_DoubleTapped(object? sender, TappedEventArgs e) { + // DoubleTapped fires on PointerReleased and is not affected by PointerPressed.Handled, + // making it more reliable than ClickCount for detecting double-clicks. var element = e.Source as Control; - if (element != null) + MapLocation? data = element?.DataContext as MapLocation; + if (data?.Location != null) { - MapLocation? data = element.DataContext as MapLocation; - if (data?.Location != null) - { - data.Location.Pinned = true; - e.Handled = true; - return; - } + data.Location.Pinned = true; + LocationDetails.IsOpen = false; + e.Handled = true; } - } } } From 515f8400d27dead38ae4b4675af072d79bd8116b Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 5 Apr 2026 04:14:23 -0700 Subject: [PATCH 014/149] Fix LocationControl: pin icon, black rectangles, compact title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PushPinCheckBox ControlTheme: FontAwesome pushpin () with :not(:checked) rotation (90°) and :disabled visibility, replacing the plain CheckBox that showed Fluent theme's default checkmark - Fix TrackableItemControl (GateItem/HostedItem) IsVisible bindings: change from {Binding GateItem/HostedItem, Converter=NullToFalse} to {Binding Converter=NullToFalse} — the DataContext is already set to the item on the same element, so the path was resolving against the item itself (not the parent Section), silently failing and always showing true - Add TitleText computed property to LocationControl: returns ShortName when Compact=true, Name otherwise; notifies on CompactProperty and DataContextProperty changes Co-Authored-By: Claude Sonnet 4.6 --- EmoTracker/UI/LocationControl.axaml | 37 ++++++++++++++++++++++---- EmoTracker/UI/LocationControl.axaml.cs | 11 ++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/EmoTracker/UI/LocationControl.axaml b/EmoTracker/UI/LocationControl.axaml index 4a10587..3de52d7 100644 --- a/EmoTracker/UI/LocationControl.axaml +++ b/EmoTracker/UI/LocationControl.axaml @@ -8,7 +8,33 @@ xmlns:local="clr-namespace:EmoTracker.UI" xmlns:settings="clr-namespace:EmoTracker.Data.Settings;assembly=EmoTracker.Data"> - + + + + + + + + + + + + + + + Text="{Binding TitleText, RelativeSource={RelativeSource AncestorType=local:LocationControl}}" /> + Margin="0" + Theme="{StaticResource PushPinCheckBox}"/> @@ -107,7 +134,7 @@ + IsVisible="{Binding Converter={x:Static converters:NullToFalseConverter.Instance}}" /> + IsVisible="{Binding Converter={x:Static converters:NullToFalseConverter.Instance}}" /> diff --git a/EmoTracker/UI/LocationControl.axaml.cs b/EmoTracker/UI/LocationControl.axaml.cs index 731d5e1..89deb7c 100644 --- a/EmoTracker/UI/LocationControl.axaml.cs +++ b/EmoTracker/UI/LocationControl.axaml.cs @@ -6,6 +6,7 @@ using Avalonia.VisualTree; using EmoTracker.Data; using EmoTracker.UI.Controls; +using DataLocation = EmoTracker.Data.Locations.Location; namespace EmoTracker.UI { @@ -64,6 +65,11 @@ public PreserveDimension PreserveDimension public Thickness SectionItemMargin => Compact ? new Thickness(5, 0, 5, 0) : new Thickness(0); + public string? TitleText => + Compact + ? (DataContext as DataLocation)?.ShortName + : (DataContext as DataLocation)?.Name; + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); @@ -72,6 +78,11 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang NotifyPropertyChanged(nameof(SectionsItemsPanel)); NotifyPropertyChanged(nameof(SectionHorizontalAlignment)); NotifyPropertyChanged(nameof(SectionItemMargin)); + NotifyPropertyChanged(nameof(TitleText)); + } + else if (change.Property == DataContextProperty) + { + NotifyPropertyChanged(nameof(TitleText)); } } From f96510262a481903c17d74af9bdebdc4dfa0d6a6 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 5 Apr 2026 04:25:55 -0700 Subject: [PATCH 015/149] Add DropShadowDirectionEffect support for layout items Implements the drop shadow feature for layout elements using Avalonia's DropShadowDirectionEffect (BlurRadius=15, ShadowDepth=0, Opacity=0.8), matching the centred-glow appearance of the WPF DropShadowEffect. Adds BoolToDropShadowEffectConverter and wires Effect binding to all outer Grid containers in LayoutControl.axaml. Co-Authored-By: Claude Sonnet 4.6 --- .../Converters/VisibilityConverters.cs | 26 ++++++++++ EmoTracker/UI/LayoutControl.axaml | 48 ++++++++++++------- 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/EmoTracker.UI/Converters/VisibilityConverters.cs b/EmoTracker.UI/Converters/VisibilityConverters.cs index fe8baeb..2655673 100644 --- a/EmoTracker.UI/Converters/VisibilityConverters.cs +++ b/EmoTracker.UI/Converters/VisibilityConverters.cs @@ -239,4 +239,30 @@ public object Convert(object value, Type targetType, object parameter, CultureIn public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => value is bool b ? !b : value; } + +#if !WINDOWS + /// + /// Converts a bool to an Avalonia + /// when true, or null when false. + /// Mirrors the WPF DropShadowEffect (BlurRadius=15, ShadowDepth=0, Opacity=0.8) which produces + /// a centred glow with no directional offset. + /// + public class BoolToDropShadowEffectConverter : Singleton, IValueConverter + { + private static readonly Avalonia.Media.DropShadowDirectionEffect s_effect = + new Avalonia.Media.DropShadowDirectionEffect + { + BlurRadius = 15, + ShadowDepth = 0, + Opacity = 0.8, + Color = Colors.Black, + }; + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is true ? (object)s_effect : null; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } +#endif } diff --git a/EmoTracker/UI/LayoutControl.axaml b/EmoTracker/UI/LayoutControl.axaml index a66d0dd..3d24247 100644 --- a/EmoTracker/UI/LayoutControl.axaml +++ b/EmoTracker/UI/LayoutControl.axaml @@ -15,7 +15,8 @@ Margin="{Binding Margin}" HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" - IsHitTestVisible="{Binding HitTestVisible}"> + IsHitTestVisible="{Binding HitTestVisible}" + Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -28,7 +29,8 @@ Margin="{Binding Margin}" HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" - IsHitTestVisible="{Binding HitTestVisible}"> + IsHitTestVisible="{Binding HitTestVisible}" + Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -48,7 +50,8 @@ Background="{Binding Background, Converter={x:Static converters:StringToBrushConverter.Instance}}" HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" - IsHitTestVisible="{Binding HitTestVisible}"> + IsHitTestVisible="{Binding HitTestVisible}" + Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -70,7 +73,8 @@ Background="{Binding Background, Converter={x:Static converters:StringToBrushConverter.Instance}}" HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" - IsHitTestVisible="{Binding HitTestVisible}"> + IsHitTestVisible="{Binding HitTestVisible}" + Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -94,7 +98,8 @@ Background="{Binding Background, Converter={x:Static converters:StringToBrushConverter.Instance}}" HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" - IsHitTestVisible="{Binding HitTestVisible}"> + IsHitTestVisible="{Binding HitTestVisible}" + Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -121,7 +126,8 @@ Margin="{Binding Margin}" HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" - IsHitTestVisible="{Binding HitTestVisible}"> + IsHitTestVisible="{Binding HitTestVisible}" + Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -134,7 +140,8 @@ Margin="{Binding Margin}" HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" - IsHitTestVisible="{Binding HitTestVisible}"> + IsHitTestVisible="{Binding HitTestVisible}" + Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -147,7 +154,8 @@ Margin="{Binding Margin}" HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" - IsHitTestVisible="{Binding HitTestVisible}"> + IsHitTestVisible="{Binding HitTestVisible}" + Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -161,7 +169,8 @@ Margin="{Binding Margin}" HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" - IsHitTestVisible="{Binding HitTestVisible}"> + IsHitTestVisible="{Binding HitTestVisible}" + Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -188,7 +197,8 @@ Background="{Binding Background, Converter={x:Static converters:StringToBrushConverter.Instance}}" HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" - IsHitTestVisible="{Binding HitTestVisible}"> + IsHitTestVisible="{Binding HitTestVisible}" + Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -200,7 +210,8 @@ Background="{Binding Background, Converter={x:Static converters:StringToBrushConverter.Instance}}" HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" - IsHitTestVisible="{Binding HitTestVisible}"> + IsHitTestVisible="{Binding HitTestVisible}" + Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -214,7 +225,8 @@ Margin="{Binding Margin}" HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" - IsHitTestVisible="{Binding HitTestVisible}"> + IsHitTestVisible="{Binding HitTestVisible}" + Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -225,7 +237,8 @@ Margin="{Binding Margin}" HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" - IsHitTestVisible="{Binding HitTestVisible}"> + IsHitTestVisible="{Binding HitTestVisible}" + Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -238,7 +251,8 @@ Margin="{Binding Margin}" HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" - IsHitTestVisible="{Binding HitTestVisible}"> + IsHitTestVisible="{Binding HitTestVisible}" + Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -249,7 +263,8 @@ Margin="{Binding Margin}" HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" - IsHitTestVisible="{Binding HitTestVisible}"> + IsHitTestVisible="{Binding HitTestVisible}" + Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -271,7 +286,8 @@ Margin="{Binding Margin}" HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" - IsHitTestVisible="{Binding HitTestVisible}"> + IsHitTestVisible="{Binding HitTestVisible}" + Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> From cee9320424f9d65730287e03c1573c2bf4b2f171 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 5 Apr 2026 04:28:47 -0700 Subject: [PATCH 016/149] Apply unconditional drop shadow to location info popover Adds a DropShadowDirectionEffect (BlurRadius=15, ShadowDepth=0, Opacity=0.8) directly on the LocationControl inside the map's LocationDetails popup, matching the WPF version's glow appearance. Co-Authored-By: Claude Sonnet 4.6 --- EmoTracker/UI/LocationMapControl.axaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/EmoTracker/UI/LocationMapControl.axaml b/EmoTracker/UI/LocationMapControl.axaml index 6cc6eb4..b563dd6 100644 --- a/EmoTracker/UI/LocationMapControl.axaml +++ b/EmoTracker/UI/LocationMapControl.axaml @@ -28,7 +28,11 @@ - + + + + + From 671c15b2e8f9e51d7b2daaf21c35f3ff382db684 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 5 Apr 2026 04:31:36 -0700 Subject: [PATCH 017/149] Fix layout element margins not being applied in Avalonia Avalonia's TypeConverter (ThicknessTypeConverter) is only used during XAML literal parsing, not at binding resolution time. A {Binding Margin} where the source is a plain string therefore silently falls back to Thickness(0). Add StringToThicknessConverter (Avalonia-only) and wire it into every Margin binding in LayoutControl.axaml. Co-Authored-By: Claude Sonnet 4.6 --- .../Converters/ThicknessConverter.cs | 31 ++++++++++++++++++ EmoTracker/UI/LayoutControl.axaml | 32 +++++++++---------- 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/EmoTracker.UI/Converters/ThicknessConverter.cs b/EmoTracker.UI/Converters/ThicknessConverter.cs index 760bf75..9a6b2b5 100644 --- a/EmoTracker.UI/Converters/ThicknessConverter.cs +++ b/EmoTracker.UI/Converters/ThicknessConverter.cs @@ -11,6 +11,10 @@ namespace EmoTracker.UI.Converters { + /// + /// Converts a model value to the platform Thickness type. + /// Used by LocationMapControl and similar controls that bind a structured Thickness model. + /// public class ThicknessConverter : Singleton, IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) @@ -34,4 +38,31 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu throw new NotImplementedException(); } } + +#if !WINDOWS + /// + /// Converts a margin string (e.g. "5", "5,10", "5,10,5,10") to an Avalonia + /// . + /// + /// Avalonia's TypeConverter is only applied during XAML literal parsing, not at binding + /// resolution time, so a {Binding Margin} where the source is a string + /// silently falls back to Thickness(0) without this explicit converter. + /// + /// + public class StringToThicknessConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string s && !string.IsNullOrWhiteSpace(s)) + { + try { return Thickness.Parse(s); } + catch { } + } + return new Thickness(0); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } +#endif } diff --git a/EmoTracker/UI/LayoutControl.axaml b/EmoTracker/UI/LayoutControl.axaml index 3d24247..1faed43 100644 --- a/EmoTracker/UI/LayoutControl.axaml +++ b/EmoTracker/UI/LayoutControl.axaml @@ -12,7 +12,7 @@ Date: Sun, 5 Apr 2026 04:44:22 -0700 Subject: [PATCH 018/149] Fix missing MinWidth/MinHeight/MaxWidth/MaxHeight on layout elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WPF LayoutItemStyle conditionally applied all four size-constraint properties via DataTrigger. The Avalonia version only bound Width and Height, silently dropping every min/max constraint from layout JSON. A MaxHeight omission is the direct cause of the pinned-locations panel growing unboundedly instead of capping at its configured size. Missing MinWidth/MaxWidth constraints also explain inconsistent resize behaviour. Adds NegativeToZeroDoubleConverter (MinWidth/MinHeight, -1 → 0) and NegativeToInfinityDoubleConverter (MaxWidth/MaxHeight, -1 → ∞) to mirror the WPF DataTrigger guard, and wires all four into every outer Grid wrapper in LayoutControl.axaml. Co-Authored-By: Claude Sonnet 4.6 --- .../Converters/VisibilityConverters.cs | 32 ++++++++++ EmoTracker/UI/LayoutControl.axaml | 64 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/EmoTracker.UI/Converters/VisibilityConverters.cs b/EmoTracker.UI/Converters/VisibilityConverters.cs index 2655673..421c1e4 100644 --- a/EmoTracker.UI/Converters/VisibilityConverters.cs +++ b/EmoTracker.UI/Converters/VisibilityConverters.cs @@ -93,6 +93,38 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu => throw new NotSupportedException(); } + /// + /// Returns 0.0 when the value is negative; otherwise returns the value as-is. + /// Use for MinWidth/MinHeight bindings where -1 means "no minimum" (platform default is 0). + /// + public class NegativeToZeroDoubleConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is double d) return d < 0 ? 0.0 : d; + return 0.0; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Returns when the value is negative; otherwise returns the value as-is. + /// Use for MaxWidth/MaxHeight bindings where -1 means "no maximum" (platform default is ∞). + /// + public class NegativeToInfinityDoubleConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is double d) return d < 0 ? double.PositiveInfinity : d; + return double.PositiveInfinity; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + /// /// Returns 0.0 when the value is negative or zero; otherwise returns the value as-is. /// Use for Canvas.Left / Canvas.Top bindings where -1 means "default / unset". diff --git a/EmoTracker/UI/LayoutControl.axaml b/EmoTracker/UI/LayoutControl.axaml index 1faed43..033e82b 100644 --- a/EmoTracker/UI/LayoutControl.axaml +++ b/EmoTracker/UI/LayoutControl.axaml @@ -16,6 +16,10 @@ HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" IsHitTestVisible="{Binding HitTestVisible}" + MinWidth="{Binding MinWidth, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MinHeight="{Binding MinHeight, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MaxWidth="{Binding MaxWidth, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" + MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -30,6 +34,10 @@ HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" IsHitTestVisible="{Binding HitTestVisible}" + MinWidth="{Binding MinWidth, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MinHeight="{Binding MinHeight, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MaxWidth="{Binding MaxWidth, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" + MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -51,6 +59,10 @@ HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" IsHitTestVisible="{Binding HitTestVisible}" + MinWidth="{Binding MinWidth, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MinHeight="{Binding MinHeight, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MaxWidth="{Binding MaxWidth, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" + MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -74,6 +86,10 @@ HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" IsHitTestVisible="{Binding HitTestVisible}" + MinWidth="{Binding MinWidth, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MinHeight="{Binding MinHeight, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MaxWidth="{Binding MaxWidth, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" + MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -99,6 +115,10 @@ HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" IsHitTestVisible="{Binding HitTestVisible}" + MinWidth="{Binding MinWidth, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MinHeight="{Binding MinHeight, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MaxWidth="{Binding MaxWidth, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" + MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -127,6 +147,10 @@ HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" IsHitTestVisible="{Binding HitTestVisible}" + MinWidth="{Binding MinWidth, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MinHeight="{Binding MinHeight, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MaxWidth="{Binding MaxWidth, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" + MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -141,6 +165,10 @@ HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" IsHitTestVisible="{Binding HitTestVisible}" + MinWidth="{Binding MinWidth, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MinHeight="{Binding MinHeight, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MaxWidth="{Binding MaxWidth, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" + MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -170,6 +202,10 @@ HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" IsHitTestVisible="{Binding HitTestVisible}" + MinWidth="{Binding MinWidth, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MinHeight="{Binding MinHeight, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MaxWidth="{Binding MaxWidth, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" + MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -198,6 +234,10 @@ HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" IsHitTestVisible="{Binding HitTestVisible}" + MinWidth="{Binding MinWidth, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MinHeight="{Binding MinHeight, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MaxWidth="{Binding MaxWidth, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" + MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -211,6 +251,10 @@ HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" IsHitTestVisible="{Binding HitTestVisible}" + MinWidth="{Binding MinWidth, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MinHeight="{Binding MinHeight, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MaxWidth="{Binding MaxWidth, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" + MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -226,6 +270,10 @@ HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" IsHitTestVisible="{Binding HitTestVisible}" + MinWidth="{Binding MinWidth, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MinHeight="{Binding MinHeight, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MaxWidth="{Binding MaxWidth, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" + MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -238,6 +286,10 @@ HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" IsHitTestVisible="{Binding HitTestVisible}" + MinWidth="{Binding MinWidth, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MinHeight="{Binding MinHeight, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MaxWidth="{Binding MaxWidth, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" + MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -264,6 +320,10 @@ HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" IsHitTestVisible="{Binding HitTestVisible}" + MinWidth="{Binding MinWidth, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MinHeight="{Binding MinHeight, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MaxWidth="{Binding MaxWidth, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" + MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> @@ -287,6 +347,10 @@ HorizontalAlignment="{Binding HorizontalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" VerticalAlignment="{Binding VerticalAlignment, Converter={x:Static converters:TrivialEnumConverter.Instance}}" IsHitTestVisible="{Binding HitTestVisible}" + MinWidth="{Binding MinWidth, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MinHeight="{Binding MinHeight, Converter={x:Static converters:NegativeToZeroDoubleConverter.Instance}}" + MaxWidth="{Binding MaxWidth, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" + MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> From 7be6128b54d3363adf7de19504f2c01ae590f24d Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 5 Apr 2026 04:51:56 -0700 Subject: [PATCH 019/149] Temporarily disable update check during development Co-Authored-By: Claude Sonnet 4.6 --- EmoTracker/Update/AppUpdate.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/EmoTracker/Update/AppUpdate.cs b/EmoTracker/Update/AppUpdate.cs index 7e3242f..08efed3 100644 --- a/EmoTracker/Update/AppUpdate.cs +++ b/EmoTracker/Update/AppUpdate.cs @@ -182,6 +182,9 @@ private void MWebClient_DownloadFileCompleted(object sender, AsyncCompletedEvent public void CheckForUpdates() { + // TEMPORARILY DISABLE UPDATES + return; + try { Status = UpdateStatus.CheckingForUpdate; From 2d8b30ee6191790e18f59f8fa3b14f08379faf07 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 5 Apr 2026 04:52:38 -0700 Subject: [PATCH 020/149] Add VS user files and Claude local settings to .gitignore Ignores *.user, *.suo, .vs/, and .claude/settings.local.json which are developer-machine-specific and should not be tracked. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitignore b/.gitignore index e7134bb..c38f969 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,14 @@ ScaffoldingReadMe.txt *~ CodeCoverage/ +# Visual Studio user-specific files +*.user +*.suo +.vs/ + +# Claude Code local settings +.claude/settings.local.json + # MSBuild Binary and Structured Log *.binlog From eddc5e56388d8b79bc3a8c7a218975fc21695236 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 5 Apr 2026 13:15:41 -0700 Subject: [PATCH 021/149] Fix layout sizing: LayoutTransformControl, SizeToContent, Bounds fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainWindow.axaml: - Replace RenderTransform on TrackerScaleGrid with LayoutTransformControl. RenderTransform is post-layout only so Ctrl+scroll zoom left dark background visible (scale < 1) or clipped content (scale > 1). LayoutTransformControl participates in the layout pass like WPF's LayoutTransform. MainWindow.axaml.cs: - Add UpdateResizeMode() to mirror WPF's AllowResize=false behaviour: sets SizeToContent=WidthAndHeight and CanResize=false so the window shrinks to the pack's natural content size (same as WPF SizeToContent trigger). - Subscribe to Tracker.PropertyChanged so AllowResize changes at runtime update the window's resize mode (e.g. when switching packs). - Fix RefreshTrackerLayout() to fall back to Width/Height when Bounds is 0×0. At construction time Bounds has not been measured yet; using 0>0=false always picked horizontal layout regardless of the configured window size. LayoutControl.axaml: - Add HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch" to the root ContentControl so the DataTemplate-generated layout always fills the full content area rather than relying on Avalonia's Left/Top defaults. Co-Authored-By: Claude Sonnet 4.6 --- EmoTracker/MainWindow.axaml | 10 ++++++---- EmoTracker/MainWindow.axaml.cs | 30 ++++++++++++++++++++++++++++-- EmoTracker/UI/LayoutControl.axaml | 4 +++- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/EmoTracker/MainWindow.axaml b/EmoTracker/MainWindow.axaml index a7e1723..7891130 100644 --- a/EmoTracker/MainWindow.axaml +++ b/EmoTracker/MainWindow.axaml @@ -111,14 +111,16 @@ - - + + + - + - + = 0.0) Width = ApplicationSettings.Instance.InitialWidth; @@ -30,8 +31,9 @@ public MainWindow() this.KeyDown += MainWindow_KeyDown; this.PointerWheelChanged += MainWindow_PointerWheelChanged; - // Set initial layout + // Set initial layout and resize mode RefreshTrackerLayout(); + UpdateResizeMode(); } private void TitleBar_PointerPressed(object sender, PointerPressedEventArgs e) @@ -187,6 +189,26 @@ protected override void OnClosing(WindowClosingEventArgs e) base.OnClosing(e); } + private void Tracker_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Tracker.AllowResize)) + UpdateResizeMode(); + } + + private void UpdateResizeMode() + { + if (!Tracker.Instance.AllowResize) + { + CanResize = false; + SizeToContent = SizeToContent.WidthAndHeight; + } + else + { + CanResize = true; + SizeToContent = SizeToContent.Manual; + } + } + private void Instance_PropertyChanged(object sender, PropertyChangedEventArgs e) { RefreshTrackerLayout(); @@ -194,7 +216,11 @@ private void Instance_PropertyChanged(object sender, PropertyChangedEventArgs e) private void RefreshTrackerLayout() { - bool vertical = Bounds.Height > Bounds.Width; + // At construction time Bounds may be 0×0 (not yet laid out), so fall back + // to the logical Width/Height which are always set from settings or XAML defaults. + double h = Bounds.Height > 0 ? Bounds.Height : Height; + double w = Bounds.Width > 0 ? Bounds.Width : Width; + bool vertical = h > w; var layout = vertical ? ApplicationModel.Instance.TrackerVerticalLayout : ApplicationModel.Instance.TrackerHorizontalLayout; diff --git a/EmoTracker/UI/LayoutControl.axaml b/EmoTracker/UI/LayoutControl.axaml index 033e82b..fff828c 100644 --- a/EmoTracker/UI/LayoutControl.axaml +++ b/EmoTracker/UI/LayoutControl.axaml @@ -361,7 +361,9 @@ - + From 3514a060542b61d34704db8d5239c9670a6c583a Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 5 Apr 2026 13:47:29 -0700 Subject: [PATCH 022/149] Add per-element LayoutTransform scale to layout DataTemplates Implements the LayoutItem.Scale / OverrideScale feature in Avalonia, equivalent to WPF's LayoutItemStyle DataTrigger that applied a LayoutTransform when OverrideScale=true. - Add LayoutItem.EffectiveScale (returns Scale when OverrideScale, else 1.0) - Wrap every LayoutControl DataTemplate's content in LayoutTransformControl bound to EffectiveScale so the per-element scale participates in the layout pass (Margin/HAlign/VAlign moved to LTC; Width/Height/Min/Max remain on the inner Grid, matching WPF ContentPresenter semantics) Co-Authored-By: Claude Opus 4.6 --- EmoTracker.Data/Layout/LayoutItem.cs | 7 +- EmoTracker/UI/LayoutControl.axaml | 636 +++++++++++++++------------ 2 files changed, 359 insertions(+), 284 deletions(-) diff --git a/EmoTracker.Data/Layout/LayoutItem.cs b/EmoTracker.Data/Layout/LayoutItem.cs index 2cccae4..1f83625 100644 --- a/EmoTracker.Data/Layout/LayoutItem.cs +++ b/EmoTracker.Data/Layout/LayoutItem.cs @@ -154,6 +154,11 @@ public bool OverrideScale get { return mScale > 0.0; } } + public double EffectiveScale + { + get { return OverrideScale ? mScale : 1.0; } + } + public bool OverrideCanvasX { get { return mCanvasX > 0.0; } @@ -190,7 +195,7 @@ public string DockLocation public double Scale { get { return mScale; } - protected set { SetProperty(ref mScale, value); NotifyPropertyChanged("OverrideScale"); } + protected set { SetProperty(ref mScale, value); NotifyPropertyChanged("OverrideScale"); NotifyPropertyChanged("EffectiveScale"); } } public double Width diff --git a/EmoTracker/UI/LayoutControl.axaml b/EmoTracker/UI/LayoutControl.axaml index fff828c..3605e3e 100644 --- a/EmoTracker/UI/LayoutControl.axaml +++ b/EmoTracker/UI/LayoutControl.axaml @@ -9,324 +9,390 @@ xmlns:local="clr-namespace:EmoTracker.UI"> + + - - - + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + + + + + - - - + + + + + + + + - - - + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - + - - - + + + + + + + + - - - - - + + + + + + + + + + - - - + + + + + + + + - - - + + + + + + + + - - - + + + + + + + + - - - + + + + + + + + @@ -341,20 +407,24 @@ - - - - + + + + + + + + + From 6eb89077e7cb415e3761da8fccdb5141a3eda907 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 5 Apr 2026 14:19:17 -0700 Subject: [PATCH 023/149] Fix layout fill: add HorizontalContentAlignment=Stretch to item containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In WPF, ContentPresenter.HorizontalContentAlignment defaults to Stretch, so DataTemplate content fills its item container and layout constraints flow correctly (DockPanel LastChildFill, Viewbox scaling, etc.). In Avalonia it defaults to Left, causing each layout element to be arranged at its desired/natural size — the map panel dictated layout size instead of filling the remaining window space. Fix: add a shared StretchItemContainer ControlTheme and apply it as ItemContainerTheme on every layout-hosting ItemsControl (ArrayPanel, DockPanel, CanvasPanel, ViewBox, GroupBox, Container, ScrollPanel). DockPanel and CanvasPanel already had inline themes for attached properties; Stretch setters are merged into those. Co-Authored-By: Claude Opus 4.6 --- EmoTracker/UI/LayoutControl.axaml | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/EmoTracker/UI/LayoutControl.axaml b/EmoTracker/UI/LayoutControl.axaml index 3605e3e..2cc97dc 100644 --- a/EmoTracker/UI/LayoutControl.axaml +++ b/EmoTracker/UI/LayoutControl.axaml @@ -7,6 +7,16 @@ xmlns:data="clr-namespace:EmoTracker.Data;assembly=EmoTracker.Data" xmlns:layout="clr-namespace:EmoTracker.Data.Layout;assembly=EmoTracker.Data" xmlns:local="clr-namespace:EmoTracker.UI"> + + + + + + + - + @@ -284,7 +301,8 @@ MaxWidth="{Binding MaxWidth, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> - + @@ -307,7 +325,8 @@ Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> - + From e35d77808fd777c390ed5aa292f2da1e0135031b Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 5 Apr 2026 14:25:54 -0700 Subject: [PATCH 024/149] Fix Container/GroupBox panels: use Grid instead of default StackPanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default ItemsControl panel is a vertical StackPanel, which measures children with infinite height. This breaks DockPanel.LastChildFill downstream — the map gets infinite remaining space and measures at its natural image size rather than filling the window's available space. Container (JSON types "container"/"grid") maps to a single-cell Grid in WPF. Switching the ItemsPanel to Grid passes finite height constraints through the layout chain, so the DockPanel receives the actual window content height and the map's Viewbox scales to fit. Co-Authored-By: Claude Opus 4.6 --- EmoTracker/UI/LayoutControl.axaml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/EmoTracker/UI/LayoutControl.axaml b/EmoTracker/UI/LayoutControl.axaml index 2cc97dc..309ad90 100644 --- a/EmoTracker/UI/LayoutControl.axaml +++ b/EmoTracker/UI/LayoutControl.axaml @@ -279,7 +279,13 @@ + ItemContainerTheme="{StaticResource StretchItemContainer}"> + + + + + + @@ -301,8 +307,18 @@ MaxWidth="{Binding MaxWidth, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> + + ItemContainerTheme="{StaticResource StretchItemContainer}"> + + + + + + From aef77902f14e4edc3d412926710f48424911321d Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 5 Apr 2026 14:36:23 -0700 Subject: [PATCH 025/149] Fix RecentPinnedLocations: use WrapPanel with orientation binding The ItemsControl used the default vertical StackPanel, ignoring the pack JSON orientation/style settings. Pinned location cards stacked vertically instead of flowing horizontally with wrapping. Fix: use WrapPanel with Orientation bound to the model's Orientation property (parsed from the pack's "orientation"/"style" fields). Co-Authored-By: Claude Opus 4.6 --- EmoTracker/UI/LayoutControl.axaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/EmoTracker/UI/LayoutControl.axaml b/EmoTracker/UI/LayoutControl.axaml index 309ad90..569c7fd 100644 --- a/EmoTracker/UI/LayoutControl.axaml +++ b/EmoTracker/UI/LayoutControl.axaml @@ -63,6 +63,15 @@ MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> + + + + + + Date: Sun, 5 Apr 2026 14:56:45 -0700 Subject: [PATCH 026/149] Fix item margins, ButtonPopup styling, and location PreserveDimension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add StringToThicknessConverter to ItemGridControl margin bindings (Avalonia doesn't auto-convert string→Thickness in bindings) - Apply NegativeToNaNDoubleConverter to Item DataTemplate IconWidth/IconHeight - Implement full ButtonPopup DataTemplate with gear icon, image, and popup - Add HeaderContent support to GroupBox DataTemplate header bar - Add PreserveDimension binding to RecentPinnedLocations LocationControls - Add ComputedMaxWidth/MaxHeight constraints to LocationControl - Move PreserveDimension enum to EmoTracker.UI for cross-project access - Add ObjectEqualsConverter and OrientationToPreserveDimensionConverter Co-Authored-By: Claude Opus 4.6 --- .../Converters/VisibilityConverters.cs | 41 +++++++++++ EmoTracker.UI/PreserveDimension.cs | 11 +++ EmoTracker/UI/ItemGridControl.axaml | 7 +- EmoTracker/UI/LayoutControl.axaml | 68 ++++++++++++++++--- EmoTracker/UI/LocationControl.axaml | 4 +- EmoTracker/UI/LocationControl.axaml.cs | 25 +++++-- 6 files changed, 137 insertions(+), 19 deletions(-) create mode 100644 EmoTracker.UI/PreserveDimension.cs diff --git a/EmoTracker.UI/Converters/VisibilityConverters.cs b/EmoTracker.UI/Converters/VisibilityConverters.cs index 421c1e4..1db71f4 100644 --- a/EmoTracker.UI/Converters/VisibilityConverters.cs +++ b/EmoTracker.UI/Converters/VisibilityConverters.cs @@ -273,6 +273,47 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu } #if !WINDOWS + /// + /// Returns true when the value's ToString() matches the ConverterParameter + /// string (case-insensitive). Useful for controlling IsVisible based on an enum property. + /// IsVisible="{Binding Style, Converter={x:Static converters:ObjectEqualsConverter.Instance}, ConverterParameter=Settings}" + /// + public class ObjectEqualsConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value == null || parameter == null) + return false; + return string.Equals(value.ToString(), parameter.ToString(), StringComparison.OrdinalIgnoreCase); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + + /// + /// Maps EmoTracker.Data.Layout.Orientation.Horizontal → + /// and Vertical → . + /// When locations wrap horizontally their height should stay uniform; when they wrap + /// vertically their width should stay uniform. + /// + public class OrientationToPreserveDimensionConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is EmoTracker.Data.Layout.Orientation orientation) + { + return orientation == EmoTracker.Data.Layout.Orientation.Horizontal + ? PreserveDimension.Height + : PreserveDimension.Width; + } + return PreserveDimension.None; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + /// /// Converts a bool to an Avalonia /// when true, or null when false. diff --git a/EmoTracker.UI/PreserveDimension.cs b/EmoTracker.UI/PreserveDimension.cs new file mode 100644 index 0000000..ad4eaa0 --- /dev/null +++ b/EmoTracker.UI/PreserveDimension.cs @@ -0,0 +1,11 @@ +#if !WINDOWS +namespace EmoTracker.UI +{ + public enum PreserveDimension + { + None, + Width, + Height + } +} +#endif diff --git a/EmoTracker/UI/ItemGridControl.axaml b/EmoTracker/UI/ItemGridControl.axaml index cef8e44..401b1df 100644 --- a/EmoTracker/UI/ItemGridControl.axaml +++ b/EmoTracker/UI/ItemGridControl.axaml @@ -1,14 +1,15 @@ + xmlns:local="clr-namespace:EmoTracker.UI" + xmlns:converters="clr-namespace:EmoTracker.UI.Converters;assembly=EmoTracker.UI"> - + @@ -24,7 +25,7 @@ diff --git a/EmoTracker/UI/LayoutControl.axaml b/EmoTracker/UI/LayoutControl.axaml index 569c7fd..83636a8 100644 --- a/EmoTracker/UI/LayoutControl.axaml +++ b/EmoTracker/UI/LayoutControl.axaml @@ -76,7 +76,8 @@ + Compact="{Binding DataContext.CompactDisplay, RelativeSource={RelativeSource AncestorType=ItemsControl}}" + PreserveDimension="{Binding DataContext.Orientation, RelativeSource={RelativeSource AncestorType=ItemsControl}, Converter={x:Static converters:OrientationToPreserveDimensionConverter.Instance}}" /> @@ -280,9 +281,17 @@ - + + + + + + + + + + IconWidth="{Binding DataContext.Width, RelativeSource={RelativeSource AncestorType=Grid}, Converter={x:Static converters:NegativeToNaNDoubleConverter.Instance}}" + IconHeight="{Binding DataContext.Height, RelativeSource={RelativeSource AncestorType=Grid}, Converter={x:Static converters:NegativeToNaNDoubleConverter.Instance}}" /> @@ -465,8 +474,51 @@ MaxWidth="{Binding MaxWidth, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" MaxHeight="{Binding MaxHeight, Converter={x:Static converters:NegativeToInfinityDoubleConverter.Instance}}" Effect="{Binding DropShadow, Converter={x:Static converters:BoolToDropShadowEffectConverter.Instance}}"> - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EmoTracker/UI/LocationControl.axaml b/EmoTracker/UI/LocationControl.axaml index 3de52d7..f4eb9d9 100644 --- a/EmoTracker/UI/LocationControl.axaml +++ b/EmoTracker/UI/LocationControl.axaml @@ -38,7 +38,9 @@ + MinWidth="100" + MaxWidth="{Binding ComputedMaxWidth, RelativeSource={RelativeSource AncestorType=local:LocationControl}}" + MaxHeight="{Binding ComputedMaxHeight, RelativeSource={RelativeSource AncestorType=local:LocationControl}}"> diff --git a/EmoTracker/UI/LocationControl.axaml.cs b/EmoTracker/UI/LocationControl.axaml.cs index 89deb7c..cf0d9cc 100644 --- a/EmoTracker/UI/LocationControl.axaml.cs +++ b/EmoTracker/UI/LocationControl.axaml.cs @@ -10,13 +10,6 @@ namespace EmoTracker.UI { - public enum PreserveDimension - { - None, - Width, - Height - } - /// /// Interaction logic for LocationControl.axaml /// @@ -70,6 +63,17 @@ public PreserveDimension PreserveDimension ? (DataContext as DataLocation)?.ShortName : (DataContext as DataLocation)?.Name; + // ---- PreserveDimension-dependent constraints ---- + // WPF used MultiDataTriggers to set MaxWidth=120 when PreserveDimension=Width (not compact) + // and MaxHeight=90 when PreserveDimension=Height (not compact). + // In Avalonia we expose these as computed properties bound from AXAML. + + public double ComputedMaxWidth => + !Compact && PreserveDimension == PreserveDimension.Width ? 120 : double.PositiveInfinity; + + public double ComputedMaxHeight => + !Compact && PreserveDimension == PreserveDimension.Height ? 90 : double.PositiveInfinity; + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); @@ -79,6 +83,13 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang NotifyPropertyChanged(nameof(SectionHorizontalAlignment)); NotifyPropertyChanged(nameof(SectionItemMargin)); NotifyPropertyChanged(nameof(TitleText)); + NotifyPropertyChanged(nameof(ComputedMaxWidth)); + NotifyPropertyChanged(nameof(ComputedMaxHeight)); + } + else if (change.Property == PreserveDimensionProperty) + { + NotifyPropertyChanged(nameof(ComputedMaxWidth)); + NotifyPropertyChanged(nameof(ComputedMaxHeight)); } else if (change.Property == DataContextProperty) { From d1d79ab343f4cef3319c42c7a5858166a8a09ebe Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 5 Apr 2026 15:20:17 -0700 Subject: [PATCH 027/149] Fix map orientation switching with imperative code-behind Bindings from inside ItemsPanelTemplate to ancestor controls don't reliably resolve in Avalonia. Walk the visual tree to find the named MapsPanel StackPanel and set its Orientation imperatively on load, aspect ratio change, and DataContext change. Co-Authored-By: Claude Opus 4.6 --- EmoTracker/UI/LocationMapControl.axaml | 11 +++- EmoTracker/UI/LocationMapControl.axaml.cs | 61 +++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/EmoTracker/UI/LocationMapControl.axaml b/EmoTracker/UI/LocationMapControl.axaml index b563dd6..feba676 100644 --- a/EmoTracker/UI/LocationMapControl.axaml +++ b/EmoTracker/UI/LocationMapControl.axaml @@ -57,8 +57,15 @@ - + + + + + + + diff --git a/EmoTracker/UI/LocationMapControl.axaml.cs b/EmoTracker/UI/LocationMapControl.axaml.cs index d16b7e6..6482f84 100644 --- a/EmoTracker/UI/LocationMapControl.axaml.cs +++ b/EmoTracker/UI/LocationMapControl.axaml.cs @@ -5,13 +5,16 @@ using Avalonia.Interactivity; using EmoTracker.Data; using EmoTracker.Data.Core.Transactions; +using EmoTracker.Data.Layout; using EmoTracker.Data.Locations; using EmoTracker.Data.Media; using EmoTracker.UI.Controls; +using Avalonia.VisualTree; using System; using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; using Location = EmoTracker.Data.Locations.Location; namespace EmoTracker.UI @@ -44,6 +47,11 @@ private void LocationMapControl_Loaded(object? sender, VisualTreeAttachmentEvent w.OnGlobalPreviewKeyDown += MainWindow_OnGlobalPreviewKeyEvent; w.OnGlobalPreviewKeyUp += MainWindow_OnGlobalPreviewKeyEvent; } + + // The ItemsPanelTemplate may not have materialized yet at this point. + // Dispatch so the visual tree is fully built before we walk it. + Avalonia.Threading.Dispatcher.UIThread.Post(UpdateMapOrientation, + Avalonia.Threading.DispatcherPriority.Loaded); } private void LocationMapControl_Unloaded(object? sender, VisualTreeAttachmentEventArgs e) @@ -69,11 +77,39 @@ protected override void OnSizeChanged(SizeChangedEventArgs e) bool bNewAspect = e.NewSize.Height > e.NewSize.Width; if (bOldAspect != bNewAspect) + { NotifyPropertyChanged(nameof(UseVerticalOrientation)); + NotifyPropertyChanged(nameof(EffectiveMapOrientation)); + UpdateMapOrientation(); + } base.OnSizeChanged(e); } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == DataContextProperty) + { + NotifyPropertyChanged(nameof(EffectiveMapOrientation)); + UpdateMapOrientation(); + } + } + + /// + /// Imperatively sets the Orientation of the MapsPanel StackPanel inside the + /// ItemsPanelTemplate. Bindings from inside ItemsPanelTemplate to ancestor + /// controls don't reliably resolve in Avalonia, so we walk the visual tree instead. + /// + private void UpdateMapOrientation() + { + var panel = MapsItemsControl.GetVisualDescendants() + .OfType() + .FirstOrDefault(p => p.Name == "MapsPanel"); + if (panel != null) + panel.Orientation = EffectiveMapOrientation; + } + #region -- Details View -- private Control? mDetailsTarget; @@ -191,6 +227,31 @@ private void CollectBadgeImages(Location? location) public bool UseVerticalOrientation => Bounds.Height > Bounds.Width; + /// + /// Resolves the effective map orientation for the inner StackPanel. + /// WPF used DataTriggers to switch ItemsPanel; in Avalonia we compute + /// the orientation and bind the StackPanel's Orientation directly. + /// + public Avalonia.Layout.Orientation EffectiveMapOrientation + { + get + { + if (DataContext is MapPanel mapPanel) + { + return mapPanel.Orientation switch + { + MapPanel.MapOrientation.Vertical => Avalonia.Layout.Orientation.Vertical, + MapPanel.MapOrientation.Horizontal => Avalonia.Layout.Orientation.Horizontal, + MapPanel.MapOrientation.Auto => UseVerticalOrientation + ? Avalonia.Layout.Orientation.Vertical + : Avalonia.Layout.Orientation.Horizontal, + _ => Avalonia.Layout.Orientation.Horizontal + }; + } + return Avalonia.Layout.Orientation.Horizontal; + } + } + public bool IsShiftPressed { get From 523a5195b5475132d5f32371ad8e08b8a2c6eecf Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 5 Apr 2026 15:41:34 -0700 Subject: [PATCH 028/149] Fix pinned locations column alignment by removing explicit Left alignment WPF defaults HorizontalAlignment to Stretch, allowing LocationControls to fill the WrapPanel column width uniformly. The explicit Left alignment in the Avalonia template prevented this, causing uneven column widths. Co-Authored-By: Claude Opus 4.6 --- EmoTracker/UI/LayoutControl.axaml | 1 - 1 file changed, 1 deletion(-) diff --git a/EmoTracker/UI/LayoutControl.axaml b/EmoTracker/UI/LayoutControl.axaml index 83636a8..ab69492 100644 --- a/EmoTracker/UI/LayoutControl.axaml +++ b/EmoTracker/UI/LayoutControl.axaml @@ -75,7 +75,6 @@ From 6b49d4e47cebd17a36effdff0386b48b7962e5be Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 5 Apr 2026 17:00:52 -0700 Subject: [PATCH 029/149] Fix ButtonPopup sizing: use icon pixel dimensions for unspecified sizes Move Popup outside LayoutTransformControl (matching WPF structure) so popup content is not scaled by the button's EffectiveScale. Add IconDimensionMultiConverter that falls back to the source bitmap's pixel dimensions when IconWidth/IconHeight are unspecified (-1/NaN), instead of using NaN auto-sizing which interacts badly with parent layout transforms. Co-Authored-By: Claude Opus 4.6 --- .../Converters/VisibilityConverters.cs | 54 +++++++++++++ EmoTracker/UI/LayoutControl.axaml | 76 +++++++++---------- EmoTracker/UI/TrackableItemControl.axaml | 17 ++++- 3 files changed, 106 insertions(+), 41 deletions(-) diff --git a/EmoTracker.UI/Converters/VisibilityConverters.cs b/EmoTracker.UI/Converters/VisibilityConverters.cs index 1db71f4..abd17c0 100644 --- a/EmoTracker.UI/Converters/VisibilityConverters.cs +++ b/EmoTracker.UI/Converters/VisibilityConverters.cs @@ -2,6 +2,7 @@ using EmoTracker.Data.Locations; using EmoTracker.Data.Settings; using System; +using System.Collections.Generic; using System.Globalization; #if WINDOWS @@ -12,6 +13,7 @@ using Avalonia.Controls; using Avalonia.Data.Converters; using Avalonia.Media; +using Avalonia.Media.Imaging; #endif namespace EmoTracker.UI.Converters @@ -273,6 +275,29 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu } #if !WINDOWS + /// + /// Returns when the value is negative, + /// causing the binding to fall back to the target property's default value. + /// If a ConverterParameter is supplied, returns that as a double instead + /// of UnsetValue, allowing callers to specify a fallback size explicitly. + /// Use for IconWidth/IconHeight bindings where -1 means "use a sensible default" + /// rather than NaN (auto-size to source dimensions — can be huge for banner images). + /// + public class NegativeToUnsetConverter : Singleton, IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is double d && d >= 0) return d; + if (parameter != null + && double.TryParse(parameter.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double fallback)) + return fallback; + return Avalonia.AvaloniaProperty.UnsetValue; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + } + /// /// Returns true when the value's ToString() matches the ConverterParameter /// string (case-insensitive). Useful for controlling IsVisible based on an enum property. @@ -337,5 +362,34 @@ public object Convert(object value, Type targetType, object parameter, CultureIn public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotSupportedException(); } + + /// + /// Multi-value converter for icon dimensions. Returns the explicit dimension when it is + /// a valid positive number; otherwise falls back to the pixel dimensions of the bound + /// source image. Use ConverterParameter="Width" or + /// "Height" to select which pixel dimension to read. + /// values[0] = dimension (double, e.g. IconWidth), values[1] = Image.Source (IImage). + /// + public class IconDimensionMultiConverter : Singleton, IMultiValueConverter + { + public object Convert(IList values, Type targetType, object parameter, CultureInfo culture) + { + double dimension = values.Count > 0 && values[0] is double d ? d : double.NaN; + + // If the layout specifies a valid positive size, use it directly. + if (dimension > 0 && !double.IsNaN(dimension)) + return dimension; + + // Fall back to the source image's pixel dimensions. + if (values.Count > 1 && values[1] is Bitmap bitmap) + { + bool useWidth = string.Equals(parameter?.ToString(), "Width", StringComparison.OrdinalIgnoreCase); + return (double)(useWidth ? bitmap.PixelSize.Width : bitmap.PixelSize.Height); + } + + // Last resort default. + return 32.0; + } + } #endif } diff --git a/EmoTracker/UI/LayoutControl.axaml b/EmoTracker/UI/LayoutControl.axaml index ab69492..9523219 100644 --- a/EmoTracker/UI/LayoutControl.axaml +++ b/EmoTracker/UI/LayoutControl.axaml @@ -459,21 +459,22 @@ - - - - - - + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/EmoTracker/UI/TrackableItemControl.axaml b/EmoTracker/UI/TrackableItemControl.axaml index d037388..d446de4 100644 --- a/EmoTracker/UI/TrackableItemControl.axaml +++ b/EmoTracker/UI/TrackableItemControl.axaml @@ -17,9 +17,20 @@ + Source="{Binding Icon, Converter={x:Static converters:ImageReferenceConverter.Instance}}"> + + + + + + + + + + + + + Date: Sun, 5 Apr 2026 17:14:23 -0700 Subject: [PATCH 030/149] Fix F2 and F11 keyboard shortcuts, add map location visibility logic - F2: Implement BroadcastView window for Avalonia and wire up ShowBroadcastView command (was a no-op with #if WINDOWS guard) - F11: Add MapLocationVisibilityConverter that replicates WPF's MultiDataTrigger logic for hiding cleared/empty locations based on DisplayAllLocations, shift key, ForceVisible/ForceInvisible - Use tunnel routing for KeyDown to match WPF's PreviewKeyDown behavior Co-Authored-By: Claude Opus 4.6 --- .../Converters/VisibilityConverters.cs | 46 +++++++++++++++++++ EmoTracker/ApplicationModel.cs | 19 ++++++++ EmoTracker/MainWindow.axaml.cs | 4 +- EmoTracker/UI/BroadcastView.axaml | 15 ++++++ EmoTracker/UI/BroadcastView.axaml.cs | 25 ++++++++++ EmoTracker/UI/LocationMapControl.axaml | 16 ++++++- 6 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 EmoTracker/UI/BroadcastView.axaml create mode 100644 EmoTracker/UI/BroadcastView.axaml.cs diff --git a/EmoTracker.UI/Converters/VisibilityConverters.cs b/EmoTracker.UI/Converters/VisibilityConverters.cs index abd17c0..e3f3693 100644 --- a/EmoTracker.UI/Converters/VisibilityConverters.cs +++ b/EmoTracker.UI/Converters/VisibilityConverters.cs @@ -391,5 +391,51 @@ public object Convert(IList values, Type targetType, object parameter, C return 32.0; } } + + /// + /// Multi-value converter that replicates WPF's MultiDataTrigger-based map location + /// visibility logic. Evaluates (in priority order): ForceInvisible, ForceVisible, + /// HasVisibleSections, Cleared+DisplayAll+Shift, Empty+DisplayAll+Shift. + /// Binding order: + /// [0] ForceVisible (bool), [1] ForceInvisible (bool), + /// [2] Location.HasVisibleSections (bool), [3] Location.AccessibilityLevel (enum), + /// [4] Location.HasAvailableItems (bool), [5] Location.Badges.Count (int), + /// [6] Location.NoteTakingSite.Empty (bool), + /// [7] DisplayAllLocations (bool), [8] IsShiftPressed (bool). + /// + public class MapLocationVisibilityConverter : Singleton, IMultiValueConverter + { + public object Convert(IList values, Type targetType, object parameter, CultureInfo culture) + { + if (values.Count < 9) return true; + + bool forceVisible = values[0] is true; + bool forceInvisible = values[1] is true; + bool hasVisibleSections = values[2] is true; + bool isCleared = values[3] is AccessibilityLevel level + && level == AccessibilityLevel.Cleared; + bool hasAvailableItems = values[4] is true; + int badgeCount = values[5] is int bc ? bc : 0; + bool notesEmpty = values[6] is not false; // default true when null/unset + bool displayAll = values[7] is true; + bool shiftPressed = values[8] is true; + + // Highest priority: script-driven force rules (last WPF triggers win) + if (forceInvisible) return false; + if (forceVisible) return true; + + // No visible sections at all → hide + if (!hasVisibleSections) return false; + + // Cleared location hidden unless DisplayAll or Shift + if (isCleared && !displayAll && !shiftPressed) return false; + + // Empty location (no items, badges, or notes) hidden unless DisplayAll or Shift + if (!hasAvailableItems && badgeCount == 0 && notesEmpty && !displayAll && !shiftPressed) + return false; + + return true; + } + } #endif } diff --git a/EmoTracker/ApplicationModel.cs b/EmoTracker/ApplicationModel.cs index d2bcbac..600b9a3 100644 --- a/EmoTracker/ApplicationModel.cs +++ b/EmoTracker/ApplicationModel.cs @@ -220,9 +220,28 @@ private void ShowBroadcastView(object obj) MainWindow appWindow = Application.Current.MainWindow as MainWindow; if (appWindow != null) appWindow.ShowBroadcastView(); +#else + if (mBroadcastView == null) + { + mBroadcastView = new BroadcastView(); + mBroadcastView.Closing += (_, _) => mBroadcastView = null; + + var mainWindow = (Avalonia.Application.Current?.ApplicationLifetime + as Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.MainWindow; + + mBroadcastView.Show(mainWindow); + } + else + { + mBroadcastView.Activate(); + } #endif } +#if !WINDOWS + private BroadcastView mBroadcastView; +#endif + private void ShowDevleoperConsole(object obj) { #if WINDOWS diff --git a/EmoTracker/MainWindow.axaml.cs b/EmoTracker/MainWindow.axaml.cs index d91ae73..df27433 100644 --- a/EmoTracker/MainWindow.axaml.cs +++ b/EmoTracker/MainWindow.axaml.cs @@ -28,7 +28,9 @@ public MainWindow() Height = ApplicationSettings.Instance.InitialHeight; this.Loaded += MainWindow_Loaded; - this.KeyDown += MainWindow_KeyDown; + // Use Tunnel routing to match WPF's PreviewKeyDown — the window + // handles shortcuts before any child control can consume the key. + this.AddHandler(KeyDownEvent, MainWindow_KeyDown, Avalonia.Interactivity.RoutingStrategies.Tunnel); this.PointerWheelChanged += MainWindow_PointerWheelChanged; // Set initial layout and resize mode diff --git a/EmoTracker/UI/BroadcastView.axaml b/EmoTracker/UI/BroadcastView.axaml new file mode 100644 index 0000000..b5b9fb6 --- /dev/null +++ b/EmoTracker/UI/BroadcastView.axaml @@ -0,0 +1,15 @@ + + + + + diff --git a/EmoTracker/UI/BroadcastView.axaml.cs b/EmoTracker/UI/BroadcastView.axaml.cs new file mode 100644 index 0000000..762b3e1 --- /dev/null +++ b/EmoTracker/UI/BroadcastView.axaml.cs @@ -0,0 +1,25 @@ +using Avalonia.Controls; +using Avalonia.Input; + +namespace EmoTracker.UI +{ + public partial class BroadcastView : Window + { + public BroadcastView() + { + InitializeComponent(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.F5) + { + ApplicationModel.Instance.RefreshCommand.Execute(null); + e.Handled = true; + return; + } + + base.OnKeyDown(e); + } + } +} diff --git a/EmoTracker/UI/LocationMapControl.axaml b/EmoTracker/UI/LocationMapControl.axaml index feba676..39b0893 100644 --- a/EmoTracker/UI/LocationMapControl.axaml +++ b/EmoTracker/UI/LocationMapControl.axaml @@ -86,8 +86,20 @@ - + + + + + + + + + + + + + + Date: Sun, 5 Apr 2026 17:39:06 -0700 Subject: [PATCH 031/149] Use native window chrome, move app buttons to status bar Replace custom titlebar (BorderOnly + manual drag/min/max/close) with native system decorations. Move Settings, Package Manager, and Refresh buttons to the left side of the status bar for cross-platform compatibility. Co-Authored-By: Claude Opus 4.6 --- EmoTracker/MainWindow.axaml | 128 +++++++++------------------------ EmoTracker/MainWindow.axaml.cs | 30 -------- 2 files changed, 35 insertions(+), 123 deletions(-) diff --git a/EmoTracker/MainWindow.axaml b/EmoTracker/MainWindow.axaml index 7891130..c9c3e71 100644 --- a/EmoTracker/MainWindow.axaml +++ b/EmoTracker/MainWindow.axaml @@ -17,100 +17,12 @@ MinWidth="220" MinHeight="61" Width="1280" Height="960" Title="{Binding MainWindowTitle, Source={x:Static local:ApplicationModel.Instance}}" - Topmost="{Binding AlwaysOnTop, Source={x:Static data:ApplicationSettings.Instance}}" - SystemDecorations="BorderOnly"> + Topmost="{Binding AlwaysOnTop, Source={x:Static data:ApplicationSettings.Instance}}"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + @@ -152,8 +64,8 @@ - - + + @@ -161,6 +73,36 @@ + + + + + + + + + diff --git a/EmoTracker/MainWindow.axaml.cs b/EmoTracker/MainWindow.axaml.cs index df27433..21ed82c 100644 --- a/EmoTracker/MainWindow.axaml.cs +++ b/EmoTracker/MainWindow.axaml.cs @@ -38,27 +38,10 @@ public MainWindow() UpdateResizeMode(); } - private void TitleBar_PointerPressed(object sender, PointerPressedEventArgs e) - { - if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed - && e.Source is not Button) - { - BeginMoveDrag(e); - } - } - private void MainWindow_Loaded(object sender, RoutedEventArgs e) { - if (this.FindControl