aboutsummaryrefslogtreecommitdiff
path: root/src/Ryujinx.Ava/UI/Helpers
diff options
context:
space:
mode:
authorTSR Berry <20988865+TSRBerry@users.noreply.github.com>2023-04-08 01:22:00 +0200
committerMary <thog@protonmail.com>2023-04-27 23:51:14 +0200
commitcee712105850ac3385cd0091a923438167433f9f (patch)
tree4a5274b21d8b7f938c0d0ce18736d3f2993b11b1 /src/Ryujinx.Ava/UI/Helpers
parentcd124bda587ef09668a971fa1cac1c3f0cfc9f21 (diff)
Move solution and projects to src
Diffstat (limited to 'src/Ryujinx.Ava/UI/Helpers')
-rw-r--r--src/Ryujinx.Ava/UI/Helpers/ApplicationOpenedEventArgs.cs16
-rw-r--r--src/Ryujinx.Ava/UI/Helpers/BitmapArrayValueConverter.cs35
-rw-r--r--src/Ryujinx.Ava/UI/Helpers/ButtonKeyAssigner.cs118
-rw-r--r--src/Ryujinx.Ava/UI/Helpers/ContentDialogHelper.cs407
-rw-r--r--src/Ryujinx.Ava/UI/Helpers/Glyph.cs9
-rw-r--r--src/Ryujinx.Ava/UI/Helpers/GlyphValueConverter.cs49
-rw-r--r--src/Ryujinx.Ava/UI/Helpers/HotKeyControl.cs52
-rw-r--r--src/Ryujinx.Ava/UI/Helpers/KeyValueConverter.cs46
-rw-r--r--src/Ryujinx.Ava/UI/Helpers/LoggerAdapter.cs115
-rw-r--r--src/Ryujinx.Ava/UI/Helpers/MiniCommand.cs71
-rw-r--r--src/Ryujinx.Ava/UI/Helpers/NotificationHelper.cs65
-rw-r--r--src/Ryujinx.Ava/UI/Helpers/OffscreenTextBox.cs40
-rw-r--r--src/Ryujinx.Ava/UI/Helpers/UserErrorDialog.cs91
-rw-r--r--src/Ryujinx.Ava/UI/Helpers/UserResult.cs12
-rw-r--r--src/Ryujinx.Ava/UI/Helpers/Win32NativeInterop.cs123
15 files changed, 1249 insertions, 0 deletions
diff --git a/src/Ryujinx.Ava/UI/Helpers/ApplicationOpenedEventArgs.cs b/src/Ryujinx.Ava/UI/Helpers/ApplicationOpenedEventArgs.cs
new file mode 100644
index 00000000..ebf5c16e
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Helpers/ApplicationOpenedEventArgs.cs
@@ -0,0 +1,16 @@
+using Avalonia.Interactivity;
+using Ryujinx.Ui.App.Common;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+ public class ApplicationOpenedEventArgs : RoutedEventArgs
+ {
+ public ApplicationData Application { get; }
+
+ public ApplicationOpenedEventArgs(ApplicationData application, RoutedEvent routedEvent)
+ {
+ Application = application;
+ RoutedEvent = routedEvent;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Helpers/BitmapArrayValueConverter.cs b/src/Ryujinx.Ava/UI/Helpers/BitmapArrayValueConverter.cs
new file mode 100644
index 00000000..3fd368f8
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Helpers/BitmapArrayValueConverter.cs
@@ -0,0 +1,35 @@
+using Avalonia.Data.Converters;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using System;
+using System.Globalization;
+using System.IO;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+ internal class BitmapArrayValueConverter : IValueConverter
+ {
+ public static BitmapArrayValueConverter Instance = new();
+
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value == null)
+ {
+ return null;
+ }
+
+ if (value is byte[] buffer && targetType == typeof(IImage))
+ {
+ MemoryStream mem = new(buffer);
+ return new Bitmap(mem);
+ }
+
+ throw new NotSupportedException();
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotSupportedException();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Helpers/ButtonKeyAssigner.cs b/src/Ryujinx.Ava/UI/Helpers/ButtonKeyAssigner.cs
new file mode 100644
index 00000000..6730b571
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Helpers/ButtonKeyAssigner.cs
@@ -0,0 +1,118 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.LogicalTree;
+using Avalonia.Threading;
+using Ryujinx.Input;
+using Ryujinx.Input.Assigner;
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+ internal class ButtonKeyAssigner
+ {
+ internal class ButtonAssignedEventArgs : EventArgs
+ {
+ public ToggleButton Button { get; }
+ public bool IsAssigned { get; }
+
+ public ButtonAssignedEventArgs(ToggleButton button, bool isAssigned)
+ {
+ Button = button;
+ IsAssigned = isAssigned;
+ }
+ }
+
+ public ToggleButton ToggledButton { get; set; }
+
+ private bool _isWaitingForInput;
+ private bool _shouldUnbind;
+ public event EventHandler<ButtonAssignedEventArgs> ButtonAssigned;
+
+ public ButtonKeyAssigner(ToggleButton toggleButton)
+ {
+ ToggledButton = toggleButton;
+ }
+
+ public async void GetInputAndAssign(IButtonAssigner assigner, IKeyboard keyboard = null)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ ToggledButton.IsChecked = true;
+ });
+
+ if (_isWaitingForInput)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ Cancel();
+ });
+
+ return;
+ }
+
+ _isWaitingForInput = true;
+
+ assigner.Initialize();
+
+ await Task.Run(async () =>
+ {
+ while (true)
+ {
+ if (!_isWaitingForInput)
+ {
+ return;
+ }
+
+ await Task.Delay(10);
+
+ assigner.ReadInput();
+
+ if (assigner.HasAnyButtonPressed() || assigner.ShouldCancel() || (keyboard != null && keyboard.IsPressed(Key.Escape)))
+ {
+ break;
+ }
+ }
+ });
+
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ string pressedButton = assigner.GetPressedButton();
+
+ if (_shouldUnbind)
+ {
+ SetButtonText(ToggledButton, "Unbound");
+ }
+ else if (pressedButton != "")
+ {
+ SetButtonText(ToggledButton, pressedButton);
+ }
+
+ _shouldUnbind = false;
+ _isWaitingForInput = false;
+
+ ToggledButton.IsChecked = false;
+
+ ButtonAssigned?.Invoke(this, new ButtonAssignedEventArgs(ToggledButton, pressedButton != null));
+
+ static void SetButtonText(ToggleButton button, string text)
+ {
+ ILogical textBlock = button.GetLogicalDescendants().First(x => x is TextBlock);
+
+ if (textBlock != null && textBlock is TextBlock block)
+ {
+ block.Text = text;
+ }
+ }
+ });
+ }
+
+ public void Cancel(bool shouldUnbind = false)
+ {
+ _isWaitingForInput = false;
+ ToggledButton.IsChecked = false;
+ _shouldUnbind = shouldUnbind;
+ }
+ }
+}
diff --git a/src/Ryujinx.Ava/UI/Helpers/ContentDialogHelper.cs b/src/Ryujinx.Ava/UI/Helpers/ContentDialogHelper.cs
new file mode 100644
index 00000000..cb474506
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Helpers/ContentDialogHelper.cs
@@ -0,0 +1,407 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Media;
+using Avalonia.Threading;
+using FluentAvalonia.Core;
+using FluentAvalonia.UI.Controls;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Controls;
+using Ryujinx.Ava.UI.Windows;
+using Ryujinx.Common.Logging;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+ public static class ContentDialogHelper
+ {
+ private static bool _isChoiceDialogOpen;
+
+ public async static Task<UserResult> ShowContentDialog(
+ string title,
+ object content,
+ string primaryButton,
+ string secondaryButton,
+ string closeButton,
+ UserResult primaryButtonResult = UserResult.Ok,
+ ManualResetEvent deferResetEvent = null,
+ TypedEventHandler<ContentDialog, ContentDialogButtonClickEventArgs> deferCloseAction = null)
+ {
+ UserResult result = UserResult.None;
+
+ ContentDialog contentDialog = new()
+ {
+ Title = title,
+ PrimaryButtonText = primaryButton,
+ SecondaryButtonText = secondaryButton,
+ CloseButtonText = closeButton,
+ Content = content
+ };
+
+ contentDialog.PrimaryButtonCommand = MiniCommand.Create(() =>
+ {
+ result = primaryButtonResult;
+ });
+
+ contentDialog.SecondaryButtonCommand = MiniCommand.Create(() =>
+ {
+ result = UserResult.No;
+ contentDialog.PrimaryButtonClick -= deferCloseAction;
+ });
+
+ contentDialog.CloseButtonCommand = MiniCommand.Create(() =>
+ {
+ result = UserResult.Cancel;
+ contentDialog.PrimaryButtonClick -= deferCloseAction;
+ });
+
+ if (deferResetEvent != null)
+ {
+ contentDialog.PrimaryButtonClick += deferCloseAction;
+ }
+
+ await ShowAsync(contentDialog);
+
+ return result;
+ }
+
+ private async static Task<UserResult> ShowTextDialog(
+ string title,
+ string primaryText,
+ string secondaryText,
+ string primaryButton,
+ string secondaryButton,
+ string closeButton,
+ int iconSymbol,
+ UserResult primaryButtonResult = UserResult.Ok,
+ ManualResetEvent deferResetEvent = null,
+ TypedEventHandler<ContentDialog, ContentDialogButtonClickEventArgs> deferCloseAction = null)
+ {
+ Grid content = CreateTextDialogContent(primaryText, secondaryText, iconSymbol);
+
+ return await ShowContentDialog(title, content, primaryButton, secondaryButton, closeButton, primaryButtonResult, deferResetEvent, deferCloseAction);
+ }
+
+ public async static Task<UserResult> ShowDeferredContentDialog(
+ StyleableWindow window,
+ string title,
+ string primaryText,
+ string secondaryText,
+ string primaryButton,
+ string secondaryButton,
+ string closeButton,
+ int iconSymbol,
+ ManualResetEvent deferResetEvent,
+ Func<Window, Task> doWhileDeferred = null)
+ {
+ bool startedDeferring = false;
+ UserResult result = UserResult.None;
+
+ return await ShowTextDialog(
+ title,
+ primaryText,
+ secondaryText,
+ primaryButton,
+ secondaryButton,
+ closeButton,
+ iconSymbol,
+ primaryButton == LocaleManager.Instance[LocaleKeys.InputDialogYes] ? UserResult.Yes : UserResult.Ok,
+ deferResetEvent,
+ DeferClose);
+
+ async void DeferClose(ContentDialog sender, ContentDialogButtonClickEventArgs args)
+ {
+ if (startedDeferring)
+ {
+ return;
+ }
+
+ sender.PrimaryButtonClick -= DeferClose;
+
+ startedDeferring = true;
+
+ var deferral = args.GetDeferral();
+
+ result = primaryButton == LocaleManager.Instance[LocaleKeys.InputDialogYes] ? UserResult.Yes : UserResult.Ok;
+
+ sender.PrimaryButtonClick -= DeferClose;
+
+ _ = Task.Run(() =>
+ {
+ deferResetEvent.WaitOne();
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ deferral.Complete();
+ });
+ });
+
+ if (doWhileDeferred != null)
+ {
+ await doWhileDeferred(window);
+
+ deferResetEvent.Set();
+ }
+ }
+ }
+
+ private static Grid CreateTextDialogContent(string primaryText, string secondaryText, int symbol)
+ {
+ Grid content = new()
+ {
+ RowDefinitions = new RowDefinitions() { new RowDefinition(), new RowDefinition() },
+ ColumnDefinitions = new ColumnDefinitions() { new ColumnDefinition(GridLength.Auto), new ColumnDefinition() },
+
+ MinHeight = 80
+ };
+
+ SymbolIcon icon = new()
+ {
+ Symbol = (Symbol)symbol,
+ Margin = new Thickness(10),
+ FontSize = 40,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
+ };
+
+ Grid.SetColumn(icon, 0);
+ Grid.SetRowSpan(icon, 2);
+ Grid.SetRow(icon, 0);
+
+ TextBlock primaryLabel = new()
+ {
+ Text = primaryText,
+ Margin = new Thickness(5),
+ TextWrapping = TextWrapping.Wrap,
+ MaxWidth = 450
+ };
+
+ TextBlock secondaryLabel = new()
+ {
+ Text = secondaryText,
+ Margin = new Thickness(5),
+ TextWrapping = TextWrapping.Wrap,
+ MaxWidth = 450
+ };
+
+ Grid.SetColumn(primaryLabel, 1);
+ Grid.SetColumn(secondaryLabel, 1);
+ Grid.SetRow(primaryLabel, 0);
+ Grid.SetRow(secondaryLabel, 1);
+
+ content.Children.Add(icon);
+ content.Children.Add(primaryLabel);
+ content.Children.Add(secondaryLabel);
+
+ return content;
+ }
+
+ public static async Task<UserResult> CreateInfoDialog(
+ string primary,
+ string secondaryText,
+ string acceptButton,
+ string closeButton,
+ string title)
+ {
+ return await ShowTextDialog(
+ title,
+ primary,
+ secondaryText,
+ acceptButton,
+ "",
+ closeButton,
+ (int)Symbol.Important);
+ }
+
+ internal static async Task<UserResult> CreateConfirmationDialog(
+ string primaryText,
+ string secondaryText,
+ string acceptButtonText,
+ string cancelButtonText,
+ string title,
+ UserResult primaryButtonResult = UserResult.Yes)
+ {
+ return await ShowTextDialog(
+ string.IsNullOrWhiteSpace(title) ? LocaleManager.Instance[LocaleKeys.DialogConfirmationTitle] : title,
+ primaryText,
+ secondaryText,
+ acceptButtonText,
+ "",
+ cancelButtonText,
+ (int)Symbol.Help,
+ primaryButtonResult);
+ }
+
+ internal static async Task CreateUpdaterInfoDialog(string primary, string secondaryText)
+ {
+ await ShowTextDialog(
+ LocaleManager.Instance[LocaleKeys.DialogUpdaterTitle],
+ primary,
+ secondaryText,
+ "",
+ "",
+ LocaleManager.Instance[LocaleKeys.InputDialogOk],
+ (int)Symbol.Important);
+ }
+
+ internal static async Task CreateWarningDialog(string primary, string secondaryText)
+ {
+ await ShowTextDialog(
+ LocaleManager.Instance[LocaleKeys.DialogWarningTitle],
+ primary,
+ secondaryText,
+ "",
+ "",
+ LocaleManager.Instance[LocaleKeys.InputDialogOk],
+ (int)Symbol.Important);
+ }
+
+ internal static async Task CreateErrorDialog(string errorMessage, string secondaryErrorMessage = "")
+ {
+ Logger.Error?.Print(LogClass.Application, errorMessage);
+
+ await ShowTextDialog(
+ LocaleManager.Instance[LocaleKeys.DialogErrorTitle],
+ LocaleManager.Instance[LocaleKeys.DialogErrorMessage],
+ errorMessage,
+ secondaryErrorMessage,
+ "",
+ LocaleManager.Instance[LocaleKeys.InputDialogOk],
+ (int)Symbol.Dismiss);
+ }
+
+ internal static async Task<bool> CreateChoiceDialog(string title, string primary, string secondaryText)
+ {
+ if (_isChoiceDialogOpen)
+ {
+ return false;
+ }
+
+ _isChoiceDialogOpen = true;
+
+ UserResult response = await ShowTextDialog(
+ title,
+ primary,
+ secondaryText,
+ LocaleManager.Instance[LocaleKeys.InputDialogYes],
+ "",
+ LocaleManager.Instance[LocaleKeys.InputDialogNo],
+ (int)Symbol.Help,
+ UserResult.Yes);
+
+ _isChoiceDialogOpen = false;
+
+ return response == UserResult.Yes;
+ }
+
+ internal static async Task<bool> CreateExitDialog()
+ {
+ return await CreateChoiceDialog(
+ LocaleManager.Instance[LocaleKeys.DialogExitTitle],
+ LocaleManager.Instance[LocaleKeys.DialogExitMessage],
+ LocaleManager.Instance[LocaleKeys.DialogExitSubMessage]);
+ }
+
+ internal static async Task<bool> CreateStopEmulationDialog()
+ {
+ return await CreateChoiceDialog(
+ LocaleManager.Instance[LocaleKeys.DialogStopEmulationTitle],
+ LocaleManager.Instance[LocaleKeys.DialogStopEmulationMessage],
+ LocaleManager.Instance[LocaleKeys.DialogExitSubMessage]);
+ }
+
+ public static async Task<ContentDialogResult> ShowAsync(ContentDialog contentDialog)
+ {
+ ContentDialogResult result;
+
+ ContentDialogOverlayWindow contentDialogOverlayWindow = null;
+
+ Window parent = GetMainWindow();
+
+ if (parent != null && parent.IsActive && parent is MainWindow window && window.ViewModel.IsGameRunning)
+ {
+ contentDialogOverlayWindow = new()
+ {
+ Height = parent.Bounds.Height,
+ Width = parent.Bounds.Width,
+ Position = parent.PointToScreen(new Point()),
+ ShowInTaskbar = false
+ };
+
+ parent.PositionChanged += OverlayOnPositionChanged;
+
+ void OverlayOnPositionChanged(object sender, PixelPointEventArgs e)
+ {
+ contentDialogOverlayWindow.Position = parent.PointToScreen(new Point());
+ }
+
+ contentDialogOverlayWindow.ContentDialog = contentDialog;
+
+ bool opened = false;
+
+ contentDialogOverlayWindow.Opened += OverlayOnActivated;
+
+ async void OverlayOnActivated(object sender, EventArgs e)
+ {
+ if (opened)
+ {
+ return;
+ }
+
+ opened = true;
+
+ contentDialogOverlayWindow.Position = parent.PointToScreen(new Point());
+
+ result = await ShowDialog();
+ }
+
+ result = await contentDialogOverlayWindow.ShowDialog<ContentDialogResult>(parent);
+ }
+ else
+ {
+ result = await ShowDialog();
+ }
+
+ async Task<ContentDialogResult> ShowDialog()
+ {
+ if (contentDialogOverlayWindow is not null)
+ {
+ result = await contentDialog.ShowAsync(contentDialogOverlayWindow);
+
+ contentDialogOverlayWindow!.Close();
+ }
+ else
+ {
+ result = await contentDialog.ShowAsync();
+ }
+
+ return result;
+ }
+
+ if (contentDialogOverlayWindow is not null)
+ {
+ contentDialogOverlayWindow.Content = null;
+ contentDialogOverlayWindow.Close();
+ }
+
+ return result;
+ }
+
+ private static Window GetMainWindow()
+ {
+ if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime al)
+ {
+ foreach (Window item in al.Windows)
+ {
+ if (item.IsActive && item is MainWindow window)
+ {
+ return window;
+ }
+ }
+ }
+
+ return null;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Helpers/Glyph.cs b/src/Ryujinx.Ava/UI/Helpers/Glyph.cs
new file mode 100644
index 00000000..4aae854f
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Helpers/Glyph.cs
@@ -0,0 +1,9 @@
+namespace Ryujinx.Ava.UI.Helpers
+{
+ public enum Glyph
+ {
+ List,
+ Grid,
+ Chip
+ }
+}
diff --git a/src/Ryujinx.Ava/UI/Helpers/GlyphValueConverter.cs b/src/Ryujinx.Ava/UI/Helpers/GlyphValueConverter.cs
new file mode 100644
index 00000000..3d6c9c01
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Helpers/GlyphValueConverter.cs
@@ -0,0 +1,49 @@
+using Avalonia.Data;
+using Avalonia.Markup.Xaml;
+using FluentAvalonia.UI.Controls;
+using System;
+using System.Collections.Generic;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+ public class GlyphValueConverter : MarkupExtension
+ {
+ private string _key;
+
+ private static Dictionary<Glyph, string> _glyphs = new Dictionary<Glyph, string>
+ {
+ { Glyph.List, char.ConvertFromUtf32((int)Symbol.List).ToString() },
+ { Glyph.Grid, char.ConvertFromUtf32((int)Symbol.ViewAll).ToString() },
+ { Glyph.Chip, char.ConvertFromUtf32(59748).ToString() }
+ };
+
+ public GlyphValueConverter(string key)
+ {
+ _key = key;
+ }
+
+ public string this[string key]
+ {
+ get
+ {
+ if (_glyphs.TryGetValue(Enum.Parse<Glyph>(key), out var val))
+ {
+ return val;
+ }
+
+ return string.Empty;
+ }
+ }
+
+ public override object ProvideValue(IServiceProvider serviceProvider)
+ {
+ Avalonia.Markup.Xaml.MarkupExtensions.ReflectionBindingExtension binding = new($"[{_key}]")
+ {
+ Mode = BindingMode.OneWay,
+ Source = this
+ };
+
+ return binding.ProvideValue(serviceProvider);
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Helpers/HotKeyControl.cs b/src/Ryujinx.Ava/UI/Helpers/HotKeyControl.cs
new file mode 100644
index 00000000..f1fad157
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Helpers/HotKeyControl.cs
@@ -0,0 +1,52 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using System;
+using System.Windows.Input;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+ public class HotKeyControl : ContentControl, ICommandSource
+ {
+ public static readonly StyledProperty<object> CommandParameterProperty =
+ AvaloniaProperty.Register<HotKeyControl, object>(nameof(CommandParameter));
+
+ public static readonly DirectProperty<HotKeyControl, ICommand> CommandProperty =
+ AvaloniaProperty.RegisterDirect<HotKeyControl, ICommand>(nameof(Command),
+ control => control.Command, (control, command) => control.Command = command, enableDataValidation: true);
+
+ public static readonly StyledProperty<KeyGesture> HotKeyProperty = HotKeyManager.HotKeyProperty.AddOwner<Button>();
+
+ private ICommand _command;
+ private bool _commandCanExecute;
+
+ public ICommand Command
+ {
+ get { return _command; }
+ set { SetAndRaise(CommandProperty, ref _command, value); }
+ }
+
+ public KeyGesture HotKey
+ {
+ get { return GetValue(HotKeyProperty); }
+ set { SetValue(HotKeyProperty, value); }
+ }
+
+ public object CommandParameter
+ {
+ get { return GetValue(CommandParameterProperty); }
+ set { SetValue(CommandParameterProperty, value); }
+ }
+
+ public void CanExecuteChanged(object sender, EventArgs e)
+ {
+ var canExecute = Command == null || Command.CanExecute(CommandParameter);
+
+ if (canExecute != _commandCanExecute)
+ {
+ _commandCanExecute = canExecute;
+ UpdateIsEffectivelyEnabled();
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Helpers/KeyValueConverter.cs b/src/Ryujinx.Ava/UI/Helpers/KeyValueConverter.cs
new file mode 100644
index 00000000..8d5c2815
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Helpers/KeyValueConverter.cs
@@ -0,0 +1,46 @@
+using Avalonia.Data.Converters;
+using Ryujinx.Common.Configuration.Hid;
+using Ryujinx.Common.Configuration.Hid.Controller;
+using System;
+using System.Globalization;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+ internal class KeyValueConverter : IValueConverter
+ {
+ public static KeyValueConverter Instance = new();
+
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value == null)
+ {
+ return null;
+ }
+
+ return value.ToString();
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ object key = null;
+
+ if (value != null)
+ {
+ if (targetType == typeof(Key))
+ {
+ key = Enum.Parse<Key>(value.ToString());
+ }
+ else if (targetType == typeof(GamepadInputId))
+ {
+ key = Enum.Parse<GamepadInputId>(value.ToString());
+ }
+ else if (targetType == typeof(StickInputId))
+ {
+ key = Enum.Parse<StickInputId>(value.ToString());
+ }
+ }
+
+ return key;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Helpers/LoggerAdapter.cs b/src/Ryujinx.Ava/UI/Helpers/LoggerAdapter.cs
new file mode 100644
index 00000000..7a29cc19
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Helpers/LoggerAdapter.cs
@@ -0,0 +1,115 @@
+using Avalonia.Utilities;
+using System;
+using System.Text;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+ using AvaLogger = Avalonia.Logging.Logger;
+ using AvaLogLevel = Avalonia.Logging.LogEventLevel;
+ using RyuLogClass = Ryujinx.Common.Logging.LogClass;
+ using RyuLogger = Ryujinx.Common.Logging.Logger;
+
+ internal class LoggerAdapter : Avalonia.Logging.ILogSink
+ {
+ public static void Register()
+ {
+ AvaLogger.Sink = new LoggerAdapter();
+ }
+
+ private static RyuLogger.Log? GetLog(AvaLogLevel level)
+ {
+ return level switch
+ {
+ AvaLogLevel.Verbose => RyuLogger.Debug,
+ AvaLogLevel.Debug => RyuLogger.Debug,
+ AvaLogLevel.Information => RyuLogger.Debug,
+ AvaLogLevel.Warning => RyuLogger.Debug,
+ AvaLogLevel.Error => RyuLogger.Error,
+ AvaLogLevel.Fatal => RyuLogger.Error,
+ _ => throw new ArgumentOutOfRangeException(nameof(level), level, null)
+ };
+ }
+
+ public bool IsEnabled(AvaLogLevel level, string area)
+ {
+ return GetLog(level) != null;
+ }
+
+ public void Log(AvaLogLevel level, string area, object source, string messageTemplate)
+ {
+ GetLog(level)?.PrintMsg(RyuLogClass.Ui, Format(level, area, messageTemplate, source, null));
+ }
+
+ public void Log<T0>(AvaLogLevel level, string area, object source, string messageTemplate, T0 propertyValue0)
+ {
+ GetLog(level)?.PrintMsg(RyuLogClass.Ui, Format(level, area, messageTemplate, source, new object[] { propertyValue0 }));
+ }
+
+ public void Log<T0, T1>(AvaLogLevel level, string area, object source, string messageTemplate, T0 propertyValue0, T1 propertyValue1)
+ {
+ GetLog(level)?.PrintMsg(RyuLogClass.Ui, Format(level, area, messageTemplate, source, new object[] { propertyValue0, propertyValue1 }));
+ }
+
+ public void Log<T0, T1, T2>(AvaLogLevel level, string area, object source, string messageTemplate, T0 propertyValue0, T1 propertyValue1, T2 propertyValue2)
+ {
+ GetLog(level)?.PrintMsg(RyuLogClass.Ui, Format(level, area, messageTemplate, source, new object[] { propertyValue0, propertyValue1, propertyValue2 }));
+ }
+
+ public void Log(AvaLogLevel level, string area, object source, string messageTemplate, params object[] propertyValues)
+ {
+ GetLog(level)?.PrintMsg(RyuLogClass.Ui, Format(level, area, messageTemplate, source, propertyValues));
+ }
+
+ private static string Format(AvaLogLevel level, string area, string template, object source, object[] v)
+ {
+ var result = new StringBuilder();
+ var r = new CharacterReader(template.AsSpan());
+ int i = 0;
+
+ result.Append('[');
+ result.Append(level);
+ result.Append("] ");
+
+ result.Append('[');
+ result.Append(area);
+ result.Append("] ");
+
+ while (!r.End)
+ {
+ var c = r.Take();
+
+ if (c != '{')
+ {
+ result.Append(c);
+ }
+ else
+ {
+ if (r.Peek != '{')
+ {
+ result.Append('\'');
+ result.Append(i < v.Length ? v[i++] : null);
+ result.Append('\'');
+ r.TakeUntil('}');
+ r.Take();
+ }
+ else
+ {
+ result.Append('{');
+ r.Take();
+ }
+ }
+ }
+
+ if (source != null)
+ {
+ result.Append(" (");
+ result.Append(source.GetType().Name);
+ result.Append(" #");
+ result.Append(source.GetHashCode());
+ result.Append(')');
+ }
+
+ return result.ToString();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Helpers/MiniCommand.cs b/src/Ryujinx.Ava/UI/Helpers/MiniCommand.cs
new file mode 100644
index 00000000..305182c9
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Helpers/MiniCommand.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Threading.Tasks;
+using System.Windows.Input;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+ public sealed class MiniCommand<T> : MiniCommand, ICommand
+ {
+ private readonly Action<T> _callback;
+ private bool _busy;
+ private Func<T, Task> _asyncCallback;
+
+ public MiniCommand(Action<T> callback)
+ {
+ _callback = callback;
+ }
+
+ public MiniCommand(Func<T, Task> callback)
+ {
+ _asyncCallback = callback;
+ }
+
+ private bool Busy
+ {
+ get => _busy;
+ set
+ {
+ _busy = value;
+ CanExecuteChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ public override event EventHandler CanExecuteChanged;
+ public override bool CanExecute(object parameter) => !_busy;
+
+ public override async void Execute(object parameter)
+ {
+ if (Busy)
+ {
+ return;
+ }
+ try
+ {
+ Busy = true;
+ if (_callback != null)
+ {
+ _callback((T)parameter);
+ }
+ else
+ {
+ await _asyncCallback((T)parameter);
+ }
+ }
+ finally
+ {
+ Busy = false;
+ }
+ }
+ }
+
+ public abstract class MiniCommand : ICommand
+ {
+ public static MiniCommand Create(Action callback) => new MiniCommand<object>(_ => callback());
+ public static MiniCommand Create<TArg>(Action<TArg> callback) => new MiniCommand<TArg>(callback);
+ public static MiniCommand CreateFromTask(Func<Task> callback) => new MiniCommand<object>(_ => callback());
+
+ public abstract bool CanExecute(object parameter);
+ public abstract void Execute(object parameter);
+ public abstract event EventHandler CanExecuteChanged;
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Helpers/NotificationHelper.cs b/src/Ryujinx.Ava/UI/Helpers/NotificationHelper.cs
new file mode 100644
index 00000000..7e2afb8b
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Helpers/NotificationHelper.cs
@@ -0,0 +1,65 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Notifications;
+using Avalonia.Threading;
+using Ryujinx.Ava.Common.Locale;
+using System;
+using System.Collections.Concurrent;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+ public static class NotificationHelper
+ {
+ private const int MaxNotifications = 4;
+ private const int NotificationDelayInMs = 5000;
+
+ private static WindowNotificationManager _notificationManager;
+
+ private static readonly ManualResetEvent _templateAppliedEvent = new(false);
+ private static readonly BlockingCollection<Notification> _notifications = new();
+
+ public static void SetNotificationManager(Window host)
+ {
+ _notificationManager = new WindowNotificationManager(host)
+ {
+ Position = NotificationPosition.BottomRight,
+ MaxItems = MaxNotifications,
+ Margin = new Thickness(0, 0, 15, 40)
+ };
+
+ _notificationManager.TemplateApplied += (sender, args) =>
+ {
+ _templateAppliedEvent.Set();
+ };
+
+ Task.Run(async () =>
+ {
+ _templateAppliedEvent.WaitOne();
+
+ foreach (var notification in _notifications.GetConsumingEnumerable())
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ _notificationManager.Show(notification);
+ });
+
+ await Task.Delay(NotificationDelayInMs / MaxNotifications);
+ }
+ });
+ }
+
+ public static void Show(string title, string text, NotificationType type, bool waitingExit = false, Action onClick = null, Action onClose = null)
+ {
+ var delay = waitingExit ? TimeSpan.FromMilliseconds(0) : TimeSpan.FromMilliseconds(NotificationDelayInMs);
+
+ _notifications.Add(new Notification(title, text, type, delay, onClick, onClose));
+ }
+
+ public static void ShowError(string message)
+ {
+ Show(LocaleManager.Instance[LocaleKeys.DialogErrorTitle], $"{LocaleManager.Instance[LocaleKeys.DialogErrorMessage]}\n\n{message}", NotificationType.Error);
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Helpers/OffscreenTextBox.cs b/src/Ryujinx.Ava/UI/Helpers/OffscreenTextBox.cs
new file mode 100644
index 00000000..785e785c
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Helpers/OffscreenTextBox.cs
@@ -0,0 +1,40 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+ public class OffscreenTextBox : TextBox
+ {
+ public RoutedEvent<KeyEventArgs> GetKeyDownRoutedEvent()
+ {
+ return KeyDownEvent;
+ }
+
+ public RoutedEvent<KeyEventArgs> GetKeyUpRoutedEvent()
+ {
+ return KeyUpEvent;
+ }
+
+ public void SendKeyDownEvent(KeyEventArgs keyEvent)
+ {
+ OnKeyDown(keyEvent);
+ }
+
+ public void SendKeyUpEvent(KeyEventArgs keyEvent)
+ {
+ OnKeyUp(keyEvent);
+ }
+
+ public void SendText(string text)
+ {
+ OnTextInput(new TextInputEventArgs()
+ {
+ Text = text,
+ Device = KeyboardDevice.Instance,
+ Source = this,
+ RoutedEvent = TextInputEvent
+ });
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Helpers/UserErrorDialog.cs b/src/Ryujinx.Ava/UI/Helpers/UserErrorDialog.cs
new file mode 100644
index 00000000..4ed629ff
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Helpers/UserErrorDialog.cs
@@ -0,0 +1,91 @@
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Windows;
+using Ryujinx.Ui.Common;
+using Ryujinx.Ui.Common.Helper;
+using System.Threading.Tasks;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+ internal class UserErrorDialog
+ {
+ private const string SetupGuideUrl = "https://github.com/Ryujinx/Ryujinx/wiki/Ryujinx-Setup-&-Configuration-Guide";
+
+ private static string GetErrorCode(UserError error)
+ {
+ return $"RYU-{(uint)error:X4}";
+ }
+
+ private static string GetErrorTitle(UserError error)
+ {
+ return error switch
+ {
+ UserError.NoKeys => LocaleManager.Instance[LocaleKeys.UserErrorNoKeys],
+ UserError.NoFirmware => LocaleManager.Instance[LocaleKeys.UserErrorNoFirmware],
+ UserError.FirmwareParsingFailed => LocaleManager.Instance[LocaleKeys.UserErrorFirmwareParsingFailed],
+ UserError.ApplicationNotFound => LocaleManager.Instance[LocaleKeys.UserErrorApplicationNotFound],
+ UserError.Unknown => LocaleManager.Instance[LocaleKeys.UserErrorUnknown],
+ _ => LocaleManager.Instance[LocaleKeys.UserErrorUndefined]
+ };
+ }
+
+ private static string GetErrorDescription(UserError error)
+ {
+ return error switch
+ {
+ UserError.NoKeys => LocaleManager.Instance[LocaleKeys.UserErrorNoKeysDescription],
+ UserError.NoFirmware => LocaleManager.Instance[LocaleKeys.UserErrorNoFirmwareDescription],
+ UserError.FirmwareParsingFailed => LocaleManager.Instance[LocaleKeys.UserErrorFirmwareParsingFailedDescription],
+ UserError.ApplicationNotFound => LocaleManager.Instance[LocaleKeys.UserErrorApplicationNotFoundDescription],
+ UserError.Unknown => LocaleManager.Instance[LocaleKeys.UserErrorUnknownDescription],
+ _ => LocaleManager.Instance[LocaleKeys.UserErrorUndefinedDescription]
+ };
+ }
+
+ private static bool IsCoveredBySetupGuide(UserError error)
+ {
+ return error switch
+ {
+ UserError.NoKeys or
+ UserError.NoFirmware or
+ UserError.FirmwareParsingFailed => true,
+ _ => false
+ };
+ }
+
+ private static string GetSetupGuideUrl(UserError error)
+ {
+ if (!IsCoveredBySetupGuide(error))
+ {
+ return null;
+ }
+
+ return error switch
+ {
+ UserError.NoKeys => SetupGuideUrl + "#initial-setup---placement-of-prodkeys",
+ UserError.NoFirmware => SetupGuideUrl + "#initial-setup-continued---installation-of-firmware",
+ _ => SetupGuideUrl
+ };
+ }
+
+ public static async Task ShowUserErrorDialog(UserError error, StyleableWindow owner)
+ {
+ string errorCode = GetErrorCode(error);
+
+ bool isInSetupGuide = IsCoveredBySetupGuide(error);
+
+ string setupButtonLabel = isInSetupGuide ? LocaleManager.Instance[LocaleKeys.OpenSetupGuideMessage] : "";
+
+ var result = await ContentDialogHelper.CreateInfoDialog(
+ LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogUserErrorDialogMessage, errorCode, GetErrorTitle(error)),
+ GetErrorDescription(error) + (isInSetupGuide
+ ? LocaleManager.Instance[LocaleKeys.DialogUserErrorDialogInfoMessage]
+ : ""), setupButtonLabel, LocaleManager.Instance[LocaleKeys.InputDialogOk],
+ LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogUserErrorDialogTitle, errorCode));
+
+ if (result == UserResult.Ok)
+ {
+ OpenHelper.OpenUrl(GetSetupGuideUrl(error));
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Helpers/UserResult.cs b/src/Ryujinx.Ava/UI/Helpers/UserResult.cs
new file mode 100644
index 00000000..57802804
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Helpers/UserResult.cs
@@ -0,0 +1,12 @@
+namespace Ryujinx.Ava.UI.Helpers
+{
+ public enum UserResult
+ {
+ Ok,
+ Yes,
+ No,
+ Abort,
+ Cancel,
+ None,
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Helpers/Win32NativeInterop.cs b/src/Ryujinx.Ava/UI/Helpers/Win32NativeInterop.cs
new file mode 100644
index 00000000..03d3a49f
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Helpers/Win32NativeInterop.cs
@@ -0,0 +1,123 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+
+namespace Ryujinx.Ava.UI.Helpers
+{
+ [SupportedOSPlatform("windows")]
+ internal partial class Win32NativeInterop
+ {
+ [Flags]
+ public enum ClassStyles : uint
+ {
+ CS_CLASSDC = 0x40,
+ CS_OWNDC = 0x20,
+ }
+
+ [Flags]
+ public enum WindowStyles : uint
+ {
+ WS_CHILD = 0x40000000
+ }
+
+ public enum Cursors : uint
+ {
+ IDC_ARROW = 32512
+ }
+
+ public enum WindowsMessages : uint
+ {
+ MOUSEMOVE = 0x0200,
+ LBUTTONDOWN = 0x0201,
+ LBUTTONUP = 0x0202,
+ LBUTTONDBLCLK = 0x0203,
+ RBUTTONDOWN = 0x0204,
+ RBUTTONUP = 0x0205,
+ RBUTTONDBLCLK = 0x0206,
+ MBUTTONDOWN = 0x0207,
+ MBUTTONUP = 0x0208,
+ MBUTTONDBLCLK = 0x0209,
+ MOUSEWHEEL = 0x020A,
+ XBUTTONDOWN = 0x020B,
+ XBUTTONUP = 0x020C,
+ XBUTTONDBLCLK = 0x020D,
+ MOUSEHWHEEL = 0x020E,
+ MOUSELAST = 0x020E
+ }
+
+ [UnmanagedFunctionPointer(CallingConvention.Winapi)]
+ internal delegate IntPtr WindowProc(IntPtr hWnd, WindowsMessages msg, IntPtr wParam, IntPtr lParam);
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct WNDCLASSEX
+ {
+ public int cbSize;
+ public ClassStyles style;
+ public IntPtr lpfnWndProc; // not WndProc
+ public int cbClsExtra;
+ public int cbWndExtra;
+ public IntPtr hInstance;
+ public IntPtr hIcon;
+ public IntPtr hCursor;
+ public IntPtr hbrBackground;
+ public IntPtr lpszMenuName;
+ public IntPtr lpszClassName;
+ public IntPtr hIconSm;
+
+ public WNDCLASSEX()
+ {
+ cbSize = Marshal.SizeOf<WNDCLASSEX>();
+ }
+ }
+
+ public static IntPtr CreateEmptyCursor()
+ {
+ return CreateCursor(IntPtr.Zero, 0, 0, 1, 1, new byte[] { 0xFF }, new byte[] { 0x00 });
+ }
+
+ public static IntPtr CreateArrowCursor()
+ {
+ return LoadCursor(IntPtr.Zero, (IntPtr)Cursors.IDC_ARROW);
+ }
+
+ [LibraryImport("user32.dll")]
+ public static partial IntPtr SetCursor(IntPtr handle);
+
+ [LibraryImport("user32.dll")]
+ public static partial IntPtr CreateCursor(IntPtr hInst, int xHotSpot, int yHotSpot, int nWidth, int nHeight, byte[] pvANDPlane, byte[] pvXORPlane);
+
+ [LibraryImport("user32.dll", SetLastError = true, EntryPoint = "RegisterClassExW")]
+ public static partial ushort RegisterClassEx(ref WNDCLASSEX param);
+
+ [LibraryImport("user32.dll", SetLastError = true, EntryPoint = "UnregisterClassW")]
+ public static partial short UnregisterClass([MarshalAs(UnmanagedType.LPWStr)] string lpClassName, IntPtr instance);
+
+ [LibraryImport("user32.dll", EntryPoint = "DefWindowProcW")]
+ public static partial IntPtr DefWindowProc(IntPtr hWnd, WindowsMessages msg, IntPtr wParam, IntPtr lParam);
+
+ [LibraryImport("kernel32.dll", EntryPoint = "GetModuleHandleA")]
+ public static partial IntPtr GetModuleHandle([MarshalAs(UnmanagedType.LPStr)] string lpModuleName);
+
+ [LibraryImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static partial bool DestroyWindow(IntPtr hwnd);
+
+ [LibraryImport("user32.dll", SetLastError = true, EntryPoint = "LoadCursorA")]
+ public static partial IntPtr LoadCursor(IntPtr hInstance, IntPtr lpCursorName);
+
+ [LibraryImport("user32.dll", SetLastError = true, EntryPoint = "CreateWindowExW")]
+ public static partial IntPtr CreateWindowEx(
+ uint dwExStyle,
+ [MarshalAs(UnmanagedType.LPWStr)] string lpClassName,
+ [MarshalAs(UnmanagedType.LPWStr)] string lpWindowName,
+ WindowStyles dwStyle,
+ int x,
+ int y,
+ int nWidth,
+ int nHeight,
+ IntPtr hWndParent,
+ IntPtr hMenu,
+ IntPtr hInstance,
+ IntPtr lpParam);
+ }
+}