aboutsummaryrefslogtreecommitdiff
path: root/src/Ryujinx.Ava/UI
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
parentcd124bda587ef09668a971fa1cac1c3f0cfc9f21 (diff)
Move solution and projects to src
Diffstat (limited to 'src/Ryujinx.Ava/UI')
-rw-r--r--src/Ryujinx.Ava/UI/Applet/AvaHostUiHandler.cs197
-rw-r--r--src/Ryujinx.Ava/UI/Applet/AvaloniaDynamicTextInputHandler.cs164
-rw-r--r--src/Ryujinx.Ava/UI/Applet/AvaloniaHostUiTheme.cs43
-rw-r--r--src/Ryujinx.Ava/UI/Applet/ErrorAppletWindow.axaml52
-rw-r--r--src/Ryujinx.Ava/UI/Applet/ErrorAppletWindow.axaml.cs80
-rw-r--r--src/Ryujinx.Ava/UI/Applet/SwkbdAppletDialog.axaml65
-rw-r--r--src/Ryujinx.Ava/UI/Applet/SwkbdAppletDialog.axaml.cs148
-rw-r--r--src/Ryujinx.Ava/UI/Controls/GameGridView.axaml177
-rw-r--r--src/Ryujinx.Ava/UI/Controls/GameGridView.axaml.cs57
-rw-r--r--src/Ryujinx.Ava/UI/Controls/GameListView.axaml233
-rw-r--r--src/Ryujinx.Ava/UI/Controls/GameListView.axaml.cs57
-rw-r--r--src/Ryujinx.Ava/UI/Controls/NavigationDialogHost.axaml17
-rw-r--r--src/Ryujinx.Ava/UI/Controls/NavigationDialogHost.axaml.cs218
-rw-r--r--src/Ryujinx.Ava/UI/Controls/UpdateWaitWindow.axaml42
-rw-r--r--src/Ryujinx.Ava/UI/Controls/UpdateWaitWindow.axaml.cs31
-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
-rw-r--r--src/Ryujinx.Ava/UI/Models/CheatModel.cs40
-rw-r--r--src/Ryujinx.Ava/UI/Models/CheatsList.cs51
-rw-r--r--src/Ryujinx.Ava/UI/Models/ControllerModel.cs6
-rw-r--r--src/Ryujinx.Ava/UI/Models/DeviceType.cs9
-rw-r--r--src/Ryujinx.Ava/UI/Models/DownloadableContentModel.cs35
-rw-r--r--src/Ryujinx.Ava/UI/Models/Generic/LastPlayedSortComparer.cs33
-rw-r--r--src/Ryujinx.Ava/UI/Models/InputConfiguration.cs456
-rw-r--r--src/Ryujinx.Ava/UI/Models/PlayerModel.cs6
-rw-r--r--src/Ryujinx.Ava/UI/Models/ProfileImageModel.cs32
-rw-r--r--src/Ryujinx.Ava/UI/Models/SaveModel.cs112
-rw-r--r--src/Ryujinx.Ava/UI/Models/StatusUpdatedEventArgs.cs28
-rw-r--r--src/Ryujinx.Ava/UI/Models/TempProfile.cs61
-rw-r--r--src/Ryujinx.Ava/UI/Models/TimeZone.cs16
-rw-r--r--src/Ryujinx.Ava/UI/Models/TitleUpdateModel.cs19
-rw-r--r--src/Ryujinx.Ava/UI/Models/UserProfile.cs103
-rw-r--r--src/Ryujinx.Ava/UI/Renderer/EmbeddedWindow.cs288
-rw-r--r--src/Ryujinx.Ava/UI/Renderer/EmbeddedWindowOpenGL.cs87
-rw-r--r--src/Ryujinx.Ava/UI/Renderer/EmbeddedWindowVulkan.cs42
-rw-r--r--src/Ryujinx.Ava/UI/Renderer/OpenTKBindingsContext.cs20
-rw-r--r--src/Ryujinx.Ava/UI/Renderer/RendererHost.axaml11
-rw-r--r--src/Ryujinx.Ava/UI/Renderer/RendererHost.axaml.cs68
-rw-r--r--src/Ryujinx.Ava/UI/Renderer/SPBOpenGLContext.cs47
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/AboutWindowViewModel.cs133
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/AmiiboWindowViewModel.cs467
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/AvatarProfileViewModel.cs363
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/BaseModel.cs15
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/ControllerSettingsViewModel.cs899
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/DownloadableContentManagerViewModel.cs338
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs1907
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/SettingsViewModel.cs585
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/TitleUpdateViewModel.cs252
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/UserFirmwareAvatarSelectorViewModel.cs230
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/UserProfileImageSelectorViewModel.cs18
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/UserProfileViewModel.cs25
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/UserSaveManagerViewModel.cs120
-rw-r--r--src/Ryujinx.Ava/UI/Views/Main/MainMenuBarView.axaml164
-rw-r--r--src/Ryujinx.Ava/UI/Views/Main/MainMenuBarView.axaml.cs236
-rw-r--r--src/Ryujinx.Ava/UI/Views/Main/MainStatusBarView.axaml232
-rw-r--r--src/Ryujinx.Ava/UI/Views/Main/MainStatusBarView.axaml.cs52
-rw-r--r--src/Ryujinx.Ava/UI/Views/Main/MainViewControls.axaml175
-rw-r--r--src/Ryujinx.Ava/UI/Views/Main/MainViewControls.axaml.cs54
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsAudioView.axaml81
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsAudioView.axaml.cs12
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsCPUView.axaml78
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsCPUView.axaml.cs12
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsGraphicsView.axaml296
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsGraphicsView.axaml.cs12
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsHotkeysView.axaml104
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsHotkeysView.axaml.cs81
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsInputView.axaml46
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsInputView.axaml.cs17
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsLoggingView.axaml121
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsLoggingView.axaml.cs12
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsNetworkView.axaml46
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsNetworkView.axaml.cs12
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsSystemView.axaml195
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsSystemView.axaml.cs52
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsUIView.axaml156
-rw-r--r--src/Ryujinx.Ava/UI/Views/Settings/SettingsUIView.axaml.cs82
-rw-r--r--src/Ryujinx.Ava/UI/Views/User/UserEditorView.axaml123
-rw-r--r--src/Ryujinx.Ava/UI/Views/User/UserEditorView.axaml.cs165
-rw-r--r--src/Ryujinx.Ava/UI/Views/User/UserFirmwareAvatarSelectorView.axaml114
-rw-r--r--src/Ryujinx.Ava/UI/Views/User/UserFirmwareAvatarSelectorView.axaml.cs88
-rw-r--r--src/Ryujinx.Ava/UI/Views/User/UserProfileImageSelectorView.axaml63
-rw-r--r--src/Ryujinx.Ava/UI/Views/User/UserProfileImageSelectorView.axaml.cs124
-rw-r--r--src/Ryujinx.Ava/UI/Views/User/UserRecovererView.axaml83
-rw-r--r--src/Ryujinx.Ava/UI/Views/User/UserRecovererView.axaml.cs51
-rw-r--r--src/Ryujinx.Ava/UI/Views/User/UserSaveManagerView.axaml215
-rw-r--r--src/Ryujinx.Ava/UI/Views/User/UserSaveManagerView.axaml.cs147
-rw-r--r--src/Ryujinx.Ava/UI/Views/User/UserSelectorView.axaml165
-rw-r--r--src/Ryujinx.Ava/UI/Views/User/UserSelectorView.axaml.cs128
-rw-r--r--src/Ryujinx.Ava/UI/Windows/AboutWindow.axaml247
-rw-r--r--src/Ryujinx.Ava/UI/Windows/AboutWindow.axaml.cs62
-rw-r--r--src/Ryujinx.Ava/UI/Windows/AmiiboWindow.axaml74
-rw-r--r--src/Ryujinx.Ava/UI/Windows/AmiiboWindow.axaml.cs59
-rw-r--r--src/Ryujinx.Ava/UI/Windows/CheatWindow.axaml106
-rw-r--r--src/Ryujinx.Ava/UI/Windows/CheatWindow.axaml.cs119
-rw-r--r--src/Ryujinx.Ava/UI/Windows/ContentDialogOverlayWindow.axaml25
-rw-r--r--src/Ryujinx.Ava/UI/Windows/ContentDialogOverlayWindow.axaml.cs25
-rw-r--r--src/Ryujinx.Ava/UI/Windows/ControllerSettingsWindow.axaml1169
-rw-r--r--src/Ryujinx.Ava/UI/Windows/ControllerSettingsWindow.axaml.cs181
-rw-r--r--src/Ryujinx.Ava/UI/Windows/DownloadableContentManagerWindow.axaml194
-rw-r--r--src/Ryujinx.Ava/UI/Windows/DownloadableContentManagerWindow.axaml.cs115
-rw-r--r--src/Ryujinx.Ava/UI/Windows/IconColorPicker.cs192
-rw-r--r--src/Ryujinx.Ava/UI/Windows/MainWindow.axaml206
-rw-r--r--src/Ryujinx.Ava/UI/Windows/MainWindow.axaml.cs441
-rw-r--r--src/Ryujinx.Ava/UI/Windows/MotionSettingsWindow.axaml141
-rw-r--r--src/Ryujinx.Ava/UI/Windows/MotionSettingsWindow.axaml.cs71
-rw-r--r--src/Ryujinx.Ava/UI/Windows/RumbleSettingsWindow.axaml57
-rw-r--r--src/Ryujinx.Ava/UI/Windows/RumbleSettingsWindow.axaml.cs57
-rw-r--r--src/Ryujinx.Ava/UI/Windows/SettingsWindow.axaml128
-rw-r--r--src/Ryujinx.Ava/UI/Windows/SettingsWindow.axaml.cs103
-rw-r--r--src/Ryujinx.Ava/UI/Windows/StyleableWindow.cs39
-rw-r--r--src/Ryujinx.Ava/UI/Windows/TitleUpdateWindow.axaml135
-rw-r--r--src/Ryujinx.Ava/UI/Windows/TitleUpdateWindow.axaml.cs96
125 files changed, 17588 insertions, 0 deletions
diff --git a/src/Ryujinx.Ava/UI/Applet/AvaHostUiHandler.cs b/src/Ryujinx.Ava/UI/Applet/AvaHostUiHandler.cs
new file mode 100644
index 00000000..c87308cf
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Applet/AvaHostUiHandler.cs
@@ -0,0 +1,197 @@
+using Avalonia.Controls;
+using Avalonia.Threading;
+using FluentAvalonia.UI.Controls;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Controls;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Windows;
+using Ryujinx.HLE;
+using Ryujinx.HLE.HOS.Applets;
+using Ryujinx.HLE.HOS.Services.Am.AppletOE.ApplicationProxyService.ApplicationProxy.Types;
+using Ryujinx.HLE.Ui;
+using System;
+using System.Threading;
+
+namespace Ryujinx.Ava.UI.Applet
+{
+ internal class AvaHostUiHandler : IHostUiHandler
+ {
+ private readonly MainWindow _parent;
+
+ public IHostUiTheme HostUiTheme { get; }
+
+ public AvaHostUiHandler(MainWindow parent)
+ {
+ _parent = parent;
+
+ HostUiTheme = new AvaloniaHostUiTheme(parent);
+ }
+
+ public bool DisplayMessageDialog(ControllerAppletUiArgs args)
+ {
+ string message = LocaleManager.Instance.UpdateAndGetDynamicValue(
+ args.PlayerCountMin == args.PlayerCountMax ? LocaleKeys.DialogControllerAppletMessage : LocaleKeys.DialogControllerAppletMessagePlayerRange,
+ args.PlayerCountMin == args.PlayerCountMax ? args.PlayerCountMin.ToString() : $"{args.PlayerCountMin}-{args.PlayerCountMax}",
+ args.SupportedStyles,
+ string.Join(", ", args.SupportedPlayers),
+ args.IsDocked ? LocaleManager.Instance[LocaleKeys.DialogControllerAppletDockModeSet] : "");
+
+ return DisplayMessageDialog(LocaleManager.Instance[LocaleKeys.DialogControllerAppletTitle], message);
+ }
+
+ public bool DisplayMessageDialog(string title, string message)
+ {
+ ManualResetEvent dialogCloseEvent = new(false);
+
+ bool okPressed = false;
+
+ Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ try
+ {
+ ManualResetEvent deferEvent = new(false);
+
+ bool opened = false;
+
+ _parent.Activate();
+
+ UserResult response = await ContentDialogHelper.ShowDeferredContentDialog(_parent,
+ title,
+ message,
+ "",
+ LocaleManager.Instance[LocaleKeys.DialogOpenSettingsWindowLabel],
+ "",
+ LocaleManager.Instance[LocaleKeys.SettingsButtonClose],
+ (int)Symbol.Important,
+ deferEvent,
+ async (window) =>
+ {
+ if (opened)
+ {
+ return;
+ }
+
+ opened = true;
+
+ _parent.SettingsWindow = new SettingsWindow(_parent.VirtualFileSystem, _parent.ContentManager);
+
+ await _parent.SettingsWindow.ShowDialog(window);
+
+ opened = false;
+ });
+
+ if (response == UserResult.Ok)
+ {
+ okPressed = true;
+ }
+
+ dialogCloseEvent.Set();
+ }
+ catch (Exception ex)
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogMessageDialogErrorExceptionMessage, ex));
+
+ dialogCloseEvent.Set();
+ }
+ });
+
+ dialogCloseEvent.WaitOne();
+
+ return okPressed;
+ }
+
+ public bool DisplayInputDialog(SoftwareKeyboardUiArgs args, out string userText)
+ {
+ ManualResetEvent dialogCloseEvent = new(false);
+
+ bool okPressed = false;
+ bool error = false;
+ string inputText = args.InitialText ?? "";
+
+ Dispatcher.UIThread.Post(async () =>
+ {
+ try
+ {
+ var response = await SwkbdAppletDialog.ShowInputDialog(_parent, LocaleManager.Instance[LocaleKeys.SoftwareKeyboard], args);
+
+ if (response.Result == UserResult.Ok)
+ {
+ inputText = response.Input;
+ okPressed = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ error = true;
+
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogSoftwareKeyboardErrorExceptionMessage, ex));
+ }
+ finally
+ {
+ dialogCloseEvent.Set();
+ }
+ });
+
+ dialogCloseEvent.WaitOne();
+
+ userText = error ? null : inputText;
+
+ return error || okPressed;
+ }
+
+ public void ExecuteProgram(Switch device, ProgramSpecifyKind kind, ulong value)
+ {
+ device.Configuration.UserChannelPersistence.ExecuteProgram(kind, value);
+ if (_parent.ViewModel.AppHost != null)
+ {
+ _parent.ViewModel.AppHost.Stop();
+ }
+ }
+
+ public bool DisplayErrorAppletDialog(string title, string message, string[] buttons)
+ {
+ ManualResetEvent dialogCloseEvent = new(false);
+
+ bool showDetails = false;
+
+ Dispatcher.UIThread.Post(async () =>
+ {
+ try
+ {
+ ErrorAppletWindow msgDialog = new(_parent, buttons, message)
+ {
+ Title = title,
+ WindowStartupLocation = WindowStartupLocation.CenterScreen,
+ Width = 400
+ };
+
+ object response = await msgDialog.Run();
+
+ if (response != null && buttons != null && buttons.Length > 1 && (int)response != buttons.Length - 1)
+ {
+ showDetails = true;
+ }
+
+ dialogCloseEvent.Set();
+
+ msgDialog.Close();
+ }
+ catch (Exception ex)
+ {
+ dialogCloseEvent.Set();
+
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogErrorAppletErrorExceptionMessage, ex));
+ }
+ });
+
+ dialogCloseEvent.WaitOne();
+
+ return showDetails;
+ }
+
+ public IDynamicTextInputHandler CreateDynamicTextInputHandler()
+ {
+ return new AvaloniaDynamicTextInputHandler(_parent);
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Applet/AvaloniaDynamicTextInputHandler.cs b/src/Ryujinx.Ava/UI/Applet/AvaloniaDynamicTextInputHandler.cs
new file mode 100644
index 00000000..2dd65e36
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Applet/AvaloniaDynamicTextInputHandler.cs
@@ -0,0 +1,164 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Threading;
+using Ryujinx.Ava.Input;
+using Ryujinx.Ava.UI.Controls;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Windows;
+using Ryujinx.HLE.Ui;
+using System;
+using System.Threading;
+
+using HidKey = Ryujinx.Common.Configuration.Hid.Key;
+
+namespace Ryujinx.Ava.UI.Applet
+{
+ class AvaloniaDynamicTextInputHandler : IDynamicTextInputHandler
+ {
+ private MainWindow _parent;
+ private OffscreenTextBox _hiddenTextBox;
+ private bool _canProcessInput;
+ private IDisposable _textChangedSubscription;
+ private IDisposable _selectionStartChangedSubscription;
+ private IDisposable _selectionEndtextChangedSubscription;
+
+ public AvaloniaDynamicTextInputHandler(MainWindow parent)
+ {
+ _parent = parent;
+
+ (_parent.InputManager.KeyboardDriver as AvaloniaKeyboardDriver).KeyPressed += AvaloniaDynamicTextInputHandler_KeyPressed;
+ (_parent.InputManager.KeyboardDriver as AvaloniaKeyboardDriver).KeyRelease += AvaloniaDynamicTextInputHandler_KeyRelease;
+ (_parent.InputManager.KeyboardDriver as AvaloniaKeyboardDriver).TextInput += AvaloniaDynamicTextInputHandler_TextInput;
+
+ _hiddenTextBox = _parent.HiddenTextBox;
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ _textChangedSubscription = _hiddenTextBox.GetObservable(TextBox.TextProperty).Subscribe(TextChanged);
+ _selectionStartChangedSubscription = _hiddenTextBox.GetObservable(TextBox.SelectionStartProperty).Subscribe(SelectionChanged);
+ _selectionEndtextChangedSubscription = _hiddenTextBox.GetObservable(TextBox.SelectionEndProperty).Subscribe(SelectionChanged);
+ });
+ }
+
+ private void TextChanged(string text)
+ {
+ TextChangedEvent?.Invoke(text ?? string.Empty, _hiddenTextBox.SelectionStart, _hiddenTextBox.SelectionEnd, true);
+ }
+
+ private void SelectionChanged(int selection)
+ {
+ if (_hiddenTextBox.SelectionEnd < _hiddenTextBox.SelectionStart)
+ {
+ _hiddenTextBox.SelectionStart = _hiddenTextBox.SelectionEnd;
+ }
+
+ TextChangedEvent?.Invoke(_hiddenTextBox.Text ?? string.Empty, _hiddenTextBox.SelectionStart, _hiddenTextBox.SelectionEnd, true);
+ }
+
+ private void AvaloniaDynamicTextInputHandler_TextInput(object sender, string text)
+ {
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ if (_canProcessInput)
+ {
+ _hiddenTextBox.SendText(text);
+ }
+ });
+ }
+
+ private void AvaloniaDynamicTextInputHandler_KeyRelease(object sender, KeyEventArgs e)
+ {
+ var key = (HidKey)AvaloniaKeyboardMappingHelper.ToInputKey(e.Key);
+
+ if (!(KeyReleasedEvent?.Invoke(key)).GetValueOrDefault(true))
+ {
+ return;
+ }
+
+ e.RoutedEvent = _hiddenTextBox.GetKeyUpRoutedEvent();
+
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ if (_canProcessInput)
+ {
+ _hiddenTextBox.SendKeyUpEvent(e);
+ }
+ });
+ }
+
+ private void AvaloniaDynamicTextInputHandler_KeyPressed(object sender, KeyEventArgs e)
+ {
+ var key = (HidKey)AvaloniaKeyboardMappingHelper.ToInputKey(e.Key);
+
+ if (!(KeyPressedEvent?.Invoke(key)).GetValueOrDefault(true))
+ {
+ return;
+ }
+
+ e.RoutedEvent = _hiddenTextBox.GetKeyUpRoutedEvent();
+
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ if (_canProcessInput)
+ {
+ _hiddenTextBox.SendKeyDownEvent(e);
+ }
+ });
+ }
+
+ public bool TextProcessingEnabled
+ {
+ get
+ {
+ return Volatile.Read(ref _canProcessInput);
+ }
+ set
+ {
+ Volatile.Write(ref _canProcessInput, value);
+ }
+ }
+
+ public event DynamicTextChangedHandler TextChangedEvent;
+ public event KeyPressedHandler KeyPressedEvent;
+ public event KeyReleasedHandler KeyReleasedEvent;
+
+ public void Dispose()
+ {
+ (_parent.InputManager.KeyboardDriver as AvaloniaKeyboardDriver).KeyPressed -= AvaloniaDynamicTextInputHandler_KeyPressed;
+ (_parent.InputManager.KeyboardDriver as AvaloniaKeyboardDriver).KeyRelease -= AvaloniaDynamicTextInputHandler_KeyRelease;
+ (_parent.InputManager.KeyboardDriver as AvaloniaKeyboardDriver).TextInput -= AvaloniaDynamicTextInputHandler_TextInput;
+
+ _textChangedSubscription?.Dispose();
+ _selectionStartChangedSubscription?.Dispose();
+ _selectionEndtextChangedSubscription?.Dispose();
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ _hiddenTextBox.Clear();
+ _parent.ViewModel.RendererHostControl.Focus();
+
+ _parent = null;
+ });
+ }
+
+ public void SetText(string text, int cursorBegin)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ _hiddenTextBox.Text = text;
+ _hiddenTextBox.CaretIndex = cursorBegin;
+ });
+ }
+
+ public void SetText(string text, int cursorBegin, int cursorEnd)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ _hiddenTextBox.Text = text;
+ _hiddenTextBox.SelectionStart = cursorBegin;
+ _hiddenTextBox.SelectionEnd = cursorEnd;
+ });
+ }
+ }
+}
diff --git a/src/Ryujinx.Ava/UI/Applet/AvaloniaHostUiTheme.cs b/src/Ryujinx.Ava/UI/Applet/AvaloniaHostUiTheme.cs
new file mode 100644
index 00000000..77c7a2d2
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Applet/AvaloniaHostUiTheme.cs
@@ -0,0 +1,43 @@
+using Avalonia.Media;
+using Ryujinx.Ava.UI.Windows;
+using Ryujinx.HLE.Ui;
+using System;
+
+namespace Ryujinx.Ava.UI.Applet
+{
+ class AvaloniaHostUiTheme : IHostUiTheme
+ {
+ public AvaloniaHostUiTheme(MainWindow parent)
+ {
+ FontFamily = OperatingSystem.IsWindows() && OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000, 0) ? "Segoe UI Variable" : parent.FontFamily.Name;
+ DefaultBackgroundColor = BrushToThemeColor(parent.Background);
+ DefaultForegroundColor = BrushToThemeColor(parent.Foreground);
+ DefaultBorderColor = BrushToThemeColor(parent.BorderBrush);
+ SelectionBackgroundColor = BrushToThemeColor(parent.ViewControls.SearchBox.SelectionBrush);
+ SelectionForegroundColor = BrushToThemeColor(parent.ViewControls.SearchBox.SelectionForegroundBrush);
+ }
+
+ public string FontFamily { get; }
+
+ public ThemeColor DefaultBackgroundColor { get; }
+ public ThemeColor DefaultForegroundColor { get; }
+ public ThemeColor DefaultBorderColor { get; }
+ public ThemeColor SelectionBackgroundColor { get; }
+ public ThemeColor SelectionForegroundColor { get; }
+
+ private ThemeColor BrushToThemeColor(IBrush brush)
+ {
+ if (brush is SolidColorBrush solidColor)
+ {
+ return new ThemeColor((float)solidColor.Color.A / 255,
+ (float)solidColor.Color.R / 255,
+ (float)solidColor.Color.G / 255,
+ (float)solidColor.Color.B / 255);
+ }
+ else
+ {
+ return new ThemeColor();
+ }
+ }
+ }
+}
diff --git a/src/Ryujinx.Ava/UI/Applet/ErrorAppletWindow.axaml b/src/Ryujinx.Ava/UI/Applet/ErrorAppletWindow.axaml
new file mode 100644
index 00000000..211b4725
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Applet/ErrorAppletWindow.axaml
@@ -0,0 +1,52 @@
+<Window
+ x:Class="Ryujinx.Ava.UI.Applet.ErrorAppletWindow"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ Title="{locale:Locale ErrorWindowTitle}"
+ Width="450"
+ Height="340"
+ CanResize="False"
+ SizeToContent="Height"
+ mc:Ignorable="d"
+ Focusable="True">
+ <Grid
+ Margin="20"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="*" />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition />
+ </Grid.ColumnDefinitions>
+ <Image
+ Grid.Row="1"
+ Grid.RowSpan="2"
+ Grid.Column="0"
+ Height="80"
+ MinWidth="50"
+ Margin="5,10,20,10"
+ Source="resm:Ryujinx.Ui.Common.Resources.Logo_Ryujinx.png?assembly=Ryujinx.Ui.Common" />
+ <TextBlock
+ Grid.Row="1"
+ Grid.Column="1"
+ Margin="10"
+ VerticalAlignment="Stretch"
+ Text="{Binding Message}"
+ TextWrapping="Wrap" />
+ <StackPanel
+ Name="ButtonStack"
+ Grid.Row="2"
+ Grid.Column="1"
+ Margin="10"
+ HorizontalAlignment="Right"
+ Orientation="Horizontal"
+ Spacing="10" />
+ </Grid>
+</Window> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Applet/ErrorAppletWindow.axaml.cs b/src/Ryujinx.Ava/UI/Applet/ErrorAppletWindow.axaml.cs
new file mode 100644
index 00000000..4134797b
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Applet/ErrorAppletWindow.axaml.cs
@@ -0,0 +1,80 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Windows;
+using System.Threading.Tasks;
+
+namespace Ryujinx.Ava.UI.Applet
+{
+ internal partial class ErrorAppletWindow : StyleableWindow
+ {
+ private readonly Window _owner;
+ private object _buttonResponse;
+
+ public ErrorAppletWindow(Window owner, string[] buttons, string message)
+ {
+ _owner = owner;
+ Message = message;
+ DataContext = this;
+ InitializeComponent();
+#if DEBUG
+ this.AttachDevTools();
+#endif
+ int responseId = 0;
+
+ if (buttons != null)
+ {
+ foreach (string buttonText in buttons)
+ {
+ AddButton(buttonText, responseId);
+ responseId++;
+ }
+ }
+ else
+ {
+ AddButton(LocaleManager.Instance[LocaleKeys.InputDialogOk], 0);
+ }
+ }
+
+ public ErrorAppletWindow()
+ {
+ DataContext = this;
+ InitializeComponent();
+#if DEBUG
+ this.AttachDevTools();
+#endif
+ }
+
+ public string Message { get; set; }
+
+ private void AddButton(string label, object tag)
+ {
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ Button button = new() { Content = label, Tag = tag };
+
+ button.Click += Button_Click;
+ ButtonStack.Children.Add(button);
+ });
+ }
+
+ private void Button_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button button)
+ {
+ _buttonResponse = button.Tag;
+ }
+
+ Close();
+ }
+
+ public async Task<object> Run()
+ {
+ await ShowDialog(_owner);
+
+ return _buttonResponse;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Applet/SwkbdAppletDialog.axaml b/src/Ryujinx.Ava/UI/Applet/SwkbdAppletDialog.axaml
new file mode 100644
index 00000000..65504569
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Applet/SwkbdAppletDialog.axaml
@@ -0,0 +1,65 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Controls.SwkbdAppletDialog"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ Width="400"
+ mc:Ignorable="d"
+ Focusable="True">
+ <Grid
+ Margin="20"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition />
+ </Grid.ColumnDefinitions>
+ <Image
+ Grid.Row="1"
+ Grid.RowSpan="5"
+ Height="80"
+ MinWidth="50"
+ Margin="5,10,20,10"
+ VerticalAlignment="Center"
+ Source="resm:Ryujinx.Ui.Common.Resources.Logo_Ryujinx.png?assembly=Ryujinx.Ui.Common" />
+ <TextBlock
+ Grid.Row="1"
+ Grid.Column="1"
+ Margin="5"
+ Text="{Binding MainText}"
+ TextWrapping="Wrap" />
+ <TextBlock
+ Grid.Row="2"
+ Grid.Column="1"
+ Margin="5"
+ Text="{Binding SecondaryText}"
+ TextWrapping="Wrap" />
+ <TextBox
+ Name="Input"
+ Grid.Row="3"
+ Grid.Column="1"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Center"
+ Focusable="True"
+ KeyUp="Message_KeyUp"
+ Text="{Binding Message}"
+ TextInput="Message_TextInput"
+ TextWrapping="Wrap"
+ UseFloatingWatermark="True" />
+ <TextBlock
+ Name="Error"
+ Grid.Row="4"
+ Grid.Column="1"
+ Margin="5"
+ HorizontalAlignment="Stretch"
+ TextWrapping="Wrap" />
+ </Grid>
+</UserControl>
diff --git a/src/Ryujinx.Ava/UI/Applet/SwkbdAppletDialog.axaml.cs b/src/Ryujinx.Ava/UI/Applet/SwkbdAppletDialog.axaml.cs
new file mode 100644
index 00000000..cb69e96b
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Applet/SwkbdAppletDialog.axaml.cs
@@ -0,0 +1,148 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using FluentAvalonia.Core;
+using FluentAvalonia.UI.Controls;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Windows;
+using Ryujinx.HLE.HOS.Applets;
+using System;
+using System.Threading.Tasks;
+
+namespace Ryujinx.Ava.UI.Controls
+{
+ internal partial class SwkbdAppletDialog : UserControl
+ {
+ private Predicate<int> _checkLength;
+ private int _inputMax;
+ private int _inputMin;
+ private string _placeholder;
+
+ private ContentDialog _host;
+
+ public SwkbdAppletDialog(string mainText, string secondaryText, string placeholder, string message)
+ {
+ MainText = mainText;
+ SecondaryText = secondaryText;
+ Message = message ?? "";
+ DataContext = this;
+ _placeholder = placeholder;
+ InitializeComponent();
+
+ Input.Watermark = _placeholder;
+
+ Input.AddHandler(TextInputEvent, Message_TextInput, RoutingStrategies.Tunnel, true);
+
+ SetInputLengthValidation(0, int.MaxValue); // Disable by default.
+ }
+
+ public SwkbdAppletDialog()
+ {
+ DataContext = this;
+ InitializeComponent();
+ }
+
+ protected override void OnGotFocus(GotFocusEventArgs e)
+ {
+ // FIXME: This does not work. Might be a bug in Avalonia with DialogHost
+ // Currently focus will be redirected to the overlay window instead.
+ Input.Focus();
+ }
+
+ public string Message { get; set; } = "";
+ public string MainText { get; set; } = "";
+ public string SecondaryText { get; set; } = "";
+
+ public static async Task<(UserResult Result, string Input)> ShowInputDialog(StyleableWindow window, string title, SoftwareKeyboardUiArgs args)
+ {
+ ContentDialog contentDialog = new ContentDialog();
+
+ UserResult result = UserResult.Cancel;
+
+ SwkbdAppletDialog content = new SwkbdAppletDialog(args.HeaderText, args.SubtitleText, args.GuideText, args.InitialText);
+
+ string input = string.Empty;
+
+ content.SetInputLengthValidation(args.StringLengthMin, args.StringLengthMax);
+
+ content._host = contentDialog;
+ contentDialog.Title = title;
+ contentDialog.PrimaryButtonText = args.SubmitText;
+ contentDialog.IsPrimaryButtonEnabled = content._checkLength(content.Message.Length);
+ contentDialog.SecondaryButtonText = "";
+ contentDialog.CloseButtonText = LocaleManager.Instance[LocaleKeys.InputDialogCancel];
+ contentDialog.Content = content;
+
+ TypedEventHandler<ContentDialog, ContentDialogClosedEventArgs> handler = (sender, eventArgs) =>
+ {
+ if (eventArgs.Result == ContentDialogResult.Primary)
+ {
+ result = UserResult.Ok;
+ input = content.Input.Text;
+ }
+ };
+ contentDialog.Closed += handler;
+
+ await ContentDialogHelper.ShowAsync(contentDialog);
+
+ return (result, input);
+ }
+
+ public void SetInputLengthValidation(int min, int max)
+ {
+ _inputMin = Math.Min(min, max);
+ _inputMax = Math.Max(min, max);
+
+ Error.IsVisible = false;
+ Error.FontStyle = FontStyle.Italic;
+
+ if (_inputMin <= 0 && _inputMax == int.MaxValue) // Disable.
+ {
+ Error.IsVisible = false;
+
+ _checkLength = length => true;
+ }
+ else if (_inputMin > 0 && _inputMax == int.MaxValue)
+ {
+ Error.IsVisible = true;
+
+ Error.Text = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.SwkbdMinCharacters, _inputMin);
+
+ _checkLength = length => _inputMin <= length;
+ }
+ else
+ {
+ Error.IsVisible = true;
+
+ Error.Text = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.SwkbdMinRangeCharacters, _inputMin, _inputMax);
+
+ _checkLength = length => _inputMin <= length && length <= _inputMax;
+ }
+
+ Message_TextInput(this, new TextInputEventArgs());
+ }
+
+ private void Message_TextInput(object sender, TextInputEventArgs e)
+ {
+ if (_host != null)
+ {
+ _host.IsPrimaryButtonEnabled = _checkLength(Message.Length);
+ }
+ }
+
+ private void Message_KeyUp(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter && _host.IsPrimaryButtonEnabled)
+ {
+ _host.Hide(ContentDialogResult.Primary);
+ }
+ else
+ {
+ _host.IsPrimaryButtonEnabled = _checkLength(Message.Length);
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Controls/GameGridView.axaml b/src/Ryujinx.Ava/UI/Controls/GameGridView.axaml
new file mode 100644
index 00000000..32cabfaa
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Controls/GameGridView.axaml
@@ -0,0 +1,177 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Controls.GameGridView"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:flex="clr-namespace:Avalonia.Flexbox;assembly=Avalonia.Flexbox"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
+ d:DesignHeight="450"
+ d:DesignWidth="800"
+ mc:Ignorable="d"
+ Focusable="True">
+ <UserControl.Resources>
+ <helpers:BitmapArrayValueConverter x:Key="ByteImage" />
+ <MenuFlyout x:Key="GameContextMenu">
+ <MenuItem
+ Command="{Binding ToggleFavorite}"
+ Header="{locale:Locale GameListContextMenuToggleFavorite}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuToggleFavoriteToolTip}" />
+ <Separator />
+ <MenuItem
+ Command="{Binding OpenUserSaveDirectory}"
+ IsEnabled="{Binding EnabledUserSaveDirectory}"
+ Header="{locale:Locale GameListContextMenuOpenUserSaveDirectory}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuOpenUserSaveDirectoryToolTip}" />
+ <MenuItem
+ Command="{Binding OpenDeviceSaveDirectory}"
+ IsEnabled="{Binding EnabledDeviceSaveDirectory}"
+ Header="{locale:Locale GameListContextMenuOpenDeviceSaveDirectory}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuOpenDeviceSaveDirectoryToolTip}" />
+ <MenuItem
+ Command="{Binding OpenBcatSaveDirectory}"
+ IsEnabled="{Binding EnabledBcatSaveDirectory}"
+ Header="{locale:Locale GameListContextMenuOpenBcatSaveDirectory}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuOpenBcatSaveDirectoryToolTip}" />
+ <Separator />
+ <MenuItem
+ Command="{Binding OpenTitleUpdateManager}"
+ Header="{locale:Locale GameListContextMenuManageTitleUpdates}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuManageTitleUpdatesToolTip}" />
+ <MenuItem
+ Command="{Binding OpenDownloadableContentManager}"
+ Header="{locale:Locale GameListContextMenuManageDlc}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuManageDlcToolTip}" />
+ <MenuItem
+ Command="{Binding OpenCheatManager}"
+ Header="{locale:Locale GameListContextMenuManageCheat}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuManageCheatToolTip}" />
+ <MenuItem
+ Command="{Binding OpenModsDirectory}"
+ Header="{locale:Locale GameListContextMenuOpenModsDirectory}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuOpenModsDirectoryToolTip}" />
+ <MenuItem
+ Command="{Binding OpenSdModsDirectory}"
+ Header="{locale:Locale GameListContextMenuOpenSdModsDirectory}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuOpenSdModsDirectoryToolTip}" />
+ <Separator />
+ <MenuItem Header="{locale:Locale GameListContextMenuCacheManagement}">
+ <MenuItem
+ Command="{Binding PurgePtcCache}"
+ Header="{locale:Locale GameListContextMenuCacheManagementPurgePptc}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuCacheManagementPurgePptcToolTip}" />
+ <MenuItem
+ Command="{Binding PurgeShaderCache}"
+ Header="{locale:Locale GameListContextMenuCacheManagementPurgeShaderCache}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuCacheManagementPurgeShaderCacheToolTip}" />
+ <MenuItem
+ Command="{Binding OpenPtcDirectory}"
+ Header="{locale:Locale GameListContextMenuCacheManagementOpenPptcDirectory}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuCacheManagementOpenPptcDirectoryToolTip}" />
+ <MenuItem
+ Command="{Binding OpenShaderCacheDirectory}"
+ Header="{locale:Locale GameListContextMenuCacheManagementOpenShaderCacheDirectory}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuCacheManagementOpenShaderCacheDirectoryToolTip}" />
+ </MenuItem>
+ <MenuItem Header="{locale:Locale GameListContextMenuExtractData}">
+ <MenuItem
+ Command="{Binding ExtractExeFs}"
+ Header="{locale:Locale GameListContextMenuExtractDataExeFS}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuExtractDataExeFSToolTip}" />
+ <MenuItem
+ Command="{Binding ExtractRomFs}"
+ Header="{locale:Locale GameListContextMenuExtractDataRomFS}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuExtractDataRomFSToolTip}" />
+ <MenuItem
+ Command="{Binding ExtractLogo}"
+ Header="{locale:Locale GameListContextMenuExtractDataLogo}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuExtractDataLogoToolTip}" />
+ </MenuItem>
+ </MenuFlyout>
+ </UserControl.Resources>
+ <Grid>
+ <Grid.RowDefinitions>
+ <RowDefinition Height="*" />
+ </Grid.RowDefinitions>
+ <ListBox
+ Grid.Row="0"
+ Padding="8"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ ContextFlyout="{StaticResource GameContextMenu}"
+ DoubleTapped="GameList_DoubleTapped"
+ Items="{Binding AppsObservableList}"
+ SelectionChanged="GameList_SelectionChanged">
+ <ListBox.ItemsPanel>
+ <ItemsPanelTemplate>
+ <flex:FlexPanel
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ AlignContent="FlexStart"
+ JustifyContent="Center" />
+ </ItemsPanelTemplate>
+ </ListBox.ItemsPanel>
+ <ListBox.Styles>
+ <Style Selector="ListBoxItem">
+ <Setter Property="Margin" Value="5" />
+ <Setter Property="CornerRadius" Value="4" />
+ </Style>
+ <Style Selector="ListBoxItem:selected /template/ Border#SelectionIndicator">
+ <Setter Property="MinHeight" Value="{Binding $parent[UserControl].DataContext.GridItemSelectorSize}" />
+ </Style>
+ </ListBox.Styles>
+ <ListBox.ItemTemplate>
+ <DataTemplate>
+ <Grid>
+ <Border
+ Margin="10"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ Classes.huge="{Binding $parent[UserControl].DataContext.IsGridHuge}"
+ Classes.large="{Binding $parent[UserControl].DataContext.IsGridLarge}"
+ Classes.normal="{Binding $parent[UserControl].DataContext.IsGridMedium}"
+ Classes.small="{Binding $parent[UserControl].DataContext.IsGridSmall}"
+ ClipToBounds="True"
+ CornerRadius="4">
+ <Grid>
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <Image
+ Grid.Row="0"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Top"
+ Source="{Binding Icon, Converter={StaticResource ByteImage}}" />
+ <Panel
+ Grid.Row="1"
+ Height="50"
+ Margin="0 10 0 0"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ IsVisible="{Binding $parent[UserControl].DataContext.ShowNames}">
+ <TextBlock
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Center"
+ Text="{Binding TitleName}"
+ TextAlignment="Center"
+ TextWrapping="Wrap" />
+ </Panel>
+ </Grid>
+ </Border>
+ <ui:SymbolIcon
+ Margin="5,5,0,0"
+ HorizontalAlignment="Left"
+ VerticalAlignment="Top"
+ FontSize="16"
+ Foreground="{DynamicResource SystemAccentColor}"
+ IsVisible="{Binding Favorite}"
+ Symbol="StarFilled" />
+ </Grid>
+ </DataTemplate>
+ </ListBox.ItemTemplate>
+ </ListBox>
+ </Grid>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Controls/GameGridView.axaml.cs b/src/Ryujinx.Ava/UI/Controls/GameGridView.axaml.cs
new file mode 100644
index 00000000..aa76b7c9
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Controls/GameGridView.axaml.cs
@@ -0,0 +1,57 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.Ui.App.Common;
+using System;
+
+namespace Ryujinx.Ava.UI.Controls
+{
+ public partial class GameGridView : UserControl
+ {
+ public static readonly RoutedEvent<ApplicationOpenedEventArgs> ApplicationOpenedEvent =
+ RoutedEvent.Register<GameGridView, ApplicationOpenedEventArgs>(nameof(ApplicationOpened), RoutingStrategies.Bubble);
+
+ public event EventHandler<ApplicationOpenedEventArgs> ApplicationOpened
+ {
+ add { AddHandler(ApplicationOpenedEvent, value); }
+ remove { RemoveHandler(ApplicationOpenedEvent, value); }
+ }
+
+ public GameGridView()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public void GameList_DoubleTapped(object sender, RoutedEventArgs args)
+ {
+ if (sender is ListBox listBox)
+ {
+ if (listBox.SelectedItem is ApplicationData selected)
+ {
+ RaiseEvent(new ApplicationOpenedEventArgs(selected, ApplicationOpenedEvent));
+ }
+ }
+ }
+
+ public void GameList_SelectionChanged(object sender, SelectionChangedEventArgs args)
+ {
+ if (sender is ListBox listBox)
+ {
+ (DataContext as MainWindowViewModel).GridSelectedApplication = listBox.SelectedItem as ApplicationData;
+ }
+ }
+
+ private void SearchBox_OnKeyUp(object sender, KeyEventArgs e)
+ {
+ (DataContext as MainWindowViewModel).SearchText = (sender as TextBox).Text;
+ }
+ }
+}
diff --git a/src/Ryujinx.Ava/UI/Controls/GameListView.axaml b/src/Ryujinx.Ava/UI/Controls/GameListView.axaml
new file mode 100644
index 00000000..c13eaae8
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Controls/GameListView.axaml
@@ -0,0 +1,233 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Controls.GameListView"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ d:DesignHeight="450"
+ d:DesignWidth="800"
+ Focusable="True"
+ mc:Ignorable="d">
+ <UserControl.Resources>
+ <helpers:BitmapArrayValueConverter x:Key="ByteImage" />
+ <MenuFlyout x:Key="GameContextMenu">
+ <MenuItem
+ Command="{Binding ToggleFavorite}"
+ Header="{locale:Locale GameListContextMenuToggleFavorite}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuToggleFavoriteToolTip}" />
+ <Separator />
+ <MenuItem
+ Command="{Binding OpenUserSaveDirectory}"
+ IsEnabled="{Binding EnabledUserSaveDirectory}"
+ Header="{locale:Locale GameListContextMenuOpenUserSaveDirectory}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuOpenUserSaveDirectoryToolTip}" />
+ <MenuItem
+ Command="{Binding OpenDeviceSaveDirectory}"
+ IsEnabled="{Binding EnabledDeviceSaveDirectory}"
+ Header="{locale:Locale GameListContextMenuOpenDeviceSaveDirectory}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuOpenDeviceSaveDirectoryToolTip}" />
+ <MenuItem
+ Command="{Binding OpenBcatSaveDirectory}"
+ IsEnabled="{Binding EnabledBcatSaveDirectory}"
+ Header="{locale:Locale GameListContextMenuOpenBcatSaveDirectory}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuOpenBcatSaveDirectoryToolTip}" />
+ <Separator />
+ <MenuItem
+ Command="{Binding OpenTitleUpdateManager}"
+ Header="{locale:Locale GameListContextMenuManageTitleUpdates}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuManageTitleUpdatesToolTip}" />
+ <MenuItem
+ Command="{Binding OpenDownloadableContentManager}"
+ Header="{locale:Locale GameListContextMenuManageDlc}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuManageDlcToolTip}" />
+ <MenuItem
+ Command="{Binding OpenCheatManager}"
+ Header="{locale:Locale GameListContextMenuManageCheat}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuManageCheatToolTip}" />
+ <MenuItem
+ Command="{Binding OpenModsDirectory}"
+ Header="{locale:Locale GameListContextMenuOpenModsDirectory}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuOpenModsDirectoryToolTip}" />
+ <MenuItem
+ Command="{Binding OpenSdModsDirectory}"
+ Header="{locale:Locale GameListContextMenuOpenSdModsDirectory}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuOpenSdModsDirectoryToolTip}" />
+ <Separator />
+ <MenuItem Header="{locale:Locale GameListContextMenuCacheManagement}">
+ <MenuItem
+ Command="{Binding PurgePtcCache}"
+ Header="{locale:Locale GameListContextMenuCacheManagementPurgePptc}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuCacheManagementPurgePptcToolTip}" />
+ <MenuItem
+ Command="{Binding PurgeShaderCache}"
+ Header="{locale:Locale GameListContextMenuCacheManagementPurgeShaderCache}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuCacheManagementPurgeShaderCacheToolTip}" />
+ <MenuItem
+ Command="{Binding OpenPtcDirectory}"
+ Header="{locale:Locale GameListContextMenuCacheManagementOpenPptcDirectory}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuCacheManagementOpenPptcDirectoryToolTip}" />
+ <MenuItem
+ Command="{Binding OpenShaderCacheDirectory}"
+ Header="{locale:Locale GameListContextMenuCacheManagementOpenShaderCacheDirectory}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuCacheManagementOpenShaderCacheDirectoryToolTip}" />
+ </MenuItem>
+ <MenuItem Header="{locale:Locale GameListContextMenuExtractData}">
+ <MenuItem
+ Command="{Binding ExtractExeFs}"
+ Header="{locale:Locale GameListContextMenuExtractDataExeFS}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuExtractDataExeFSToolTip}" />
+ <MenuItem
+ Command="{Binding ExtractRomFs}"
+ Header="{locale:Locale GameListContextMenuExtractDataRomFS}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuExtractDataRomFSToolTip}" />
+ <MenuItem
+ Command="{Binding ExtractLogo}"
+ Header="{locale:Locale GameListContextMenuExtractDataLogo}"
+ ToolTip.Tip="{locale:Locale GameListContextMenuExtractDataLogoToolTip}" />
+ </MenuItem>
+ </MenuFlyout>
+ </UserControl.Resources>
+ <Grid>
+ <Grid.RowDefinitions>
+ <RowDefinition Height="*" />
+ </Grid.RowDefinitions>
+ <ListBox
+ Name="GameListBox"
+ Grid.Row="0"
+ Padding="8"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ ContextFlyout="{StaticResource GameContextMenu}"
+ DoubleTapped="GameList_DoubleTapped"
+ Items="{Binding AppsObservableList}"
+ SelectionChanged="GameList_SelectionChanged">
+ <ListBox.ItemsPanel>
+ <ItemsPanelTemplate>
+ <StackPanel
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ Orientation="Vertical"
+ Spacing="2" />
+ </ItemsPanelTemplate>
+ </ListBox.ItemsPanel>
+ <ListBox.Styles>
+ <Style Selector="ListBoxItem:selected /template/ Border#SelectionIndicator">
+ <Setter Property="MinHeight" Value="{Binding $parent[UserControl].DataContext.ListItemSelectorSize}" />
+ </Style>
+ </ListBox.Styles>
+ <ListBox.ItemTemplate>
+ <DataTemplate>
+ <Grid>
+ <Border
+ Margin="0"
+ Padding="10"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ ClipToBounds="True"
+ CornerRadius="5">
+ <Grid>
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition Width="10" />
+ <ColumnDefinition Width="*" />
+ <ColumnDefinition Width="150" />
+ <ColumnDefinition Width="100" />
+ </Grid.ColumnDefinitions>
+ <Image
+ Grid.RowSpan="3"
+ Grid.Column="0"
+ Margin="0"
+ Classes.huge="{Binding $parent[UserControl].DataContext.IsGridHuge}"
+ Classes.large="{Binding $parent[UserControl].DataContext.IsGridLarge}"
+ Classes.normal="{Binding $parent[UserControl].DataContext.IsGridMedium}"
+ Classes.small="{Binding $parent[UserControl].DataContext.IsGridSmall}"
+ Source="{Binding Icon, Converter={StaticResource ByteImage}}" />
+ <Border
+ Grid.Column="2"
+ Margin="0,0,5,0"
+ BorderBrush="{DynamicResource ThemeControlBorderColor}"
+ BorderThickness="0,0,1,0">
+ <StackPanel
+ HorizontalAlignment="Left"
+ VerticalAlignment="Top"
+ Orientation="Vertical"
+ Spacing="5">
+ <TextBlock
+ HorizontalAlignment="Stretch"
+ FontWeight="Bold"
+ Text="{Binding TitleName}"
+ TextAlignment="Left"
+ TextWrapping="Wrap" />
+ <TextBlock
+ HorizontalAlignment="Stretch"
+ Text="{Binding Developer}"
+ TextAlignment="Left"
+ TextWrapping="Wrap" />
+ <TextBlock
+ HorizontalAlignment="Stretch"
+ Text="{Binding Version}"
+ TextAlignment="Left"
+ TextWrapping="Wrap" />
+ </StackPanel>
+ </Border>
+ <StackPanel
+ Grid.Column="3"
+ Margin="10,0,0,0"
+ HorizontalAlignment="Left"
+ VerticalAlignment="Top"
+ Orientation="Vertical"
+ Spacing="5">
+ <TextBlock
+ HorizontalAlignment="Stretch"
+ Text="{Binding TitleId}"
+ TextAlignment="Left"
+ TextWrapping="Wrap" />
+ <TextBlock
+ HorizontalAlignment="Stretch"
+ Text="{Binding FileExtension}"
+ TextAlignment="Left"
+ TextWrapping="Wrap" />
+ </StackPanel>
+ <StackPanel
+ Grid.Column="4"
+ HorizontalAlignment="Right"
+ VerticalAlignment="Top"
+ Orientation="Vertical"
+ Spacing="5">
+ <TextBlock
+ HorizontalAlignment="Stretch"
+ Text="{Binding TimePlayed}"
+ TextAlignment="Right"
+ TextWrapping="Wrap" />
+ <TextBlock
+ HorizontalAlignment="Stretch"
+ Text="{Binding LastPlayed}"
+ TextAlignment="Right"
+ TextWrapping="Wrap" />
+ <TextBlock
+ HorizontalAlignment="Stretch"
+ Text="{Binding FileSize}"
+ TextAlignment="Right"
+ TextWrapping="Wrap" />
+ </StackPanel>
+ <ui:SymbolIcon
+ Grid.Row="0"
+ Grid.Column="0"
+ Margin="-5,-5,0,0"
+ HorizontalAlignment="Left"
+ VerticalAlignment="Top"
+ FontSize="16"
+ Foreground="{DynamicResource SystemAccentColor}"
+ IsVisible="{Binding Favorite}"
+ Symbol="StarFilled" />
+ </Grid>
+ </Border>
+ </Grid>
+ </DataTemplate>
+ </ListBox.ItemTemplate>
+ </ListBox>
+ </Grid>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Controls/GameListView.axaml.cs b/src/Ryujinx.Ava/UI/Controls/GameListView.axaml.cs
new file mode 100644
index 00000000..a6449709
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Controls/GameListView.axaml.cs
@@ -0,0 +1,57 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.Ui.App.Common;
+using System;
+
+namespace Ryujinx.Ava.UI.Controls
+{
+ public partial class GameListView : UserControl
+ {
+ public static readonly RoutedEvent<ApplicationOpenedEventArgs> ApplicationOpenedEvent =
+ RoutedEvent.Register<GameGridView, ApplicationOpenedEventArgs>(nameof(ApplicationOpened), RoutingStrategies.Bubble);
+
+ public event EventHandler<ApplicationOpenedEventArgs> ApplicationOpened
+ {
+ add { AddHandler(ApplicationOpenedEvent, value); }
+ remove { RemoveHandler(ApplicationOpenedEvent, value); }
+ }
+
+ public GameListView()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public void GameList_DoubleTapped(object sender, RoutedEventArgs args)
+ {
+ if (sender is ListBox listBox)
+ {
+ if (listBox.SelectedItem is ApplicationData selected)
+ {
+ RaiseEvent(new ApplicationOpenedEventArgs(selected, ApplicationOpenedEvent));
+ }
+ }
+ }
+
+ public void GameList_SelectionChanged(object sender, SelectionChangedEventArgs args)
+ {
+ if (sender is ListBox listBox)
+ {
+ (DataContext as MainWindowViewModel).ListSelectedApplication = listBox.SelectedItem as ApplicationData;
+ }
+ }
+
+ private void SearchBox_OnKeyUp(object sender, KeyEventArgs e)
+ {
+ (DataContext as MainWindowViewModel).SearchText = (sender as TextBox).Text;
+ }
+ }
+}
diff --git a/src/Ryujinx.Ava/UI/Controls/NavigationDialogHost.axaml b/src/Ryujinx.Ava/UI/Controls/NavigationDialogHost.axaml
new file mode 100644
index 00000000..bf34b303
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Controls/NavigationDialogHost.axaml
@@ -0,0 +1,17 @@
+<UserControl
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ mc:Ignorable="d"
+ d:DesignWidth="800"
+ d:DesignHeight="450"
+ x:Class="Ryujinx.Ava.UI.Controls.NavigationDialogHost"
+ Focusable="True">
+ <ui:Frame
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ x:Name="ContentFrame">
+ </ui:Frame>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Controls/NavigationDialogHost.axaml.cs b/src/Ryujinx.Ava/UI/Controls/NavigationDialogHost.axaml.cs
new file mode 100644
index 00000000..1b857fae
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Controls/NavigationDialogHost.axaml.cs
@@ -0,0 +1,218 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Styling;
+using Avalonia.Threading;
+using FluentAvalonia.Core;
+using FluentAvalonia.UI.Controls;
+using LibHac;
+using LibHac.Common;
+using LibHac.Fs;
+using LibHac.Fs.Shim;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.Ava.UI.Views.User;
+using Ryujinx.HLE.FileSystem;
+using Ryujinx.HLE.HOS.Services.Account.Acc;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using UserProfile = Ryujinx.Ava.UI.Models.UserProfile;
+
+namespace Ryujinx.Ava.UI.Controls
+{
+ public partial class NavigationDialogHost : UserControl
+ {
+ public AccountManager AccountManager { get; }
+ public ContentManager ContentManager { get; }
+ public VirtualFileSystem VirtualFileSystem { get; }
+ public HorizonClient HorizonClient { get; }
+ public UserProfileViewModel ViewModel { get; set; }
+
+ public NavigationDialogHost()
+ {
+ InitializeComponent();
+ }
+
+ public NavigationDialogHost(AccountManager accountManager, ContentManager contentManager,
+ VirtualFileSystem virtualFileSystem, HorizonClient horizonClient)
+ {
+ AccountManager = accountManager;
+ ContentManager = contentManager;
+ VirtualFileSystem = virtualFileSystem;
+ HorizonClient = horizonClient;
+ ViewModel = new UserProfileViewModel();
+ LoadProfiles();
+
+ if (contentManager.GetCurrentFirmwareVersion() != null)
+ {
+ Task.Run(() =>
+ {
+ UserFirmwareAvatarSelectorViewModel.PreloadAvatars(contentManager, virtualFileSystem);
+ });
+ }
+ InitializeComponent();
+ }
+
+ public void GoBack(object parameter = null)
+ {
+ if (ContentFrame.BackStack.Count > 0)
+ {
+ ContentFrame.GoBack();
+ }
+
+ LoadProfiles();
+ }
+
+ public void Navigate(Type sourcePageType, object parameter)
+ {
+ ContentFrame.Navigate(sourcePageType, parameter);
+ }
+
+ public static async Task Show(AccountManager ownerAccountManager, ContentManager ownerContentManager,
+ VirtualFileSystem ownerVirtualFileSystem, HorizonClient ownerHorizonClient)
+ {
+ var content = new NavigationDialogHost(ownerAccountManager, ownerContentManager, ownerVirtualFileSystem, ownerHorizonClient);
+ ContentDialog contentDialog = new ContentDialog
+ {
+ Title = LocaleManager.Instance[LocaleKeys.UserProfileWindowTitle],
+ PrimaryButtonText = "",
+ SecondaryButtonText = "",
+ CloseButtonText = "",
+ Content = content,
+ Padding = new Thickness(0)
+ };
+
+ contentDialog.Closed += (sender, args) =>
+ {
+ content.ViewModel.Dispose();
+ };
+
+ Style footer = new(x => x.Name("DialogSpace").Child().OfType<Border>());
+ footer.Setters.Add(new Setter(IsVisibleProperty, false));
+
+ contentDialog.Styles.Add(footer);
+
+ await contentDialog.ShowAsync();
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+
+ Navigate(typeof(UserSelectorViews), this);
+ }
+
+ public void LoadProfiles()
+ {
+ ViewModel.Profiles.Clear();
+ ViewModel.LostProfiles.Clear();
+
+ var profiles = AccountManager.GetAllUsers().OrderBy(x => x.Name);
+
+ foreach (var profile in profiles)
+ {
+ ViewModel.Profiles.Add(new UserProfile(profile, this));
+ }
+
+ var saveDataFilter = SaveDataFilter.Make(programId: default, saveType: SaveDataType.Account, default, saveDataId: default, index: default);
+
+ using var saveDataIterator = new UniqueRef<SaveDataIterator>();
+
+ HorizonClient.Fs.OpenSaveDataIterator(ref saveDataIterator.Ref, SaveDataSpaceId.User, in saveDataFilter).ThrowIfFailure();
+
+ Span<SaveDataInfo> saveDataInfo = stackalloc SaveDataInfo[10];
+
+ HashSet<HLE.HOS.Services.Account.Acc.UserId> lostAccounts = new();
+
+ while (true)
+ {
+ saveDataIterator.Get.ReadSaveDataInfo(out long readCount, saveDataInfo).ThrowIfFailure();
+
+ if (readCount == 0)
+ {
+ break;
+ }
+
+ for (int i = 0; i < readCount; i++)
+ {
+ var save = saveDataInfo[i];
+ var id = new HLE.HOS.Services.Account.Acc.UserId((long)save.UserId.Id.Low, (long)save.UserId.Id.High);
+ if (ViewModel.Profiles.Cast<UserProfile>().FirstOrDefault( x=> x.UserId == id) == null)
+ {
+ lostAccounts.Add(id);
+ }
+ }
+ }
+
+ foreach(var account in lostAccounts)
+ {
+ ViewModel.LostProfiles.Add(new UserProfile(new HLE.HOS.Services.Account.Acc.UserProfile(account, "", null), this));
+ }
+
+ ViewModel.Profiles.Add(new BaseModel());
+ }
+
+ public async void DeleteUser(UserProfile userProfile)
+ {
+ var lastUserId = AccountManager.LastOpenedUser.UserId;
+
+ if (userProfile.UserId == lastUserId)
+ {
+ // If we are deleting the currently open profile, then we must open something else before deleting.
+ var profile = ViewModel.Profiles.Cast<UserProfile>().FirstOrDefault(x => x.UserId != lastUserId);
+
+ if (profile == null)
+ {
+ async void Action()
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogUserProfileDeletionWarningMessage]);
+ }
+
+ Dispatcher.UIThread.Post(Action);
+
+ return;
+ }
+
+ AccountManager.OpenUser(profile.UserId);
+ }
+
+ var result = await ContentDialogHelper.CreateConfirmationDialog(
+ LocaleManager.Instance[LocaleKeys.DialogUserProfileDeletionConfirmMessage],
+ "",
+ LocaleManager.Instance[LocaleKeys.InputDialogYes],
+ LocaleManager.Instance[LocaleKeys.InputDialogNo],
+ "");
+
+ if (result == UserResult.Yes)
+ {
+ GoBack();
+ AccountManager.DeleteUser(userProfile.UserId);
+ }
+
+ LoadProfiles();
+ }
+
+ public void AddUser()
+ {
+ Navigate(typeof(UserEditorView), (this, (UserProfile)null, true));
+ }
+
+ public void EditUser(UserProfile userProfile)
+ {
+ Navigate(typeof(UserEditorView), (this, userProfile, false));
+ }
+
+ public void RecoverLostAccounts()
+ {
+ Navigate(typeof(UserRecovererView), this);
+ }
+
+ public void ManageSaves()
+ {
+ Navigate(typeof(UserSaveManagerView), (this, AccountManager, HorizonClient, VirtualFileSystem));
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Controls/UpdateWaitWindow.axaml b/src/Ryujinx.Ava/UI/Controls/UpdateWaitWindow.axaml
new file mode 100644
index 00000000..c5041230
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Controls/UpdateWaitWindow.axaml
@@ -0,0 +1,42 @@
+<Window
+ x:Class="Ryujinx.Ava.UI.Controls.UpdateWaitWindow"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ Title="Ryujinx - Waiting"
+ SizeToContent="WidthAndHeight"
+ WindowStartupLocation="CenterOwner"
+ mc:Ignorable="d"
+ Focusable="True">
+ <Grid
+ Margin="20"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition />
+ </Grid.ColumnDefinitions>
+ <Image
+ Grid.Row="1"
+ Height="70"
+ MinWidth="50"
+ Margin="5,10,20,10"
+ Source="resm:Ryujinx.Ui.Common.Resources.Logo_Ryujinx.png?assembly=Ryujinx.Ui.Common" />
+ <StackPanel
+ Grid.Row="1"
+ Grid.Column="1"
+ VerticalAlignment="Center"
+ Orientation="Vertical">
+ <TextBlock Name="PrimaryText" Margin="5" />
+ <TextBlock
+ Name="SecondaryText"
+ Margin="5"
+ VerticalAlignment="Center" />
+ </StackPanel>
+ </Grid>
+</Window> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Controls/UpdateWaitWindow.axaml.cs b/src/Ryujinx.Ava/UI/Controls/UpdateWaitWindow.axaml.cs
new file mode 100644
index 00000000..80a437e3
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Controls/UpdateWaitWindow.axaml.cs
@@ -0,0 +1,31 @@
+using Avalonia.Controls;
+using Ryujinx.Ava.UI.Windows;
+using System.Threading;
+
+namespace Ryujinx.Ava.UI.Controls
+{
+ public partial class UpdateWaitWindow : StyleableWindow
+ {
+ public UpdateWaitWindow(string primaryText, string secondaryText, CancellationTokenSource cancellationToken) : this(primaryText, secondaryText)
+ {
+ SystemDecorations = SystemDecorations.Full;
+ ShowInTaskbar = true;
+
+ Closing += (_, _) => cancellationToken.Cancel();
+ }
+
+ public UpdateWaitWindow(string primaryText, string secondaryText) : this()
+ {
+ PrimaryText.Text = primaryText;
+ SecondaryText.Text = secondaryText;
+ WindowStartupLocation = WindowStartupLocation.CenterOwner;
+ SystemDecorations = SystemDecorations.BorderOnly;
+ ShowInTaskbar = false;
+ }
+
+ public UpdateWaitWindow()
+ {
+ InitializeComponent();
+ }
+ }
+} \ No newline at end of file
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);
+ }
+}
diff --git a/src/Ryujinx.Ava/UI/Models/CheatModel.cs b/src/Ryujinx.Ava/UI/Models/CheatModel.cs
new file mode 100644
index 00000000..a7507add
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Models/CheatModel.cs
@@ -0,0 +1,40 @@
+using Ryujinx.Ava.UI.ViewModels;
+using System;
+
+namespace Ryujinx.Ava.UI.Models
+{
+ public class CheatModel : BaseModel
+ {
+ private bool _isEnabled;
+
+ public event EventHandler<bool> EnableToggled;
+
+ public CheatModel(string name, string buildId, bool isEnabled)
+ {
+ Name = name;
+ BuildId = buildId;
+ IsEnabled = isEnabled;
+ }
+
+ public bool IsEnabled
+ {
+ get => _isEnabled;
+ set
+ {
+ _isEnabled = value;
+
+ EnableToggled?.Invoke(this, _isEnabled);
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string BuildId { get; }
+
+ public string BuildIdKey => $"{BuildId}-{Name}";
+
+ public string Name { get; }
+
+ public string CleanName => Name.Substring(1, Name.Length - 8);
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Models/CheatsList.cs b/src/Ryujinx.Ava/UI/Models/CheatsList.cs
new file mode 100644
index 00000000..e674f4eb
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Models/CheatsList.cs
@@ -0,0 +1,51 @@
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Linq;
+
+namespace Ryujinx.Ava.UI.Models
+{
+ public class CheatsList : ObservableCollection<CheatModel>
+ {
+ public CheatsList(string buildId, string path)
+ {
+ BuildId = buildId;
+ Path = path;
+
+ CollectionChanged += CheatsList_CollectionChanged;
+ }
+
+ public string BuildId { get; }
+ public string Path { get; }
+
+ public bool IsEnabled
+ {
+ get
+ {
+ return this.ToList().TrueForAll(x => x.IsEnabled);
+ }
+ set
+ {
+ foreach (var cheat in this)
+ {
+ cheat.IsEnabled = value;
+ }
+
+ OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsEnabled)));
+ }
+ }
+
+ private void CheatsList_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (e.Action == NotifyCollectionChangedAction.Add)
+ {
+ (e.NewItems[0] as CheatModel).EnableToggled += Item_EnableToggled;
+ }
+ }
+
+ private void Item_EnableToggled(object sender, bool e)
+ {
+ OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsEnabled)));
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Models/ControllerModel.cs b/src/Ryujinx.Ava/UI/Models/ControllerModel.cs
new file mode 100644
index 00000000..2af2d13b
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Models/ControllerModel.cs
@@ -0,0 +1,6 @@
+using Ryujinx.Common.Configuration.Hid;
+
+namespace Ryujinx.Ava.UI.Models
+{
+ internal record ControllerModel(ControllerType Type, string Name);
+}
diff --git a/src/Ryujinx.Ava/UI/Models/DeviceType.cs b/src/Ryujinx.Ava/UI/Models/DeviceType.cs
new file mode 100644
index 00000000..fa2e620c
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Models/DeviceType.cs
@@ -0,0 +1,9 @@
+namespace Ryujinx.Ava.UI.Models
+{
+ public enum DeviceType
+ {
+ None,
+ Keyboard,
+ Controller
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Models/DownloadableContentModel.cs b/src/Ryujinx.Ava/UI/Models/DownloadableContentModel.cs
new file mode 100644
index 00000000..b2ad0d31
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Models/DownloadableContentModel.cs
@@ -0,0 +1,35 @@
+using Ryujinx.Ava.UI.ViewModels;
+using System.IO;
+
+namespace Ryujinx.Ava.UI.Models
+{
+ public class DownloadableContentModel : BaseModel
+ {
+ private bool _enabled;
+
+ public bool Enabled
+ {
+ get => _enabled;
+ set
+ {
+ _enabled = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string TitleId { get; }
+ public string ContainerPath { get; }
+ public string FullPath { get; }
+
+ public string FileName => Path.GetFileName(ContainerPath);
+
+ public DownloadableContentModel(string titleId, string containerPath, string fullPath, bool enabled)
+ {
+ TitleId = titleId;
+ ContainerPath = containerPath;
+ FullPath = fullPath;
+ Enabled = enabled;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Models/Generic/LastPlayedSortComparer.cs b/src/Ryujinx.Ava/UI/Models/Generic/LastPlayedSortComparer.cs
new file mode 100644
index 00000000..98caceb5
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Models/Generic/LastPlayedSortComparer.cs
@@ -0,0 +1,33 @@
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ui.App.Common;
+using System;
+using System.Collections.Generic;
+
+namespace Ryujinx.Ava.UI.Models.Generic
+{
+ internal class LastPlayedSortComparer : IComparer<ApplicationData>
+ {
+ public LastPlayedSortComparer() { }
+ public LastPlayedSortComparer(bool isAscending) { IsAscending = isAscending; }
+
+ public bool IsAscending { get; }
+
+ public int Compare(ApplicationData x, ApplicationData y)
+ {
+ string aValue = x.LastPlayed;
+ string bValue = y.LastPlayed;
+
+ if (aValue == LocaleManager.Instance[LocaleKeys.Never])
+ {
+ aValue = DateTime.UnixEpoch.ToString();
+ }
+
+ if (bValue == LocaleManager.Instance[LocaleKeys.Never])
+ {
+ bValue = DateTime.UnixEpoch.ToString();
+ }
+
+ return (IsAscending ? 1 : -1) * DateTime.Compare(DateTime.Parse(bValue), DateTime.Parse(aValue));
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Models/InputConfiguration.cs b/src/Ryujinx.Ava/UI/Models/InputConfiguration.cs
new file mode 100644
index 00000000..2acd716b
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Models/InputConfiguration.cs
@@ -0,0 +1,456 @@
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.Common.Configuration.Hid;
+using Ryujinx.Common.Configuration.Hid.Controller;
+using Ryujinx.Common.Configuration.Hid.Controller.Motion;
+using Ryujinx.Common.Configuration.Hid.Keyboard;
+using System;
+
+namespace Ryujinx.Ava.UI.Models
+{
+ internal class InputConfiguration<Key, Stick> : BaseModel
+ {
+ private float _deadzoneRight;
+ private float _triggerThreshold;
+ private float _deadzoneLeft;
+ private double _gyroDeadzone;
+ private int _sensitivity;
+ private bool enableMotion;
+ private float weakRumble;
+ private float strongRumble;
+ private float _rangeLeft;
+ private float _rangeRight;
+
+ public InputBackendType Backend { get; set; }
+
+ /// <summary>
+ /// Controller id
+ /// </summary>
+ public string Id { get; set; }
+
+ /// <summary>
+ /// Controller's Type
+ /// </summary>
+ public ControllerType ControllerType { get; set; }
+
+ /// <summary>
+ /// Player's Index for the controller
+ /// </summary>
+ public PlayerIndex PlayerIndex { get; set; }
+
+ public Stick LeftJoystick { get; set; }
+ public bool LeftInvertStickX { get; set; }
+ public bool LeftInvertStickY { get; set; }
+ public bool RightRotate90 { get; set; }
+ public Key LeftControllerStickButton { get; set; }
+
+ public Stick RightJoystick { get; set; }
+ public bool RightInvertStickX { get; set; }
+ public bool RightInvertStickY { get; set; }
+ public bool LeftRotate90 { get; set; }
+ public Key RightControllerStickButton { get; set; }
+
+ public float DeadzoneLeft
+ {
+ get => _deadzoneLeft;
+ set
+ {
+ _deadzoneLeft = MathF.Round(value, 3);
+
+ OnPropertyChanged();
+ }
+ }
+
+ public float RangeLeft
+ {
+ get => _rangeLeft;
+ set
+ {
+ _rangeLeft = MathF.Round(value, 3);
+
+ OnPropertyChanged();
+ }
+ }
+
+ public float DeadzoneRight
+ {
+ get => _deadzoneRight;
+ set
+ {
+ _deadzoneRight = MathF.Round(value, 3);
+
+ OnPropertyChanged();
+ }
+ }
+
+ public float RangeRight
+ {
+ get => _rangeRight;
+ set
+ {
+ _rangeRight = MathF.Round(value, 3);
+
+ OnPropertyChanged();
+ }
+ }
+
+ public float TriggerThreshold
+ {
+ get => _triggerThreshold;
+ set
+ {
+ _triggerThreshold = MathF.Round(value, 3);
+
+ OnPropertyChanged();
+ }
+ }
+
+ public MotionInputBackendType MotionBackend { get; set; }
+
+ public Key ButtonMinus { get; set; }
+ public Key ButtonL { get; set; }
+ public Key ButtonZl { get; set; }
+ public Key LeftButtonSl { get; set; }
+ public Key LeftButtonSr { get; set; }
+ public Key DpadUp { get; set; }
+ public Key DpadDown { get; set; }
+ public Key DpadLeft { get; set; }
+ public Key DpadRight { get; set; }
+
+ public Key ButtonPlus { get; set; }
+ public Key ButtonR { get; set; }
+ public Key ButtonZr { get; set; }
+ public Key RightButtonSl { get; set; }
+ public Key RightButtonSr { get; set; }
+ public Key ButtonX { get; set; }
+ public Key ButtonB { get; set; }
+ public Key ButtonY { get; set; }
+ public Key ButtonA { get; set; }
+
+
+ public Key LeftStickUp { get; set; }
+ public Key LeftStickDown { get; set; }
+ public Key LeftStickLeft { get; set; }
+ public Key LeftStickRight { get; set; }
+ public Key LeftKeyboardStickButton { get; set; }
+
+ public Key RightStickUp { get; set; }
+ public Key RightStickDown { get; set; }
+ public Key RightStickLeft { get; set; }
+ public Key RightStickRight { get; set; }
+ public Key RightKeyboardStickButton { get; set; }
+
+ public int Sensitivity
+ {
+ get => _sensitivity;
+ set
+ {
+ _sensitivity = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public double GyroDeadzone
+ {
+ get => _gyroDeadzone;
+ set
+ {
+ _gyroDeadzone = Math.Round(value, 3);
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool EnableMotion
+ {
+ get => enableMotion; set
+ {
+ enableMotion = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool EnableCemuHookMotion { get; set; }
+ public int Slot { get; set; }
+ public int AltSlot { get; set; }
+ public bool MirrorInput { get; set; }
+ public string DsuServerHost { get; set; }
+ public int DsuServerPort { get; set; }
+
+ public bool EnableRumble { get; set; }
+ public float WeakRumble
+ {
+ get => weakRumble; set
+ {
+ weakRumble = value;
+
+ OnPropertyChanged();
+ }
+ }
+ public float StrongRumble
+ {
+ get => strongRumble; set
+ {
+ strongRumble = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public InputConfiguration(InputConfig config)
+ {
+ if (config != null)
+ {
+ Backend = config.Backend;
+ Id = config.Id;
+ ControllerType = config.ControllerType;
+ PlayerIndex = config.PlayerIndex;
+
+ if (config is StandardKeyboardInputConfig keyboardConfig)
+ {
+ LeftStickUp = (Key)(object)keyboardConfig.LeftJoyconStick.StickUp;
+ LeftStickDown = (Key)(object)keyboardConfig.LeftJoyconStick.StickDown;
+ LeftStickLeft = (Key)(object)keyboardConfig.LeftJoyconStick.StickLeft;
+ LeftStickRight = (Key)(object)keyboardConfig.LeftJoyconStick.StickRight;
+ LeftKeyboardStickButton = (Key)(object)keyboardConfig.LeftJoyconStick.StickButton;
+
+ RightStickUp = (Key)(object)keyboardConfig.RightJoyconStick.StickUp;
+ RightStickDown = (Key)(object)keyboardConfig.RightJoyconStick.StickDown;
+ RightStickLeft = (Key)(object)keyboardConfig.RightJoyconStick.StickLeft;
+ RightStickRight = (Key)(object)keyboardConfig.RightJoyconStick.StickRight;
+ RightKeyboardStickButton = (Key)(object)keyboardConfig.RightJoyconStick.StickButton;
+
+ ButtonA = (Key)(object)keyboardConfig.RightJoycon.ButtonA;
+ ButtonB = (Key)(object)keyboardConfig.RightJoycon.ButtonB;
+ ButtonX = (Key)(object)keyboardConfig.RightJoycon.ButtonX;
+ ButtonY = (Key)(object)keyboardConfig.RightJoycon.ButtonY;
+ ButtonR = (Key)(object)keyboardConfig.RightJoycon.ButtonR;
+ RightButtonSl = (Key)(object)keyboardConfig.RightJoycon.ButtonSl;
+ RightButtonSr = (Key)(object)keyboardConfig.RightJoycon.ButtonSr;
+ ButtonZr = (Key)(object)keyboardConfig.RightJoycon.ButtonZr;
+ ButtonPlus = (Key)(object)keyboardConfig.RightJoycon.ButtonPlus;
+
+ DpadUp = (Key)(object)keyboardConfig.LeftJoycon.DpadUp;
+ DpadDown = (Key)(object)keyboardConfig.LeftJoycon.DpadDown;
+ DpadLeft = (Key)(object)keyboardConfig.LeftJoycon.DpadLeft;
+ DpadRight = (Key)(object)keyboardConfig.LeftJoycon.DpadRight;
+ ButtonMinus = (Key)(object)keyboardConfig.LeftJoycon.ButtonMinus;
+ LeftButtonSl = (Key)(object)keyboardConfig.LeftJoycon.ButtonSl;
+ LeftButtonSr = (Key)(object)keyboardConfig.LeftJoycon.ButtonSr;
+ ButtonZl = (Key)(object)keyboardConfig.LeftJoycon.ButtonZl;
+ ButtonL = (Key)(object)keyboardConfig.LeftJoycon.ButtonL;
+ }
+ else if (config is StandardControllerInputConfig controllerConfig)
+ {
+ LeftJoystick = (Stick)(object)controllerConfig.LeftJoyconStick.Joystick;
+ LeftInvertStickX = controllerConfig.LeftJoyconStick.InvertStickX;
+ LeftInvertStickY = controllerConfig.LeftJoyconStick.InvertStickY;
+ LeftRotate90 = controllerConfig.LeftJoyconStick.Rotate90CW;
+ LeftControllerStickButton = (Key)(object)controllerConfig.LeftJoyconStick.StickButton;
+
+ RightJoystick = (Stick)(object)controllerConfig.RightJoyconStick.Joystick;
+ RightInvertStickX = controllerConfig.RightJoyconStick.InvertStickX;
+ RightInvertStickY = controllerConfig.RightJoyconStick.InvertStickY;
+ RightRotate90 = controllerConfig.RightJoyconStick.Rotate90CW;
+ RightControllerStickButton = (Key)(object)controllerConfig.RightJoyconStick.StickButton;
+
+ ButtonA = (Key)(object)controllerConfig.RightJoycon.ButtonA;
+ ButtonB = (Key)(object)controllerConfig.RightJoycon.ButtonB;
+ ButtonX = (Key)(object)controllerConfig.RightJoycon.ButtonX;
+ ButtonY = (Key)(object)controllerConfig.RightJoycon.ButtonY;
+ ButtonR = (Key)(object)controllerConfig.RightJoycon.ButtonR;
+ RightButtonSl = (Key)(object)controllerConfig.RightJoycon.ButtonSl;
+ RightButtonSr = (Key)(object)controllerConfig.RightJoycon.ButtonSr;
+ ButtonZr = (Key)(object)controllerConfig.RightJoycon.ButtonZr;
+ ButtonPlus = (Key)(object)controllerConfig.RightJoycon.ButtonPlus;
+
+ DpadUp = (Key)(object)controllerConfig.LeftJoycon.DpadUp;
+ DpadDown = (Key)(object)controllerConfig.LeftJoycon.DpadDown;
+ DpadLeft = (Key)(object)controllerConfig.LeftJoycon.DpadLeft;
+ DpadRight = (Key)(object)controllerConfig.LeftJoycon.DpadRight;
+ ButtonMinus = (Key)(object)controllerConfig.LeftJoycon.ButtonMinus;
+ LeftButtonSl = (Key)(object)controllerConfig.LeftJoycon.ButtonSl;
+ LeftButtonSr = (Key)(object)controllerConfig.LeftJoycon.ButtonSr;
+ ButtonZl = (Key)(object)controllerConfig.LeftJoycon.ButtonZl;
+ ButtonL = (Key)(object)controllerConfig.LeftJoycon.ButtonL;
+
+ DeadzoneLeft = controllerConfig.DeadzoneLeft;
+ DeadzoneRight = controllerConfig.DeadzoneRight;
+ RangeLeft = controllerConfig.RangeLeft;
+ RangeRight = controllerConfig.RangeRight;
+ TriggerThreshold = controllerConfig.TriggerThreshold;
+
+ if (controllerConfig.Motion != null)
+ {
+ EnableMotion = controllerConfig.Motion.EnableMotion;
+ MotionBackend = controllerConfig.Motion.MotionBackend;
+ GyroDeadzone = controllerConfig.Motion.GyroDeadzone;
+ Sensitivity = controllerConfig.Motion.Sensitivity;
+
+ if (controllerConfig.Motion is CemuHookMotionConfigController cemuHook)
+ {
+ EnableCemuHookMotion = true;
+ DsuServerHost = cemuHook.DsuServerHost;
+ DsuServerPort = cemuHook.DsuServerPort;
+ Slot = cemuHook.Slot;
+ AltSlot = cemuHook.AltSlot;
+ MirrorInput = cemuHook.MirrorInput;
+ }
+
+ if (controllerConfig.Rumble != null)
+ {
+ EnableRumble = controllerConfig.Rumble.EnableRumble;
+ WeakRumble = controllerConfig.Rumble.WeakRumble;
+ StrongRumble = controllerConfig.Rumble.StrongRumble;
+ }
+ }
+ }
+ }
+ }
+
+ public InputConfiguration()
+ {
+ }
+
+ public InputConfig GetConfig()
+ {
+ if (Backend == InputBackendType.WindowKeyboard)
+ {
+ return new StandardKeyboardInputConfig()
+ {
+ Id = Id,
+ Backend = Backend,
+ PlayerIndex = PlayerIndex,
+ ControllerType = ControllerType,
+ LeftJoycon = new LeftJoyconCommonConfig<Ryujinx.Common.Configuration.Hid.Key>()
+ {
+ DpadUp = (Ryujinx.Common.Configuration.Hid.Key)(object)DpadUp,
+ DpadDown = (Ryujinx.Common.Configuration.Hid.Key)(object)DpadDown,
+ DpadLeft = (Ryujinx.Common.Configuration.Hid.Key)(object)DpadLeft,
+ DpadRight = (Ryujinx.Common.Configuration.Hid.Key)(object)DpadRight,
+ ButtonL = (Ryujinx.Common.Configuration.Hid.Key)(object)ButtonL,
+ ButtonZl = (Ryujinx.Common.Configuration.Hid.Key)(object)ButtonZl,
+ ButtonSl = (Ryujinx.Common.Configuration.Hid.Key)(object)LeftButtonSl,
+ ButtonSr = (Ryujinx.Common.Configuration.Hid.Key)(object)LeftButtonSr,
+ ButtonMinus = (Ryujinx.Common.Configuration.Hid.Key)(object)ButtonMinus
+ },
+ RightJoycon = new RightJoyconCommonConfig<Ryujinx.Common.Configuration.Hid.Key>()
+ {
+ ButtonA = (Ryujinx.Common.Configuration.Hid.Key)(object)ButtonA,
+ ButtonB = (Ryujinx.Common.Configuration.Hid.Key)(object)ButtonB,
+ ButtonX = (Ryujinx.Common.Configuration.Hid.Key)(object)ButtonX,
+ ButtonY = (Ryujinx.Common.Configuration.Hid.Key)(object)ButtonY,
+ ButtonPlus = (Ryujinx.Common.Configuration.Hid.Key)(object)ButtonPlus,
+ ButtonSl = (Ryujinx.Common.Configuration.Hid.Key)(object)RightButtonSl,
+ ButtonSr = (Ryujinx.Common.Configuration.Hid.Key)(object)RightButtonSr,
+ ButtonR = (Ryujinx.Common.Configuration.Hid.Key)(object)ButtonR,
+ ButtonZr = (Ryujinx.Common.Configuration.Hid.Key)(object)ButtonZr
+ },
+ LeftJoyconStick = new JoyconConfigKeyboardStick<Ryujinx.Common.Configuration.Hid.Key>()
+ {
+ StickUp = (Ryujinx.Common.Configuration.Hid.Key)(object)LeftStickUp,
+ StickDown = (Ryujinx.Common.Configuration.Hid.Key)(object)LeftStickDown,
+ StickRight = (Ryujinx.Common.Configuration.Hid.Key)(object)LeftStickRight,
+ StickLeft = (Ryujinx.Common.Configuration.Hid.Key)(object)LeftStickLeft,
+ StickButton = (Ryujinx.Common.Configuration.Hid.Key)(object)LeftKeyboardStickButton
+ },
+ RightJoyconStick = new JoyconConfigKeyboardStick<Ryujinx.Common.Configuration.Hid.Key>()
+ {
+ StickUp = (Ryujinx.Common.Configuration.Hid.Key)(object)RightStickUp,
+ StickDown = (Ryujinx.Common.Configuration.Hid.Key)(object)RightStickDown,
+ StickLeft = (Ryujinx.Common.Configuration.Hid.Key)(object)RightStickLeft,
+ StickRight = (Ryujinx.Common.Configuration.Hid.Key)(object)RightStickRight,
+ StickButton = (Ryujinx.Common.Configuration.Hid.Key)(object)RightKeyboardStickButton
+ },
+ Version = InputConfig.CurrentVersion
+ };
+
+ }
+ else if (Backend == InputBackendType.GamepadSDL2)
+ {
+ var config = new StandardControllerInputConfig()
+ {
+ Id = Id,
+ Backend = Backend,
+ PlayerIndex = PlayerIndex,
+ ControllerType = ControllerType,
+ LeftJoycon = new LeftJoyconCommonConfig<GamepadInputId>()
+ {
+ DpadUp = (GamepadInputId)(object)DpadUp,
+ DpadDown = (GamepadInputId)(object)DpadDown,
+ DpadLeft = (GamepadInputId)(object)DpadLeft,
+ DpadRight = (GamepadInputId)(object)DpadRight,
+ ButtonL = (GamepadInputId)(object)ButtonL,
+ ButtonZl = (GamepadInputId)(object)ButtonZl,
+ ButtonSl = (GamepadInputId)(object)LeftButtonSl,
+ ButtonSr = (GamepadInputId)(object)LeftButtonSr,
+ ButtonMinus = (GamepadInputId)(object)ButtonMinus,
+ },
+ RightJoycon = new RightJoyconCommonConfig<GamepadInputId>()
+ {
+ ButtonA = (GamepadInputId)(object)ButtonA,
+ ButtonB = (GamepadInputId)(object)ButtonB,
+ ButtonX = (GamepadInputId)(object)ButtonX,
+ ButtonY = (GamepadInputId)(object)ButtonY,
+ ButtonPlus = (GamepadInputId)(object)ButtonPlus,
+ ButtonSl = (GamepadInputId)(object)RightButtonSl,
+ ButtonSr = (GamepadInputId)(object)RightButtonSr,
+ ButtonR = (GamepadInputId)(object)ButtonR,
+ ButtonZr = (GamepadInputId)(object)ButtonZr,
+ },
+ LeftJoyconStick = new JoyconConfigControllerStick<GamepadInputId, StickInputId>()
+ {
+ Joystick = (StickInputId)(object)LeftJoystick,
+ InvertStickX = LeftInvertStickX,
+ InvertStickY = LeftInvertStickY,
+ Rotate90CW = LeftRotate90,
+ StickButton = (GamepadInputId)(object)LeftControllerStickButton,
+ },
+ RightJoyconStick = new JoyconConfigControllerStick<GamepadInputId, StickInputId>()
+ {
+ Joystick = (StickInputId)(object)RightJoystick,
+ InvertStickX = RightInvertStickX,
+ InvertStickY = RightInvertStickY,
+ Rotate90CW = RightRotate90,
+ StickButton = (GamepadInputId)(object)RightControllerStickButton,
+ },
+ Rumble = new RumbleConfigController()
+ {
+ EnableRumble = EnableRumble,
+ WeakRumble = WeakRumble,
+ StrongRumble = StrongRumble
+ },
+ Version = InputConfig.CurrentVersion,
+ DeadzoneLeft = DeadzoneLeft,
+ DeadzoneRight = DeadzoneRight,
+ RangeLeft = RangeLeft,
+ RangeRight = RangeRight,
+ TriggerThreshold = TriggerThreshold,
+ Motion = EnableCemuHookMotion
+ ? new CemuHookMotionConfigController()
+ {
+ DsuServerHost = DsuServerHost,
+ DsuServerPort = DsuServerPort,
+ Slot = Slot,
+ AltSlot = AltSlot,
+ MirrorInput = MirrorInput,
+ MotionBackend = MotionInputBackendType.CemuHook
+ }
+ : new StandardMotionConfigController()
+ {
+ MotionBackend = MotionInputBackendType.GamepadDriver
+ }
+ };
+
+ config.Motion.Sensitivity = Sensitivity;
+ config.Motion.EnableMotion = EnableMotion;
+ config.Motion.GyroDeadzone = GyroDeadzone;
+
+ return config;
+ }
+
+ return null;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Models/PlayerModel.cs b/src/Ryujinx.Ava/UI/Models/PlayerModel.cs
new file mode 100644
index 00000000..f0b1bf7a
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Models/PlayerModel.cs
@@ -0,0 +1,6 @@
+using Ryujinx.Common.Configuration.Hid;
+
+namespace Ryujinx.Ava.UI.Models
+{
+ public record PlayerModel(PlayerIndex Id, string Name);
+}
diff --git a/src/Ryujinx.Ava/UI/Models/ProfileImageModel.cs b/src/Ryujinx.Ava/UI/Models/ProfileImageModel.cs
new file mode 100644
index 00000000..8aa19400
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Models/ProfileImageModel.cs
@@ -0,0 +1,32 @@
+using Avalonia.Media;
+using Ryujinx.Ava.UI.ViewModels;
+
+namespace Ryujinx.Ava.UI.Models
+{
+ public class ProfileImageModel : BaseModel
+ {
+ public ProfileImageModel(string name, byte[] data)
+ {
+ Name = name;
+ Data = data;
+ }
+
+ public string Name { get; set; }
+ public byte[] Data { get; set; }
+
+ private SolidColorBrush _backgroundColor = new(Colors.White);
+
+ public SolidColorBrush BackgroundColor
+ {
+ get
+ {
+ return _backgroundColor;
+ }
+ set
+ {
+ _backgroundColor = value;
+ OnPropertyChanged();
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Models/SaveModel.cs b/src/Ryujinx.Ava/UI/Models/SaveModel.cs
new file mode 100644
index 00000000..ab919c88
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Models/SaveModel.cs
@@ -0,0 +1,112 @@
+using LibHac.Fs;
+using LibHac.Ncm;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.Ava.UI.Windows;
+using Ryujinx.HLE.FileSystem;
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Ryujinx.Ava.UI.Models
+{
+ public class SaveModel : BaseModel
+ {
+ private long _size;
+
+ public ulong SaveId { get; }
+ public ProgramId TitleId { get; }
+ public string TitleIdString => $"{TitleId.Value:X16}";
+ public UserId UserId { get; }
+ public bool InGameList { get; }
+ public string Title { get; }
+ public byte[] Icon { get; }
+
+ public long Size
+ {
+ get => _size; set
+ {
+ _size = value;
+ SizeAvailable = true;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(SizeString));
+ OnPropertyChanged(nameof(SizeAvailable));
+ }
+ }
+
+ public bool SizeAvailable { get; set; }
+
+ public string SizeString => GetSizeString();
+
+ private string GetSizeString()
+ {
+ const int scale = 1024;
+ string[] orders = { "GiB", "MiB", "KiB" };
+ long max = (long)Math.Pow(scale, orders.Length);
+
+ foreach (string order in orders)
+ {
+ if (Size > max)
+ {
+ return $"{decimal.Divide(Size, max):##.##} {order}";
+ }
+
+ max /= scale;
+ }
+
+ return "0 KiB";
+ }
+
+ public SaveModel(SaveDataInfo info, VirtualFileSystem virtualFileSystem)
+ {
+ SaveId = info.SaveDataId;
+ TitleId = info.ProgramId;
+ UserId = info.UserId;
+
+ var appData = MainWindow.MainWindowViewModel.Applications.FirstOrDefault(x => x.TitleId.ToUpper() == TitleIdString);
+
+ InGameList = appData != null;
+
+ if (InGameList)
+ {
+ Icon = appData.Icon;
+ Title = appData.TitleName;
+ }
+ else
+ {
+ var appMetadata = MainWindow.MainWindowViewModel.ApplicationLibrary.LoadAndSaveMetaData(TitleIdString);
+ Title = appMetadata.Title ?? TitleIdString;
+ }
+
+ Task.Run(() =>
+ {
+ var saveRoot = System.IO.Path.Combine(virtualFileSystem.GetNandPath(), $"user/save/{info.SaveDataId:x16}");
+
+ long total_size = GetDirectorySize(saveRoot);
+ long GetDirectorySize(string path)
+ {
+ long size = 0;
+ if (Directory.Exists(path))
+ {
+ var directories = Directory.GetDirectories(path);
+ foreach (var directory in directories)
+ {
+ size += GetDirectorySize(directory);
+ }
+
+ var files = Directory.GetFiles(path);
+ foreach (var file in files)
+ {
+ size += new FileInfo(file).Length;
+ }
+ }
+
+ return size;
+ }
+
+ Size = total_size;
+ });
+
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Models/StatusUpdatedEventArgs.cs b/src/Ryujinx.Ava/UI/Models/StatusUpdatedEventArgs.cs
new file mode 100644
index 00000000..ea2b9038
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Models/StatusUpdatedEventArgs.cs
@@ -0,0 +1,28 @@
+using System;
+
+namespace Ryujinx.Ava.UI.Models
+{
+ internal class StatusUpdatedEventArgs : EventArgs
+ {
+ public bool VSyncEnabled { get; }
+ public string VolumeStatus { get; }
+ public string GpuBackend { get; }
+ public string AspectRatio { get; }
+ public string DockedMode { get; }
+ public string FifoStatus { get; }
+ public string GameStatus { get; }
+ public string GpuName { get; }
+
+ public StatusUpdatedEventArgs(bool vSyncEnabled, string volumeStatus, string gpuBackend, string dockedMode, string aspectRatio, string gameStatus, string fifoStatus, string gpuName)
+ {
+ VSyncEnabled = vSyncEnabled;
+ VolumeStatus = volumeStatus;
+ GpuBackend = gpuBackend;
+ DockedMode = dockedMode;
+ AspectRatio = aspectRatio;
+ GameStatus = gameStatus;
+ FifoStatus = fifoStatus;
+ GpuName = gpuName;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Models/TempProfile.cs b/src/Ryujinx.Ava/UI/Models/TempProfile.cs
new file mode 100644
index 00000000..05e16632
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Models/TempProfile.cs
@@ -0,0 +1,61 @@
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.HLE.HOS.Services.Account.Acc;
+using System;
+
+namespace Ryujinx.Ava.UI.Models
+{
+ public class TempProfile : BaseModel
+ {
+ private readonly UserProfile _profile;
+ private byte[] _image;
+ private string _name = String.Empty;
+ private UserId _userId;
+
+ public uint MaxProfileNameLength => 0x20;
+
+ public byte[] Image
+ {
+ get => _image;
+ set
+ {
+ _image = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public UserId UserId
+ {
+ get => _userId;
+ set
+ {
+ _userId = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(UserIdString));
+ }
+ }
+
+ public string UserIdString => _userId.ToString();
+
+ public string Name
+ {
+ get => _name;
+ set
+ {
+ _name = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public TempProfile(UserProfile profile)
+ {
+ _profile = profile;
+
+ if (_profile != null)
+ {
+ Image = profile.Image;
+ Name = profile.Name;
+ UserId = profile.UserId;
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Models/TimeZone.cs b/src/Ryujinx.Ava/UI/Models/TimeZone.cs
new file mode 100644
index 00000000..cb6cc2fd
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Models/TimeZone.cs
@@ -0,0 +1,16 @@
+namespace Ryujinx.Ava.UI.Models
+{
+ internal class TimeZone
+ {
+ public TimeZone(string utcDifference, string location, string abbreviation)
+ {
+ UtcDifference = utcDifference;
+ Location = location;
+ Abbreviation = abbreviation;
+ }
+
+ public string UtcDifference { get; set; }
+ public string Location { get; set; }
+ public string Abbreviation { get; set; }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Models/TitleUpdateModel.cs b/src/Ryujinx.Ava/UI/Models/TitleUpdateModel.cs
new file mode 100644
index 00000000..80476a43
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Models/TitleUpdateModel.cs
@@ -0,0 +1,19 @@
+using LibHac.Ns;
+using Ryujinx.Ava.Common.Locale;
+
+namespace Ryujinx.Ava.UI.Models
+{
+ public class TitleUpdateModel
+ {
+ public ApplicationControlProperty Control { get; }
+ public string Path { get; }
+
+ public string Label => LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.TitleUpdateVersionLabel, Control.DisplayVersionString.ToString());
+
+ public TitleUpdateModel(ApplicationControlProperty control, string path)
+ {
+ Control = control;
+ Path = path;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Models/UserProfile.cs b/src/Ryujinx.Ava/UI/Models/UserProfile.cs
new file mode 100644
index 00000000..e7cd5300
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Models/UserProfile.cs
@@ -0,0 +1,103 @@
+using Avalonia.Media;
+using Ryujinx.Ava.UI.Controls;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.Ava.UI.Views.User;
+using Ryujinx.HLE.HOS.Services.Account.Acc;
+using Profile = Ryujinx.HLE.HOS.Services.Account.Acc.UserProfile;
+
+namespace Ryujinx.Ava.UI.Models
+{
+ public class UserProfile : BaseModel
+ {
+ private readonly Profile _profile;
+ private readonly NavigationDialogHost _owner;
+ private byte[] _image;
+ private string _name;
+ private UserId _userId;
+ private bool _isPointerOver;
+ private IBrush _backgroundColor;
+
+ public byte[] Image
+ {
+ get => _image;
+ set
+ {
+ _image = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public UserId UserId
+ {
+ get => _userId;
+ set
+ {
+ _userId = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public string Name
+ {
+ get => _name;
+ set
+ {
+ _name = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsPointerOver
+ {
+ get => _isPointerOver;
+ set
+ {
+ _isPointerOver = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public IBrush BackgroundColor
+ {
+ get => _backgroundColor;
+ set
+ {
+ _backgroundColor = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public UserProfile(Profile profile, NavigationDialogHost owner)
+ {
+ _profile = profile;
+ _owner = owner;
+
+ UpdateBackground();
+
+ Image = profile.Image;
+ Name = profile.Name;
+ UserId = profile.UserId;
+ }
+
+ public void UpdateState()
+ {
+ UpdateBackground();
+ OnPropertyChanged(nameof(Name));
+ }
+
+ private void UpdateBackground()
+ {
+ Avalonia.Application.Current.Styles.TryGetResource("ControlFillColorSecondary", out object color);
+
+ if (color is not null)
+ {
+ BackgroundColor = _profile.AccountState == AccountState.Open ? new SolidColorBrush((Color)color) : Brushes.Transparent;
+ }
+ }
+
+ public void Recover(UserProfile userProfile)
+ {
+ _owner.Navigate(typeof(UserEditorView), (_owner, userProfile, true));
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Renderer/EmbeddedWindow.cs b/src/Ryujinx.Ava/UI/Renderer/EmbeddedWindow.cs
new file mode 100644
index 00000000..a5c8b003
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Renderer/EmbeddedWindow.cs
@@ -0,0 +1,288 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Platform;
+using Ryujinx.Common.Configuration;
+using Ryujinx.Ui.Common.Configuration;
+using Ryujinx.Ui.Common.Helper;
+using SPB.Graphics;
+using SPB.Platform;
+using SPB.Platform.GLX;
+using SPB.Platform.X11;
+using SPB.Windowing;
+using System;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using System.Threading.Tasks;
+using static Ryujinx.Ava.UI.Helpers.Win32NativeInterop;
+
+namespace Ryujinx.Ava.UI.Renderer
+{
+ public class EmbeddedWindow : NativeControlHost
+ {
+ private WindowProc _wndProcDelegate;
+ private string _className;
+
+ protected GLXWindow X11Window { get; set; }
+
+ protected IntPtr WindowHandle { get; set; }
+ protected IntPtr X11Display { get; set; }
+ protected IntPtr NsView { get; set; }
+ protected IntPtr MetalLayer { get; set; }
+
+ public delegate void UpdateBoundsCallbackDelegate(Rect rect);
+ private UpdateBoundsCallbackDelegate _updateBoundsCallback;
+
+ public event EventHandler<IntPtr> WindowCreated;
+ public event EventHandler<Size> SizeChanged;
+
+ public EmbeddedWindow()
+ {
+ this.GetObservable(BoundsProperty).Subscribe(StateChanged);
+
+ Initialized += OnNativeEmbeddedWindowCreated;
+ }
+
+ public virtual void OnWindowCreated() { }
+
+ protected virtual void OnWindowDestroyed() { }
+
+ protected virtual void OnWindowDestroying()
+ {
+ WindowHandle = IntPtr.Zero;
+ X11Display = IntPtr.Zero;
+ NsView = IntPtr.Zero;
+ MetalLayer = IntPtr.Zero;
+ }
+
+ private void OnNativeEmbeddedWindowCreated(object sender, EventArgs e)
+ {
+ OnWindowCreated();
+
+ Task.Run(() =>
+ {
+ WindowCreated?.Invoke(this, WindowHandle);
+ });
+ }
+
+ private void StateChanged(Rect rect)
+ {
+ SizeChanged?.Invoke(this, rect.Size);
+ _updateBoundsCallback?.Invoke(rect);
+ }
+
+ protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle control)
+ {
+ if (OperatingSystem.IsLinux())
+ {
+ return CreateLinux(control);
+ }
+ else if (OperatingSystem.IsWindows())
+ {
+ return CreateWin32(control);
+ }
+ else if (OperatingSystem.IsMacOS())
+ {
+ return CreateMacOS();
+ }
+
+ return base.CreateNativeControlCore(control);
+ }
+
+ protected override void DestroyNativeControlCore(IPlatformHandle control)
+ {
+ OnWindowDestroying();
+
+ if (OperatingSystem.IsLinux())
+ {
+ DestroyLinux();
+ }
+ else if (OperatingSystem.IsWindows())
+ {
+ DestroyWin32(control);
+ }
+ else if (OperatingSystem.IsMacOS())
+ {
+ DestroyMacOS();
+ }
+ else
+ {
+ base.DestroyNativeControlCore(control);
+ }
+
+ OnWindowDestroyed();
+ }
+
+ [SupportedOSPlatform("linux")]
+ private IPlatformHandle CreateLinux(IPlatformHandle control)
+ {
+ if (ConfigurationState.Instance.Graphics.GraphicsBackend.Value == GraphicsBackend.Vulkan)
+ {
+ X11Window = new GLXWindow(new NativeHandle(X11.DefaultDisplay), new NativeHandle(control.Handle));
+ X11Window.Hide();
+ }
+ else
+ {
+ X11Window = PlatformHelper.CreateOpenGLWindow(FramebufferFormat.Default, 0, 0, 100, 100) as GLXWindow;
+ }
+
+ WindowHandle = X11Window.WindowHandle.RawHandle;
+ X11Display = X11Window.DisplayHandle.RawHandle;
+
+ return new PlatformHandle(WindowHandle, "X11");
+ }
+
+ [SupportedOSPlatform("windows")]
+ IPlatformHandle CreateWin32(IPlatformHandle control)
+ {
+ _className = "NativeWindow-" + Guid.NewGuid();
+
+ _wndProcDelegate = delegate (IntPtr hWnd, WindowsMessages msg, IntPtr wParam, IntPtr lParam)
+ {
+ if (VisualRoot != null)
+ {
+ if (msg == WindowsMessages.LBUTTONDOWN ||
+ msg == WindowsMessages.RBUTTONDOWN ||
+ msg == WindowsMessages.LBUTTONUP ||
+ msg == WindowsMessages.RBUTTONUP ||
+ msg == WindowsMessages.MOUSEMOVE)
+ {
+ Point rootVisualPosition = this.TranslatePoint(new Point((long)lParam & 0xFFFF, (long)lParam >> 16 & 0xFFFF), VisualRoot).Value;
+ Pointer pointer = new(0, PointerType.Mouse, true);
+
+ switch (msg)
+ {
+ case WindowsMessages.LBUTTONDOWN:
+ case WindowsMessages.RBUTTONDOWN:
+ {
+ bool isLeft = msg == WindowsMessages.LBUTTONDOWN;
+ RawInputModifiers pointerPointModifier = isLeft ? RawInputModifiers.LeftMouseButton : RawInputModifiers.RightMouseButton;
+ PointerPointProperties properties = new(pointerPointModifier, isLeft ? PointerUpdateKind.LeftButtonPressed : PointerUpdateKind.RightButtonPressed);
+
+ var evnt = new PointerPressedEventArgs(
+ this,
+ pointer,
+ VisualRoot,
+ rootVisualPosition,
+ (ulong)Environment.TickCount64,
+ properties,
+ KeyModifiers.None);
+
+ RaiseEvent(evnt);
+
+ break;
+ }
+ case WindowsMessages.LBUTTONUP:
+ case WindowsMessages.RBUTTONUP:
+ {
+ bool isLeft = msg == WindowsMessages.LBUTTONUP;
+ RawInputModifiers pointerPointModifier = isLeft ? RawInputModifiers.LeftMouseButton : RawInputModifiers.RightMouseButton;
+ PointerPointProperties properties = new(pointerPointModifier, isLeft ? PointerUpdateKind.LeftButtonReleased : PointerUpdateKind.RightButtonReleased);
+
+ var evnt = new PointerReleasedEventArgs(
+ this,
+ pointer,
+ VisualRoot,
+ rootVisualPosition,
+ (ulong)Environment.TickCount64,
+ properties,
+ KeyModifiers.None,
+ isLeft ? MouseButton.Left : MouseButton.Right);
+
+ RaiseEvent(evnt);
+
+ break;
+ }
+ case WindowsMessages.MOUSEMOVE:
+ {
+ var evnt = new PointerEventArgs(
+ PointerMovedEvent,
+ this,
+ pointer,
+ VisualRoot,
+ rootVisualPosition,
+ (ulong)Environment.TickCount64,
+ new PointerPointProperties(RawInputModifiers.None, PointerUpdateKind.Other),
+ KeyModifiers.None);
+
+ RaiseEvent(evnt);
+
+ break;
+ }
+ }
+ }
+ }
+
+ return DefWindowProc(hWnd, msg, wParam, lParam);
+ };
+
+ WNDCLASSEX wndClassEx = new()
+ {
+ cbSize = Marshal.SizeOf<WNDCLASSEX>(),
+ hInstance = GetModuleHandle(null),
+ lpfnWndProc = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate),
+ style = ClassStyles.CS_OWNDC,
+ lpszClassName = Marshal.StringToHGlobalUni(_className),
+ hCursor = CreateArrowCursor()
+ };
+
+ RegisterClassEx(ref wndClassEx);
+
+ WindowHandle = CreateWindowEx(0, _className, "NativeWindow", WindowStyles.WS_CHILD, 0, 0, 640, 480, control.Handle, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
+
+ Marshal.FreeHGlobal(wndClassEx.lpszClassName);
+
+ return new PlatformHandle(WindowHandle, "HWND");
+ }
+
+ [SupportedOSPlatform("macos")]
+ IPlatformHandle CreateMacOS()
+ {
+ // Create a new CAMetalLayer.
+ IntPtr layerClass = ObjectiveC.objc_getClass("CAMetalLayer");
+ IntPtr metalLayer = ObjectiveC.IntPtr_objc_msgSend(layerClass, "alloc");
+ ObjectiveC.objc_msgSend(metalLayer, "init");
+
+ // Create a child NSView to render into.
+ IntPtr nsViewClass = ObjectiveC.objc_getClass("NSView");
+ IntPtr child = ObjectiveC.IntPtr_objc_msgSend(nsViewClass, "alloc");
+ ObjectiveC.objc_msgSend(child, "init", new ObjectiveC.NSRect(0, 0, 0, 0));
+
+ // Make its renderer our metal layer.
+ ObjectiveC.objc_msgSend(child, "setWantsLayer:", 1);
+ ObjectiveC.objc_msgSend(child, "setLayer:", metalLayer);
+ ObjectiveC.objc_msgSend(metalLayer, "setContentsScale:", Program.DesktopScaleFactor);
+
+ // Ensure the scale factor is up to date.
+ _updateBoundsCallback = rect =>
+ {
+ ObjectiveC.objc_msgSend(metalLayer, "setContentsScale:", Program.DesktopScaleFactor);
+ };
+
+ IntPtr nsView = child;
+ MetalLayer = metalLayer;
+ NsView = nsView;
+
+ return new PlatformHandle(nsView, "NSView");
+ }
+
+ [SupportedOSPlatform("Linux")]
+ void DestroyLinux()
+ {
+ X11Window?.Dispose();
+ }
+
+ [SupportedOSPlatform("windows")]
+ void DestroyWin32(IPlatformHandle handle)
+ {
+ DestroyWindow(handle.Handle);
+ UnregisterClass(_className, GetModuleHandle(null));
+ }
+
+ [SupportedOSPlatform("macos")]
+ void DestroyMacOS()
+ {
+ // TODO
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Renderer/EmbeddedWindowOpenGL.cs b/src/Ryujinx.Ava/UI/Renderer/EmbeddedWindowOpenGL.cs
new file mode 100644
index 00000000..305e891a
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Renderer/EmbeddedWindowOpenGL.cs
@@ -0,0 +1,87 @@
+using OpenTK.Graphics.OpenGL;
+using Ryujinx.Common.Configuration;
+using Ryujinx.Graphics.GAL;
+using Ryujinx.Graphics.OpenGL;
+using Ryujinx.Ui.Common.Configuration;
+using SPB.Graphics;
+using SPB.Graphics.OpenGL;
+using SPB.Platform;
+using SPB.Platform.WGL;
+using SPB.Windowing;
+using System;
+
+namespace Ryujinx.Ava.UI.Renderer
+{
+ public class EmbeddedWindowOpenGL : EmbeddedWindow
+ {
+ private SwappableNativeWindowBase _window;
+
+ public OpenGLContextBase Context { get; set; }
+
+ public EmbeddedWindowOpenGL() { }
+
+ protected override void OnWindowDestroying()
+ {
+ Context.Dispose();
+
+ base.OnWindowDestroying();
+ }
+
+ public override void OnWindowCreated()
+ {
+ base.OnWindowCreated();
+
+ if (OperatingSystem.IsWindows())
+ {
+ _window = new WGLWindow(new NativeHandle(WindowHandle));
+ }
+ else if (OperatingSystem.IsLinux())
+ {
+ _window = X11Window;
+ }
+ else
+ {
+ throw new PlatformNotSupportedException();
+ }
+
+ var flags = OpenGLContextFlags.Compat;
+ if (ConfigurationState.Instance.Logger.GraphicsDebugLevel != GraphicsDebugLevel.None)
+ {
+ flags |= OpenGLContextFlags.Debug;
+ }
+
+ var graphicsMode = Environment.OSVersion.Platform == PlatformID.Unix ? new FramebufferFormat(new ColorFormat(8, 8, 8, 0), 16, 0, ColorFormat.Zero, 0, 2, false) : FramebufferFormat.Default;
+
+ Context = PlatformHelper.CreateOpenGLContext(graphicsMode, 3, 3, flags);
+
+ Context.Initialize(_window);
+ Context.MakeCurrent(_window);
+
+ GL.LoadBindings(new OpenTKBindingsContext(Context.GetProcAddress));
+
+ Context.MakeCurrent(null);
+ }
+
+ public void MakeCurrent()
+ {
+ Context?.MakeCurrent(_window);
+ }
+
+ public void MakeCurrent(NativeWindowBase window)
+ {
+ Context?.MakeCurrent(window);
+ }
+
+ public void SwapBuffers()
+ {
+ _window?.SwapBuffers();
+ }
+
+ public void InitializeBackgroundContext(IRenderer renderer)
+ {
+ (renderer as OpenGLRenderer)?.InitializeBackgroundContext(SPBOpenGLContext.CreateBackgroundContext(Context));
+
+ MakeCurrent();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Renderer/EmbeddedWindowVulkan.cs b/src/Ryujinx.Ava/UI/Renderer/EmbeddedWindowVulkan.cs
new file mode 100644
index 00000000..0b3eb9e3
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Renderer/EmbeddedWindowVulkan.cs
@@ -0,0 +1,42 @@
+using Silk.NET.Vulkan;
+using SPB.Graphics.Vulkan;
+using SPB.Platform.Metal;
+using SPB.Platform.Win32;
+using SPB.Platform.X11;
+using SPB.Windowing;
+using System;
+
+namespace Ryujinx.Ava.UI.Renderer
+{
+ public class EmbeddedWindowVulkan : EmbeddedWindow
+ {
+ public SurfaceKHR CreateSurface(Instance instance)
+ {
+ NativeWindowBase nativeWindowBase;
+
+ if (OperatingSystem.IsWindows())
+ {
+ nativeWindowBase = new SimpleWin32Window(new NativeHandle(WindowHandle));
+ }
+ else if (OperatingSystem.IsLinux())
+ {
+ nativeWindowBase = new SimpleX11Window(new NativeHandle(X11Display), new NativeHandle(WindowHandle));
+ }
+ else if (OperatingSystem.IsMacOS())
+ {
+ nativeWindowBase = new SimpleMetalWindow(new NativeHandle(NsView), new NativeHandle(MetalLayer));
+ }
+ else
+ {
+ throw new PlatformNotSupportedException();
+ }
+
+ return new SurfaceKHR((ulong?)VulkanHelper.CreateWindowSurface(instance.Handle, nativeWindowBase));
+ }
+
+ public SurfaceKHR CreateSurface(Instance instance, Vk api)
+ {
+ return CreateSurface(instance);
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Renderer/OpenTKBindingsContext.cs b/src/Ryujinx.Ava/UI/Renderer/OpenTKBindingsContext.cs
new file mode 100644
index 00000000..a2ec02b2
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Renderer/OpenTKBindingsContext.cs
@@ -0,0 +1,20 @@
+using OpenTK;
+using System;
+
+namespace Ryujinx.Ava.UI.Renderer
+{
+ internal class OpenTKBindingsContext : IBindingsContext
+ {
+ private readonly Func<string, IntPtr> _getProcAddress;
+
+ public OpenTKBindingsContext(Func<string, IntPtr> getProcAddress)
+ {
+ _getProcAddress = getProcAddress;
+ }
+
+ public IntPtr GetProcAddress(string procName)
+ {
+ return _getProcAddress(procName);
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Renderer/RendererHost.axaml b/src/Ryujinx.Ava/UI/Renderer/RendererHost.axaml
new file mode 100644
index 00000000..bb96b10d
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Renderer/RendererHost.axaml
@@ -0,0 +1,11 @@
+<UserControl
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ mc:Ignorable="d"
+ d:DesignWidth="800"
+ d:DesignHeight="450"
+ x:Class="Ryujinx.Ava.UI.Renderer.RendererHost"
+ Focusable="True">
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Renderer/RendererHost.axaml.cs b/src/Ryujinx.Ava/UI/Renderer/RendererHost.axaml.cs
new file mode 100644
index 00000000..ee10282d
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Renderer/RendererHost.axaml.cs
@@ -0,0 +1,68 @@
+using Avalonia;
+using Avalonia.Controls;
+using Ryujinx.Common.Configuration;
+using Ryujinx.Ui.Common.Configuration;
+using System;
+
+namespace Ryujinx.Ava.UI.Renderer
+{
+ public partial class RendererHost : UserControl, IDisposable
+ {
+ public readonly EmbeddedWindow EmbeddedWindow;
+
+ public event EventHandler<EventArgs> WindowCreated;
+ public event Action<object, Size> SizeChanged;
+
+ public RendererHost()
+ {
+ InitializeComponent();
+
+ if (ConfigurationState.Instance.Graphics.GraphicsBackend.Value == GraphicsBackend.OpenGl)
+ {
+ EmbeddedWindow = new EmbeddedWindowOpenGL();
+ }
+ else
+ {
+ EmbeddedWindow = new EmbeddedWindowVulkan();
+ }
+
+ Initialize();
+ }
+
+ private void Initialize()
+ {
+ EmbeddedWindow.WindowCreated += CurrentWindow_WindowCreated;
+ EmbeddedWindow.SizeChanged += CurrentWindow_SizeChanged;
+
+ Content = EmbeddedWindow;
+ }
+
+ public void Dispose()
+ {
+ if (EmbeddedWindow != null)
+ {
+ EmbeddedWindow.WindowCreated -= CurrentWindow_WindowCreated;
+ EmbeddedWindow.SizeChanged -= CurrentWindow_SizeChanged;
+ }
+
+ GC.SuppressFinalize(this);
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+
+ Dispose();
+ }
+
+ private void CurrentWindow_SizeChanged(object sender, Size e)
+ {
+ SizeChanged?.Invoke(sender, e);
+ }
+
+ private void CurrentWindow_WindowCreated(object sender, IntPtr e)
+ {
+ WindowCreated?.Invoke(this, EventArgs.Empty);
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Renderer/SPBOpenGLContext.cs b/src/Ryujinx.Ava/UI/Renderer/SPBOpenGLContext.cs
new file mode 100644
index 00000000..e090f14c
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Renderer/SPBOpenGLContext.cs
@@ -0,0 +1,47 @@
+using OpenTK.Graphics.OpenGL;
+using Ryujinx.Graphics.OpenGL;
+using SPB.Graphics;
+using SPB.Graphics.OpenGL;
+using SPB.Platform;
+using SPB.Windowing;
+
+namespace Ryujinx.Ava.UI.Renderer
+{
+ class SPBOpenGLContext : IOpenGLContext
+ {
+ private readonly OpenGLContextBase _context;
+ private readonly NativeWindowBase _window;
+
+ private SPBOpenGLContext(OpenGLContextBase context, NativeWindowBase window)
+ {
+ _context = context;
+ _window = window;
+ }
+
+ public void Dispose()
+ {
+ _context.Dispose();
+ _window.Dispose();
+ }
+
+ public void MakeCurrent()
+ {
+ _context.MakeCurrent(_window);
+ }
+
+ public static SPBOpenGLContext CreateBackgroundContext(OpenGLContextBase sharedContext)
+ {
+ OpenGLContextBase context = PlatformHelper.CreateOpenGLContext(FramebufferFormat.Default, 3, 3, OpenGLContextFlags.Compat, true, sharedContext);
+ NativeWindowBase window = PlatformHelper.CreateOpenGLWindow(FramebufferFormat.Default, 0, 0, 100, 100);
+
+ context.Initialize(window);
+ context.MakeCurrent(window);
+
+ GL.LoadBindings(new OpenTKBindingsContext(context.GetProcAddress));
+
+ context.MakeCurrent(null);
+
+ return new SPBOpenGLContext(context, window);
+ }
+ }
+}
diff --git a/src/Ryujinx.Ava/UI/ViewModels/AboutWindowViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/AboutWindowViewModel.cs
new file mode 100644
index 00000000..479411cb
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/AboutWindowViewModel.cs
@@ -0,0 +1,133 @@
+using Avalonia;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Common.Utilities;
+using Ryujinx.Ui.Common.Configuration;
+using System;
+using System.Net.Http;
+using System.Net.NetworkInformation;
+using System.Threading.Tasks;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class AboutWindowViewModel : BaseModel
+ {
+ private Bitmap _githubLogo;
+ private Bitmap _discordLogo;
+ private Bitmap _patreonLogo;
+ private Bitmap _twitterLogo;
+
+ private string _version;
+ private string _supporters;
+
+ public Bitmap GithubLogo
+ {
+ get => _githubLogo;
+ set
+ {
+ _githubLogo = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public Bitmap DiscordLogo
+ {
+ get => _discordLogo;
+ set
+ {
+ _discordLogo = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public Bitmap PatreonLogo
+ {
+ get => _patreonLogo;
+ set
+ {
+ _patreonLogo = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public Bitmap TwitterLogo
+ {
+ get => _twitterLogo;
+ set
+ {
+ _twitterLogo = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public string Supporters
+ {
+ get => _supporters;
+ set
+ {
+ _supporters = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public string Version
+ {
+ get => _version;
+ set
+ {
+ _version = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public string Developers => LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.AboutPageDeveloperListMore, "gdkchan, Ac_K, marysaka, rip in peri peri, LDj3SNuD, emmaus, Thealexbarney, GoffyDude, TSRBerry, IsaacMarovitz");
+
+ public AboutWindowViewModel()
+ {
+ Version = Program.Version;
+
+ var assets = AvaloniaLocator.Current.GetService<Avalonia.Platform.IAssetLoader>();
+
+ if (ConfigurationState.Instance.Ui.BaseStyle.Value == "Light")
+ {
+ GithubLogo = new Bitmap(assets.Open(new Uri("resm:Ryujinx.Ui.Common.Resources.Logo_GitHub_Light.png?assembly=Ryujinx.Ui.Common")));
+ DiscordLogo = new Bitmap(assets.Open(new Uri("resm:Ryujinx.Ui.Common.Resources.Logo_Discord_Light.png?assembly=Ryujinx.Ui.Common")));
+ PatreonLogo = new Bitmap(assets.Open(new Uri("resm:Ryujinx.Ui.Common.Resources.Logo_Patreon_Light.png?assembly=Ryujinx.Ui.Common")));
+ TwitterLogo = new Bitmap(assets.Open(new Uri("resm:Ryujinx.Ui.Common.Resources.Logo_Twitter_Light.png?assembly=Ryujinx.Ui.Common")));
+ }
+ else
+ {
+ GithubLogo = new Bitmap(assets.Open(new Uri("resm:Ryujinx.Ui.Common.Resources.Logo_GitHub_Dark.png?assembly=Ryujinx.Ui.Common")));
+ DiscordLogo = new Bitmap(assets.Open(new Uri("resm:Ryujinx.Ui.Common.Resources.Logo_Discord_Dark.png?assembly=Ryujinx.Ui.Common")));
+ PatreonLogo = new Bitmap(assets.Open(new Uri("resm:Ryujinx.Ui.Common.Resources.Logo_Patreon_Dark.png?assembly=Ryujinx.Ui.Common")));
+ TwitterLogo = new Bitmap(assets.Open(new Uri("resm:Ryujinx.Ui.Common.Resources.Logo_Twitter_Dark.png?assembly=Ryujinx.Ui.Common")));
+ }
+
+ Dispatcher.UIThread.InvokeAsync(DownloadPatronsJson);
+ }
+
+ private async Task DownloadPatronsJson()
+ {
+ if (!NetworkInterface.GetIsNetworkAvailable())
+ {
+ Supporters = LocaleManager.Instance[LocaleKeys.ConnectionError];
+
+ return;
+ }
+
+ HttpClient httpClient = new();
+
+ try
+ {
+ string patreonJsonString = await httpClient.GetStringAsync("https://patreon.ryujinx.org/");
+
+ Supporters = string.Join(", ", JsonHelper.Deserialize(patreonJsonString, CommonJsonContext.Default.StringArray)) + "\n\n";
+ }
+ catch
+ {
+ Supporters = LocaleManager.Instance[LocaleKeys.ApiError];
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/AmiiboWindowViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/AmiiboWindowViewModel.cs
new file mode 100644
index 00000000..bb92798f
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/AmiiboWindowViewModel.cs
@@ -0,0 +1,467 @@
+using Avalonia;
+using Avalonia.Collections;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Windows;
+using Ryujinx.Common;
+using Ryujinx.Common.Configuration;
+using Ryujinx.Common.Logging;
+using Ryujinx.Common.Utilities;
+using Ryujinx.Ui.Common.Models.Amiibo;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Threading.Tasks;
+using AmiiboJsonSerializerContext = Ryujinx.Ui.Common.Models.Amiibo.AmiiboJsonSerializerContext;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class AmiiboWindowViewModel : BaseModel, IDisposable
+ {
+ private const string DefaultJson = "{ \"amiibo\": [] }";
+ private const float AmiiboImageSize = 350f;
+
+ private readonly string _amiiboJsonPath;
+ private readonly byte[] _amiiboLogoBytes;
+ private readonly HttpClient _httpClient;
+ private readonly StyleableWindow _owner;
+
+ private Bitmap _amiiboImage;
+ private List<AmiiboApi> _amiiboList;
+ private AvaloniaList<AmiiboApi> _amiibos;
+ private ObservableCollection<string> _amiiboSeries;
+
+ private int _amiiboSelectedIndex;
+ private int _seriesSelectedIndex;
+ private bool _enableScanning;
+ private bool _showAllAmiibo;
+ private bool _useRandomUuid;
+ private string _usage;
+
+ private static readonly AmiiboJsonSerializerContext SerializerContext = new(JsonHelper.GetDefaultSerializerOptions());
+
+ public AmiiboWindowViewModel(StyleableWindow owner, string lastScannedAmiiboId, string titleId)
+ {
+ _owner = owner;
+
+ _httpClient = new HttpClient
+ {
+ Timeout = TimeSpan.FromSeconds(30)
+ };
+
+ LastScannedAmiiboId = lastScannedAmiiboId;
+ TitleId = titleId;
+
+ Directory.CreateDirectory(Path.Join(AppDataManager.BaseDirPath, "system", "amiibo"));
+
+ _amiiboJsonPath = Path.Join(AppDataManager.BaseDirPath, "system", "amiibo", "Amiibo.json");
+ _amiiboList = new List<AmiiboApi>();
+ _amiiboSeries = new ObservableCollection<string>();
+ _amiibos = new AvaloniaList<AmiiboApi>();
+
+ _amiiboLogoBytes = EmbeddedResources.Read("Ryujinx.Ui.Common/Resources/Logo_Amiibo.png");
+
+ _ = LoadContentAsync();
+ }
+
+ public AmiiboWindowViewModel() { }
+
+ public string TitleId { get; set; }
+ public string LastScannedAmiiboId { get; set; }
+
+ public UserResult Response { get; private set; }
+
+ public bool UseRandomUuid
+ {
+ get => _useRandomUuid;
+ set
+ {
+ _useRandomUuid = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool ShowAllAmiibo
+ {
+ get => _showAllAmiibo;
+ set
+ {
+ _showAllAmiibo = value;
+
+ ParseAmiiboData();
+
+ OnPropertyChanged();
+ }
+ }
+
+ public AvaloniaList<AmiiboApi> AmiiboList
+ {
+ get => _amiibos;
+ set
+ {
+ _amiibos = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public ObservableCollection<string> AmiiboSeries
+ {
+ get => _amiiboSeries;
+ set
+ {
+ _amiiboSeries = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public int SeriesSelectedIndex
+ {
+ get => _seriesSelectedIndex;
+ set
+ {
+ _seriesSelectedIndex = value;
+
+ FilterAmiibo();
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int AmiiboSelectedIndex
+ {
+ get => _amiiboSelectedIndex;
+ set
+ {
+ _amiiboSelectedIndex = value;
+
+ EnableScanning = _amiiboSelectedIndex >= 0 && _amiiboSelectedIndex < _amiibos.Count;
+
+ SetAmiiboDetails();
+
+ OnPropertyChanged();
+ }
+ }
+
+ public Bitmap AmiiboImage
+ {
+ get => _amiiboImage;
+ set
+ {
+ _amiiboImage = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string Usage
+ {
+ get => _usage;
+ set
+ {
+ _usage = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool EnableScanning
+ {
+ get => _enableScanning;
+ set
+ {
+ _enableScanning = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public void Dispose()
+ {
+ _httpClient.Dispose();
+ }
+
+ private async Task LoadContentAsync()
+ {
+ string amiiboJsonString = DefaultJson;
+
+ if (File.Exists(_amiiboJsonPath))
+ {
+ amiiboJsonString = await File.ReadAllTextAsync(_amiiboJsonPath);
+
+ if (await NeedsUpdate(JsonHelper.Deserialize(amiiboJsonString, SerializerContext.AmiiboJson).LastUpdated))
+ {
+ amiiboJsonString = await DownloadAmiiboJson();
+ }
+ }
+ else
+ {
+ try
+ {
+ amiiboJsonString = await DownloadAmiiboJson();
+ }
+ catch (Exception ex)
+ {
+ Logger.Error?.Print(LogClass.Application, $"Failed to download amiibo data: {ex}");
+
+ ShowInfoDialog();
+ }
+ }
+
+ _amiiboList = JsonHelper.Deserialize(amiiboJsonString, SerializerContext.AmiiboJson).Amiibo;
+ _amiiboList = _amiiboList.OrderBy(amiibo => amiibo.AmiiboSeries).ToList();
+
+ ParseAmiiboData();
+ }
+
+ private void ParseAmiiboData()
+ {
+ _amiiboSeries.Clear();
+ _amiibos.Clear();
+
+ for (int i = 0; i < _amiiboList.Count; i++)
+ {
+ if (!_amiiboSeries.Contains(_amiiboList[i].AmiiboSeries))
+ {
+ if (!ShowAllAmiibo)
+ {
+ foreach (AmiiboApiGamesSwitch game in _amiiboList[i].GamesSwitch)
+ {
+ if (game != null)
+ {
+ if (game.GameId.Contains(TitleId))
+ {
+ AmiiboSeries.Add(_amiiboList[i].AmiiboSeries);
+
+ break;
+ }
+ }
+ }
+ }
+ else
+ {
+ AmiiboSeries.Add(_amiiboList[i].AmiiboSeries);
+ }
+ }
+ }
+
+ if (LastScannedAmiiboId != "")
+ {
+ SelectLastScannedAmiibo();
+ }
+ else
+ {
+ SeriesSelectedIndex = 0;
+ }
+ }
+
+ private void SelectLastScannedAmiibo()
+ {
+ AmiiboApi scanned = _amiiboList.FirstOrDefault(amiibo => amiibo.GetId() == LastScannedAmiiboId);
+
+ SeriesSelectedIndex = AmiiboSeries.IndexOf(scanned.AmiiboSeries);
+ AmiiboSelectedIndex = AmiiboList.IndexOf(scanned);
+ }
+
+ private void FilterAmiibo()
+ {
+ _amiibos.Clear();
+
+ if (_seriesSelectedIndex < 0)
+ {
+ return;
+ }
+
+ List<AmiiboApi> amiiboSortedList = _amiiboList
+ .Where(amiibo => amiibo.AmiiboSeries == _amiiboSeries[SeriesSelectedIndex])
+ .OrderBy(amiibo => amiibo.Name).ToList();
+
+ for (int i = 0; i < amiiboSortedList.Count; i++)
+ {
+ if (!_amiibos.Contains(amiiboSortedList[i]))
+ {
+ if (!_showAllAmiibo)
+ {
+ foreach (AmiiboApiGamesSwitch game in amiiboSortedList[i].GamesSwitch)
+ {
+ if (game != null)
+ {
+ if (game.GameId.Contains(TitleId))
+ {
+ _amiibos.Add(amiiboSortedList[i]);
+
+ break;
+ }
+ }
+ }
+ }
+ else
+ {
+ _amiibos.Add(amiiboSortedList[i]);
+ }
+ }
+ }
+
+ AmiiboSelectedIndex = 0;
+ }
+
+ private void SetAmiiboDetails()
+ {
+ ResetAmiiboPreview();
+
+ Usage = string.Empty;
+
+ if (_amiiboSelectedIndex < 0)
+ {
+ return;
+ }
+
+ AmiiboApi selected = _amiibos[_amiiboSelectedIndex];
+
+ string imageUrl = _amiiboList.FirstOrDefault(amiibo => amiibo.Equals(selected)).Image;
+
+ string usageString = "";
+
+ for (int i = 0; i < _amiiboList.Count; i++)
+ {
+ if (_amiiboList[i].Equals(selected))
+ {
+ bool writable = false;
+
+ foreach (AmiiboApiGamesSwitch item in _amiiboList[i].GamesSwitch)
+ {
+ if (item.GameId.Contains(TitleId))
+ {
+ foreach (AmiiboApiUsage usageItem in item.AmiiboUsage)
+ {
+ usageString += Environment.NewLine +
+ $"- {usageItem.Usage.Replace("/", Environment.NewLine + "-")}";
+
+ writable = usageItem.Write;
+ }
+ }
+ }
+
+ if (usageString.Length == 0)
+ {
+ usageString = LocaleManager.Instance[LocaleKeys.Unknown] + ".";
+ }
+
+ Usage = $"{LocaleManager.Instance[LocaleKeys.Usage]} {(writable ? $" ({LocaleManager.Instance[LocaleKeys.Writable]})" : "")} : {usageString}";
+ }
+ }
+
+ _ = UpdateAmiiboPreview(imageUrl);
+ }
+
+ private async Task<bool> NeedsUpdate(DateTime oldLastModified)
+ {
+ try
+ {
+ HttpResponseMessage response =
+ await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, "https://amiibo.ryujinx.org/"));
+
+ if (response.IsSuccessStatusCode)
+ {
+ return response.Content.Headers.LastModified != oldLastModified;
+ }
+
+ return false;
+ }
+ catch (Exception ex)
+ {
+ Logger.Error?.Print(LogClass.Application, $"Failed to check for amiibo updates: {ex}");
+
+ ShowInfoDialog();
+
+ return false;
+ }
+ }
+
+ private async Task<string> DownloadAmiiboJson()
+ {
+ HttpResponseMessage response = await _httpClient.GetAsync("https://amiibo.ryujinx.org/");
+
+ if (response.IsSuccessStatusCode)
+ {
+ string amiiboJsonString = await response.Content.ReadAsStringAsync();
+
+ using (FileStream amiiboJsonStream = File.Create(_amiiboJsonPath, 4096, FileOptions.WriteThrough))
+ {
+ amiiboJsonStream.Write(Encoding.UTF8.GetBytes(amiiboJsonString));
+ }
+
+ return amiiboJsonString;
+ }
+
+ Logger.Error?.Print(LogClass.Application, $"Failed to download amiibo data. Response status code: {response.StatusCode}");
+
+ await ContentDialogHelper.CreateInfoDialog(LocaleManager.Instance[LocaleKeys.DialogAmiiboApiTitle],
+ LocaleManager.Instance[LocaleKeys.DialogAmiiboApiFailFetchMessage],
+ LocaleManager.Instance[LocaleKeys.InputDialogOk],
+ "",
+ LocaleManager.Instance[LocaleKeys.RyujinxInfo]);
+
+ Close();
+
+ return DefaultJson;
+ }
+
+ private void Close()
+ {
+ Dispatcher.UIThread.Post(_owner.Close);
+ }
+
+ private async Task UpdateAmiiboPreview(string imageUrl)
+ {
+ HttpResponseMessage response = await _httpClient.GetAsync(imageUrl);
+
+ if (response.IsSuccessStatusCode)
+ {
+ byte[] amiiboPreviewBytes = await response.Content.ReadAsByteArrayAsync();
+ using (MemoryStream memoryStream = new(amiiboPreviewBytes))
+ {
+ Bitmap bitmap = new(memoryStream);
+
+ double ratio = Math.Min(AmiiboImageSize / bitmap.Size.Width,
+ AmiiboImageSize / bitmap.Size.Height);
+
+ int resizeHeight = (int)(bitmap.Size.Height * ratio);
+ int resizeWidth = (int)(bitmap.Size.Width * ratio);
+
+ AmiiboImage = bitmap.CreateScaledBitmap(new PixelSize(resizeWidth, resizeHeight));
+ }
+ }
+ else
+ {
+ Logger.Error?.Print(LogClass.Application, $"Failed to get amiibo preview. Response status code: {response.StatusCode}");
+ }
+ }
+
+ private void ResetAmiiboPreview()
+ {
+ using (MemoryStream memoryStream = new(_amiiboLogoBytes))
+ {
+ Bitmap bitmap = new(memoryStream);
+
+ AmiiboImage = bitmap;
+ }
+ }
+
+ private async void ShowInfoDialog()
+ {
+ await ContentDialogHelper.CreateInfoDialog(LocaleManager.Instance[LocaleKeys.DialogAmiiboApiTitle],
+ LocaleManager.Instance[LocaleKeys.DialogAmiiboApiConnectErrorMessage],
+ LocaleManager.Instance[LocaleKeys.InputDialogOk],
+ "",
+ LocaleManager.Instance[LocaleKeys.RyujinxInfo]);
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/AvatarProfileViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/AvatarProfileViewModel.cs
new file mode 100644
index 00000000..b2b31014
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/AvatarProfileViewModel.cs
@@ -0,0 +1,363 @@
+using Avalonia.Media;
+using DynamicData;
+using LibHac.Common;
+using LibHac.Fs;
+using LibHac.Fs.Fsa;
+using LibHac.FsSystem;
+using LibHac.Ncm;
+using LibHac.Tools.Fs;
+using LibHac.Tools.FsSystem;
+using LibHac.Tools.FsSystem.NcaUtils;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.HLE.FileSystem;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Formats.Png;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Color = Avalonia.Media.Color;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ internal class AvatarProfileViewModel : BaseModel, IDisposable
+ {
+ private const int MaxImageTasks = 4;
+
+ private static readonly Dictionary<string, byte[]> _avatarStore = new();
+ private static bool _isPreloading;
+ private static Action _loadCompleteAction;
+
+ private ObservableCollection<ProfileImageModel> _images;
+ private Color _backgroundColor = Colors.White;
+
+ private int _selectedIndex;
+ private int _imagesLoaded;
+ private bool _isActive;
+ private byte[] _selectedImage;
+ private bool _isIndeterminate = true;
+
+ public bool IsActive
+ {
+ get => _isActive;
+ set => _isActive = value;
+ }
+
+ public AvatarProfileViewModel()
+ {
+ _images = new ObservableCollection<ProfileImageModel>();
+ }
+
+ public AvatarProfileViewModel(Action loadCompleteAction)
+ {
+ _images = new ObservableCollection<ProfileImageModel>();
+
+ if (_isPreloading)
+ {
+ _loadCompleteAction = loadCompleteAction;
+ }
+ else
+ {
+ ReloadImages();
+ }
+ }
+
+ public Color BackgroundColor
+ {
+ get => _backgroundColor;
+ set
+ {
+ _backgroundColor = value;
+
+ IsActive = false;
+
+ ReloadImages();
+ }
+ }
+
+ public ObservableCollection<ProfileImageModel> Images
+ {
+ get => _images;
+ set
+ {
+ _images = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsIndeterminate
+ {
+ get => _isIndeterminate;
+ set
+ {
+ _isIndeterminate = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int ImageCount => _avatarStore.Count;
+
+ public int ImagesLoaded
+ {
+ get => _imagesLoaded;
+ set
+ {
+ _imagesLoaded = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public int SelectedIndex
+ {
+ get => _selectedIndex;
+ set
+ {
+ _selectedIndex = value;
+
+ if (_selectedIndex == -1)
+ {
+ SelectedImage = null;
+ }
+ else
+ {
+ SelectedImage = _images[_selectedIndex].Data;
+ }
+
+ OnPropertyChanged();
+ }
+ }
+
+ public byte[] SelectedImage
+ {
+ get => _selectedImage;
+ private set => _selectedImage = value;
+ }
+
+ public void ReloadImages()
+ {
+ if (_isPreloading)
+ {
+ IsIndeterminate = false;
+ return;
+ }
+ Task.Run(() =>
+ {
+ IsActive = true;
+
+ Images.Clear();
+ int selectedIndex = _selectedIndex;
+ int index = 0;
+
+ ImagesLoaded = 0;
+ IsIndeterminate = false;
+
+ var keys = _avatarStore.Keys.ToList();
+
+ var newImages = new List<ProfileImageModel>();
+ var tasks = new List<Task>();
+
+ for (int i = 0; i < MaxImageTasks; i++)
+ {
+ var start = i;
+ tasks.Add(Task.Run(() => ImageTask(start)));
+ }
+
+ Task.WaitAll(tasks.ToArray());
+
+ Images.AddRange(newImages);
+
+ void ImageTask(int start)
+ {
+ for (int i = start; i < keys.Count; i += MaxImageTasks)
+ {
+ if (!IsActive)
+ {
+ return;
+ }
+
+ var key = keys[i];
+ var image = _avatarStore[keys[i]];
+
+ var data = ProcessImage(image);
+ newImages.Add(new ProfileImageModel(key, data));
+ if (index++ == selectedIndex)
+ {
+ SelectedImage = data;
+ }
+
+ Interlocked.Increment(ref _imagesLoaded);
+ OnPropertyChanged(nameof(ImagesLoaded));
+ }
+ }
+ });
+ }
+
+ private byte[] ProcessImage(byte[] data)
+ {
+ using (MemoryStream streamJpg = new())
+ {
+ Image avatarImage = Image.Load(data, new PngDecoder());
+
+ avatarImage.Mutate(x => x.BackgroundColor(new Rgba32(BackgroundColor.R,
+ BackgroundColor.G,
+ BackgroundColor.B,
+ BackgroundColor.A)));
+ avatarImage.SaveAsJpeg(streamJpg);
+
+ return streamJpg.ToArray();
+ }
+ }
+
+ public static void PreloadAvatars(ContentManager contentManager, VirtualFileSystem virtualFileSystem)
+ {
+ try
+ {
+ if (_avatarStore.Count > 0)
+ {
+ return;
+ }
+
+ _isPreloading = true;
+
+ string contentPath =
+ contentManager.GetInstalledContentPath(0x010000000000080A, StorageId.BuiltInSystem,
+ NcaContentType.Data);
+ string avatarPath = virtualFileSystem.SwitchPathToSystemPath(contentPath);
+
+ if (!string.IsNullOrWhiteSpace(avatarPath))
+ {
+ using (IStorage ncaFileStream = new LocalStorage(avatarPath, FileAccess.Read, FileMode.Open))
+ {
+ Nca nca = new(virtualFileSystem.KeySet, ncaFileStream);
+ IFileSystem romfs = nca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.ErrorOnInvalid);
+
+ foreach (DirectoryEntryEx item in romfs.EnumerateEntries())
+ {
+ // TODO: Parse DatabaseInfo.bin and table.bin files for more accuracy.
+ if (item.Type == DirectoryEntryType.File && item.FullPath.Contains("chara") &&
+ item.FullPath.Contains("szs"))
+ {
+ using var file = new UniqueRef<IFile>();
+
+ romfs.OpenFile(ref file.Ref, ("/" + item.FullPath).ToU8Span(), OpenMode.Read)
+ .ThrowIfFailure();
+
+ using (MemoryStream stream = new())
+ using (MemoryStream streamPng = new())
+ {
+ file.Get.AsStream().CopyTo(stream);
+
+ stream.Position = 0;
+
+ Image avatarImage = Image.LoadPixelData<Rgba32>(DecompressYaz0(stream), 256, 256);
+
+ avatarImage.SaveAsPng(streamPng);
+
+ _avatarStore.Add(item.FullPath, streamPng.ToArray());
+ }
+ }
+ }
+ }
+ }
+ }
+ finally
+ {
+ _isPreloading = false;
+ _loadCompleteAction?.Invoke();
+ }
+ }
+
+ private static byte[] DecompressYaz0(Stream stream)
+ {
+ using (BinaryReader reader = new(stream))
+ {
+ reader.ReadInt32(); // Magic
+
+ uint decodedLength = BinaryPrimitives.ReverseEndianness(reader.ReadUInt32());
+
+ reader.ReadInt64(); // Padding
+
+ byte[] input = new byte[stream.Length - stream.Position];
+ stream.Read(input, 0, input.Length);
+
+ uint inputOffset = 0;
+
+ byte[] output = new byte[decodedLength];
+ uint outputOffset = 0;
+
+ ushort mask = 0;
+ byte header = 0;
+
+ while (outputOffset < decodedLength)
+ {
+ if ((mask >>= 1) == 0)
+ {
+ header = input[inputOffset++];
+ mask = 0x80;
+ }
+
+ if ((header & mask) != 0)
+ {
+ if (outputOffset == output.Length)
+ {
+ break;
+ }
+
+ output[outputOffset++] = input[inputOffset++];
+ }
+ else
+ {
+ byte byte1 = input[inputOffset++];
+ byte byte2 = input[inputOffset++];
+
+ uint dist = (uint)((byte1 & 0xF) << 8) | byte2;
+ uint position = outputOffset - (dist + 1);
+
+ uint length = (uint)byte1 >> 4;
+ if (length == 0)
+ {
+ length = (uint)input[inputOffset++] + 0x12;
+ }
+ else
+ {
+ length += 2;
+ }
+
+ uint gap = outputOffset - position;
+ uint nonOverlappingLength = length;
+
+ if (nonOverlappingLength > gap)
+ {
+ nonOverlappingLength = gap;
+ }
+
+ Buffer.BlockCopy(output, (int)position, output, (int)outputOffset, (int)nonOverlappingLength);
+ outputOffset += nonOverlappingLength;
+ position += nonOverlappingLength;
+ length -= nonOverlappingLength;
+
+ while (length-- > 0)
+ {
+ output[outputOffset++] = output[position++];
+ }
+ }
+ }
+
+ return output;
+ }
+ }
+
+ public void Dispose()
+ {
+ _loadCompleteAction = null;
+ IsActive = false;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/BaseModel.cs b/src/Ryujinx.Ava/UI/ViewModels/BaseModel.cs
new file mode 100644
index 00000000..5a3717fd
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/BaseModel.cs
@@ -0,0 +1,15 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class BaseModel : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler PropertyChanged;
+
+ protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/ControllerSettingsViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/ControllerSettingsViewModel.cs
new file mode 100644
index 00000000..dd261b10
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/ControllerSettingsViewModel.cs
@@ -0,0 +1,899 @@
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Svg.Skia;
+using Avalonia.Threading;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.Input;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.Ava.UI.Windows;
+using Ryujinx.Common;
+using Ryujinx.Common.Configuration;
+using Ryujinx.Common.Configuration.Hid;
+using Ryujinx.Common.Configuration.Hid.Controller;
+using Ryujinx.Common.Configuration.Hid.Controller.Motion;
+using Ryujinx.Common.Configuration.Hid.Keyboard;
+using Ryujinx.Common.Logging;
+using Ryujinx.Common.Utilities;
+using Ryujinx.Input;
+using Ryujinx.Ui.Common.Configuration;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using ConfigGamepadInputId = Ryujinx.Common.Configuration.Hid.Controller.GamepadInputId;
+using ConfigStickInputId = Ryujinx.Common.Configuration.Hid.Controller.StickInputId;
+using Key = Ryujinx.Common.Configuration.Hid.Key;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class ControllerSettingsViewModel : BaseModel, IDisposable
+ {
+ private const string Disabled = "disabled";
+ private const string ProControllerResource = "Ryujinx.Ui.Common/Resources/Controller_ProCon.svg";
+ private const string JoyConPairResource = "Ryujinx.Ui.Common/Resources/Controller_JoyConPair.svg";
+ private const string JoyConLeftResource = "Ryujinx.Ui.Common/Resources/Controller_JoyConLeft.svg";
+ private const string JoyConRightResource = "Ryujinx.Ui.Common/Resources/Controller_JoyConRight.svg";
+ private const string KeyboardString = "keyboard";
+ private const string ControllerString = "controller";
+ private readonly MainWindow _mainWindow;
+
+ private PlayerIndex _playerId;
+ private int _controller;
+ private int _controllerNumber = 0;
+ private string _controllerImage;
+ private int _device;
+ private object _configuration;
+ private string _profileName;
+ private bool _isLoaded;
+ private readonly UserControl _owner;
+
+ private static readonly InputConfigJsonSerializerContext SerializerContext = new(JsonHelper.GetDefaultSerializerOptions());
+
+ public IGamepadDriver AvaloniaKeyboardDriver { get; }
+ public IGamepad SelectedGamepad { get; private set; }
+
+ public ObservableCollection<PlayerModel> PlayerIndexes { get; set; }
+ public ObservableCollection<(DeviceType Type, string Id, string Name)> Devices { get; set; }
+ internal ObservableCollection<ControllerModel> Controllers { get; set; }
+ public AvaloniaList<string> ProfilesList { get; set; }
+ public AvaloniaList<string> DeviceList { get; set; }
+
+ // XAML Flags
+ public bool ShowSettings => _device > 0;
+ public bool IsController => _device > 1;
+ public bool IsKeyboard => !IsController;
+ public bool IsRight { get; set; }
+ public bool IsLeft { get; set; }
+
+ public bool IsModified { get; set; }
+
+ public object Configuration
+ {
+ get => _configuration;
+ set
+ {
+ _configuration = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public PlayerIndex PlayerId
+ {
+ get => _playerId;
+ set
+ {
+ if (IsModified)
+ {
+ return;
+ }
+
+ IsModified = false;
+ _playerId = value;
+
+ if (!Enum.IsDefined(typeof(PlayerIndex), _playerId))
+ {
+ _playerId = PlayerIndex.Player1;
+ }
+
+ LoadConfiguration();
+ LoadDevice();
+ LoadProfiles();
+
+ _isLoaded = true;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int Controller
+ {
+ get => _controller;
+ set
+ {
+ _controller = value;
+
+ if (_controller == -1)
+ {
+ _controller = 0;
+ }
+
+ if (Controllers.Count > 0 && value < Controllers.Count && _controller > -1)
+ {
+ ControllerType controller = Controllers[_controller].Type;
+
+ IsLeft = true;
+ IsRight = true;
+
+ switch (controller)
+ {
+ case ControllerType.Handheld:
+ ControllerImage = JoyConPairResource;
+ break;
+ case ControllerType.ProController:
+ ControllerImage = ProControllerResource;
+ break;
+ case ControllerType.JoyconPair:
+ ControllerImage = JoyConPairResource;
+ break;
+ case ControllerType.JoyconLeft:
+ ControllerImage = JoyConLeftResource;
+ IsRight = false;
+ break;
+ case ControllerType.JoyconRight:
+ ControllerImage = JoyConRightResource;
+ IsLeft = false;
+ break;
+ }
+
+ LoadInputDriver();
+ LoadProfiles();
+ }
+
+ OnPropertyChanged();
+ NotifyChanges();
+ }
+ }
+
+ public string ControllerImage
+ {
+ get => _controllerImage;
+ set
+ {
+ _controllerImage = value;
+
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(Image));
+ }
+ }
+
+ public SvgImage Image
+ {
+ get
+ {
+ SvgImage image = new SvgImage();
+
+ if (!string.IsNullOrWhiteSpace(_controllerImage))
+ {
+ SvgSource source = new SvgSource();
+
+ source.Load(EmbeddedResources.GetStream(_controllerImage));
+
+ image.Source = source;
+ }
+
+ return image;
+ }
+ }
+
+ public string ProfileName
+ {
+ get => _profileName; set
+ {
+ _profileName = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int Device
+ {
+ get => _device;
+ set
+ {
+ _device = value < 0 ? 0 : value;
+
+ if (_device >= Devices.Count)
+ {
+ return;
+ }
+
+ var selected = Devices[_device].Type;
+
+ if (selected != DeviceType.None)
+ {
+ LoadControllers();
+
+ if (_isLoaded)
+ {
+ LoadConfiguration(LoadDefaultConfiguration());
+ }
+ }
+
+ OnPropertyChanged();
+ NotifyChanges();
+ }
+ }
+
+ public InputConfig Config { get; set; }
+
+ public ControllerSettingsViewModel(UserControl owner) : this()
+ {
+ _owner = owner;
+
+ if (Program.PreviewerDetached)
+ {
+ _mainWindow =
+ (MainWindow)((IClassicDesktopStyleApplicationLifetime)Avalonia.Application.Current
+ .ApplicationLifetime).MainWindow;
+
+ AvaloniaKeyboardDriver = new AvaloniaKeyboardDriver(owner);
+
+ _mainWindow.InputManager.GamepadDriver.OnGamepadConnected += HandleOnGamepadConnected;
+ _mainWindow.InputManager.GamepadDriver.OnGamepadDisconnected += HandleOnGamepadDisconnected;
+ if (_mainWindow.ViewModel.AppHost != null)
+ {
+ _mainWindow.ViewModel.AppHost.NpadManager.BlockInputUpdates();
+ }
+
+ _isLoaded = false;
+
+ LoadDevices();
+
+ PlayerId = PlayerIndex.Player1;
+ }
+ }
+
+ public ControllerSettingsViewModel()
+ {
+ PlayerIndexes = new ObservableCollection<PlayerModel>();
+ Controllers = new ObservableCollection<ControllerModel>();
+ Devices = new ObservableCollection<(DeviceType Type, string Id, string Name)>();
+ ProfilesList = new AvaloniaList<string>();
+ DeviceList = new AvaloniaList<string>();
+
+ ControllerImage = ProControllerResource;
+
+ PlayerIndexes.Add(new(PlayerIndex.Player1, LocaleManager.Instance[LocaleKeys.ControllerSettingsPlayer1]));
+ PlayerIndexes.Add(new(PlayerIndex.Player2, LocaleManager.Instance[LocaleKeys.ControllerSettingsPlayer2]));
+ PlayerIndexes.Add(new(PlayerIndex.Player3, LocaleManager.Instance[LocaleKeys.ControllerSettingsPlayer3]));
+ PlayerIndexes.Add(new(PlayerIndex.Player4, LocaleManager.Instance[LocaleKeys.ControllerSettingsPlayer4]));
+ PlayerIndexes.Add(new(PlayerIndex.Player5, LocaleManager.Instance[LocaleKeys.ControllerSettingsPlayer5]));
+ PlayerIndexes.Add(new(PlayerIndex.Player6, LocaleManager.Instance[LocaleKeys.ControllerSettingsPlayer6]));
+ PlayerIndexes.Add(new(PlayerIndex.Player7, LocaleManager.Instance[LocaleKeys.ControllerSettingsPlayer7]));
+ PlayerIndexes.Add(new(PlayerIndex.Player8, LocaleManager.Instance[LocaleKeys.ControllerSettingsPlayer8]));
+ PlayerIndexes.Add(new(PlayerIndex.Handheld, LocaleManager.Instance[LocaleKeys.ControllerSettingsHandheld]));
+ }
+
+ private void LoadConfiguration(InputConfig inputConfig = null)
+ {
+ Config = inputConfig ?? ConfigurationState.Instance.Hid.InputConfig.Value.Find(inputConfig => inputConfig.PlayerIndex == _playerId);
+
+ if (Config is StandardKeyboardInputConfig keyboardInputConfig)
+ {
+ Configuration = new InputConfiguration<Key, ConfigStickInputId>(keyboardInputConfig);
+ }
+
+ if (Config is StandardControllerInputConfig controllerInputConfig)
+ {
+ Configuration = new InputConfiguration<ConfigGamepadInputId, ConfigStickInputId>(controllerInputConfig);
+ }
+ }
+
+ public void LoadDevice()
+ {
+ if (Config == null || Config.Backend == InputBackendType.Invalid)
+ {
+ Device = 0;
+ }
+ else
+ {
+ var type = DeviceType.None;
+
+ if (Config is StandardKeyboardInputConfig)
+ {
+ type = DeviceType.Keyboard;
+ }
+
+ if (Config is StandardControllerInputConfig)
+ {
+ type = DeviceType.Controller;
+ }
+
+ var item = Devices.FirstOrDefault(x => x.Type == type && x.Id == Config.Id);
+ if (item != default)
+ {
+ Device = Devices.ToList().FindIndex(x => x.Id == item.Id);
+ }
+ else
+ {
+ Device = 0;
+ }
+ }
+ }
+
+ public async void ShowMotionConfig()
+ {
+ await MotionSettingsWindow.Show(this);
+ }
+
+ public async void ShowRumbleConfig()
+ {
+ await RumbleSettingsWindow.Show(this);
+ }
+
+ private void LoadInputDriver()
+ {
+ if (_device < 0)
+ {
+ return;
+ }
+
+ string id = GetCurrentGamepadId();
+ var type = Devices[Device].Type;
+
+ if (type == DeviceType.None)
+ {
+ return;
+ }
+ else if (type == DeviceType.Keyboard)
+ {
+ if (_mainWindow.InputManager.KeyboardDriver is AvaloniaKeyboardDriver)
+ {
+ // NOTE: To get input in this window, we need to bind a custom keyboard driver instead of using the InputManager one as the main window isn't focused...
+ SelectedGamepad = AvaloniaKeyboardDriver.GetGamepad(id);
+ }
+ else
+ {
+ SelectedGamepad = _mainWindow.InputManager.KeyboardDriver.GetGamepad(id);
+ }
+ }
+ else
+ {
+ SelectedGamepad = _mainWindow.InputManager.GamepadDriver.GetGamepad(id);
+ }
+ }
+
+ private void HandleOnGamepadDisconnected(string id)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ LoadDevices();
+ });
+ }
+
+ private void HandleOnGamepadConnected(string id)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ LoadDevices();
+ });
+ }
+
+ private string GetCurrentGamepadId()
+ {
+ if (_device < 0)
+ {
+ return string.Empty;
+ }
+
+ var device = Devices[Device];
+
+ if (device.Type == DeviceType.None)
+ {
+ return null;
+ }
+
+ return device.Id.Split(" ")[0];
+ }
+
+ public void LoadControllers()
+ {
+ Controllers.Clear();
+
+ if (_playerId == PlayerIndex.Handheld)
+ {
+ Controllers.Add(new(ControllerType.Handheld, LocaleManager.Instance[LocaleKeys.ControllerSettingsControllerTypeHandheld]));
+
+ Controller = 0;
+ }
+ else
+ {
+ Controllers.Add(new(ControllerType.ProController, LocaleManager.Instance[LocaleKeys.ControllerSettingsControllerTypeProController]));
+ Controllers.Add(new(ControllerType.JoyconPair, LocaleManager.Instance[LocaleKeys.ControllerSettingsControllerTypeJoyConPair]));
+ Controllers.Add(new(ControllerType.JoyconLeft, LocaleManager.Instance[LocaleKeys.ControllerSettingsControllerTypeJoyConLeft]));
+ Controllers.Add(new(ControllerType.JoyconRight, LocaleManager.Instance[LocaleKeys.ControllerSettingsControllerTypeJoyConRight]));
+
+ if (Config != null && Controllers.ToList().FindIndex(x => x.Type == Config.ControllerType) != -1)
+ {
+ Controller = Controllers.ToList().FindIndex(x => x.Type == Config.ControllerType);
+ }
+ else
+ {
+ Controller = 0;
+ }
+ }
+ }
+
+ private static string GetShortGamepadName(string str)
+ {
+ const string Ellipsis = "...";
+ const int MaxSize = 50;
+
+ if (str.Length > MaxSize)
+ {
+ return $"{str.AsSpan(0, MaxSize - Ellipsis.Length)}{Ellipsis}";
+ }
+
+ return str;
+ }
+
+ private static string GetShortGamepadId(string str)
+ {
+ const string Hyphen = "-";
+ const int Offset = 1;
+
+ return str.Substring(str.IndexOf(Hyphen) + Offset);
+ }
+
+ public void LoadDevices()
+ {
+ lock (Devices)
+ {
+ Devices.Clear();
+ DeviceList.Clear();
+ Devices.Add((DeviceType.None, Disabled, LocaleManager.Instance[LocaleKeys.ControllerSettingsDeviceDisabled]));
+
+ foreach (string id in _mainWindow.InputManager.KeyboardDriver.GamepadsIds)
+ {
+ using IGamepad gamepad = _mainWindow.InputManager.KeyboardDriver.GetGamepad(id);
+
+ if (gamepad != null)
+ {
+ Devices.Add((DeviceType.Keyboard, id, $"{GetShortGamepadName(gamepad.Name)}"));
+ }
+ }
+
+ foreach (string id in _mainWindow.InputManager.GamepadDriver.GamepadsIds)
+ {
+ using IGamepad gamepad = _mainWindow.InputManager.GamepadDriver.GetGamepad(id);
+
+ if (gamepad != null)
+ {
+ if (Devices.Any(controller => GetShortGamepadId(controller.Id) == GetShortGamepadId(gamepad.Id)))
+ {
+ _controllerNumber++;
+ }
+
+ Devices.Add((DeviceType.Controller, id, $"{GetShortGamepadName(gamepad.Name)} ({_controllerNumber})"));
+ }
+ }
+
+ _controllerNumber = 0;
+
+ DeviceList.AddRange(Devices.Select(x => x.Name));
+ Device = Math.Min(Device, DeviceList.Count);
+ }
+ }
+
+ private string GetProfileBasePath()
+ {
+ string path = AppDataManager.ProfilesDirPath;
+ var type = Devices[Device == -1 ? 0 : Device].Type;
+
+ if (type == DeviceType.Keyboard)
+ {
+ path = Path.Combine(path, KeyboardString);
+ }
+ else if (type == DeviceType.Controller)
+ {
+ path = Path.Combine(path, ControllerString);
+ }
+
+ return path;
+ }
+
+ private void LoadProfiles()
+ {
+ ProfilesList.Clear();
+
+ string basePath = GetProfileBasePath();
+
+ if (!Directory.Exists(basePath))
+ {
+ Directory.CreateDirectory(basePath);
+ }
+
+ ProfilesList.Add((LocaleManager.Instance[LocaleKeys.ControllerSettingsProfileDefault]));
+
+ foreach (string profile in Directory.GetFiles(basePath, "*.json", SearchOption.AllDirectories))
+ {
+ ProfilesList.Add(Path.GetFileNameWithoutExtension(profile));
+ }
+
+ if (string.IsNullOrWhiteSpace(ProfileName))
+ {
+ ProfileName = LocaleManager.Instance[LocaleKeys.ControllerSettingsProfileDefault];
+ }
+ }
+
+ public InputConfig LoadDefaultConfiguration()
+ {
+ var activeDevice = Devices.FirstOrDefault();
+
+ if (Devices.Count > 0 && Device < Devices.Count && Device >= 0)
+ {
+ activeDevice = Devices[Device];
+ }
+
+ InputConfig config;
+ if (activeDevice.Type == DeviceType.Keyboard)
+ {
+ string id = activeDevice.Id;
+
+ config = new StandardKeyboardInputConfig
+ {
+ Version = InputConfig.CurrentVersion,
+ Backend = InputBackendType.WindowKeyboard,
+ Id = id,
+ ControllerType = ControllerType.ProController,
+ LeftJoycon = new LeftJoyconCommonConfig<Key>
+ {
+ DpadUp = Key.Up,
+ DpadDown = Key.Down,
+ DpadLeft = Key.Left,
+ DpadRight = Key.Right,
+ ButtonMinus = Key.Minus,
+ ButtonL = Key.E,
+ ButtonZl = Key.Q,
+ ButtonSl = Key.Unbound,
+ ButtonSr = Key.Unbound
+ },
+ LeftJoyconStick =
+ new JoyconConfigKeyboardStick<Key>
+ {
+ StickUp = Key.W,
+ StickDown = Key.S,
+ StickLeft = Key.A,
+ StickRight = Key.D,
+ StickButton = Key.F
+ },
+ RightJoycon = new RightJoyconCommonConfig<Key>
+ {
+ ButtonA = Key.Z,
+ ButtonB = Key.X,
+ ButtonX = Key.C,
+ ButtonY = Key.V,
+ ButtonPlus = Key.Plus,
+ ButtonR = Key.U,
+ ButtonZr = Key.O,
+ ButtonSl = Key.Unbound,
+ ButtonSr = Key.Unbound
+ },
+ RightJoyconStick = new JoyconConfigKeyboardStick<Key>
+ {
+ StickUp = Key.I,
+ StickDown = Key.K,
+ StickLeft = Key.J,
+ StickRight = Key.L,
+ StickButton = Key.H
+ }
+ };
+ }
+ else if (activeDevice.Type == DeviceType.Controller)
+ {
+ bool isNintendoStyle = Devices.ToList().Find(x => x.Id == activeDevice.Id).Name.Contains("Nintendo");
+
+ string id = activeDevice.Id.Split(" ")[0];
+
+ config = new StandardControllerInputConfig
+ {
+ Version = InputConfig.CurrentVersion,
+ Backend = InputBackendType.GamepadSDL2,
+ Id = id,
+ ControllerType = ControllerType.ProController,
+ DeadzoneLeft = 0.1f,
+ DeadzoneRight = 0.1f,
+ RangeLeft = 1.0f,
+ RangeRight = 1.0f,
+ TriggerThreshold = 0.5f,
+ LeftJoycon = new LeftJoyconCommonConfig<ConfigGamepadInputId>
+ {
+ DpadUp = ConfigGamepadInputId.DpadUp,
+ DpadDown = ConfigGamepadInputId.DpadDown,
+ DpadLeft = ConfigGamepadInputId.DpadLeft,
+ DpadRight = ConfigGamepadInputId.DpadRight,
+ ButtonMinus = ConfigGamepadInputId.Minus,
+ ButtonL = ConfigGamepadInputId.LeftShoulder,
+ ButtonZl = ConfigGamepadInputId.LeftTrigger,
+ ButtonSl = ConfigGamepadInputId.Unbound,
+ ButtonSr = ConfigGamepadInputId.Unbound
+ },
+ LeftJoyconStick = new JoyconConfigControllerStick<ConfigGamepadInputId, ConfigStickInputId>
+ {
+ Joystick = ConfigStickInputId.Left,
+ StickButton = ConfigGamepadInputId.LeftStick,
+ InvertStickX = false,
+ InvertStickY = false
+ },
+ RightJoycon = new RightJoyconCommonConfig<ConfigGamepadInputId>
+ {
+ ButtonA = isNintendoStyle ? ConfigGamepadInputId.A : ConfigGamepadInputId.B,
+ ButtonB = isNintendoStyle ? ConfigGamepadInputId.B : ConfigGamepadInputId.A,
+ ButtonX = isNintendoStyle ? ConfigGamepadInputId.X : ConfigGamepadInputId.Y,
+ ButtonY = isNintendoStyle ? ConfigGamepadInputId.Y : ConfigGamepadInputId.X,
+ ButtonPlus = ConfigGamepadInputId.Plus,
+ ButtonR = ConfigGamepadInputId.RightShoulder,
+ ButtonZr = ConfigGamepadInputId.RightTrigger,
+ ButtonSl = ConfigGamepadInputId.Unbound,
+ ButtonSr = ConfigGamepadInputId.Unbound
+ },
+ RightJoyconStick = new JoyconConfigControllerStick<ConfigGamepadInputId, ConfigStickInputId>
+ {
+ Joystick = ConfigStickInputId.Right,
+ StickButton = ConfigGamepadInputId.RightStick,
+ InvertStickX = false,
+ InvertStickY = false
+ },
+ Motion = new StandardMotionConfigController
+ {
+ MotionBackend = MotionInputBackendType.GamepadDriver,
+ EnableMotion = true,
+ Sensitivity = 100,
+ GyroDeadzone = 1
+ },
+ Rumble = new RumbleConfigController
+ {
+ StrongRumble = 1f,
+ WeakRumble = 1f,
+ EnableRumble = false
+ }
+ };
+ }
+ else
+ {
+ config = new InputConfig();
+ }
+
+ config.PlayerIndex = _playerId;
+
+ return config;
+ }
+
+ public async void LoadProfile()
+ {
+ if (Device == 0)
+ {
+ return;
+ }
+
+ InputConfig config = null;
+
+ if (string.IsNullOrWhiteSpace(ProfileName))
+ {
+ return;
+ }
+
+ if (ProfileName == LocaleManager.Instance[LocaleKeys.ControllerSettingsProfileDefault])
+ {
+ config = LoadDefaultConfiguration();
+ }
+ else
+ {
+ string path = Path.Combine(GetProfileBasePath(), ProfileName + ".json");
+
+ if (!File.Exists(path))
+ {
+ var index = ProfilesList.IndexOf(ProfileName);
+ if (index != -1)
+ {
+ ProfilesList.RemoveAt(index);
+ }
+ return;
+ }
+
+ try
+ {
+ config = JsonHelper.DeserializeFromFile(path, SerializerContext.InputConfig);
+ }
+ catch (JsonException) { }
+ catch (InvalidOperationException)
+ {
+ Logger.Error?.Print(LogClass.Configuration, $"Profile {ProfileName} is incompatible with the current input configuration system.");
+
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogProfileInvalidProfileErrorMessage, ProfileName));
+
+ return;
+ }
+ }
+
+ if (config != null)
+ {
+ _isLoaded = false;
+
+ LoadConfiguration(config);
+
+ LoadDevice();
+
+ _isLoaded = true;
+
+ NotifyChanges();
+ }
+ }
+
+ public async void SaveProfile()
+ {
+ if (Device == 0)
+ {
+ return;
+ }
+
+ if (Configuration == null)
+ {
+ return;
+ }
+
+ if (ProfileName == LocaleManager.Instance[LocaleKeys.ControllerSettingsProfileDefault])
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogProfileDefaultProfileOverwriteErrorMessage]);
+
+ return;
+ }
+ else
+ {
+ bool validFileName = ProfileName.IndexOfAny(Path.GetInvalidFileNameChars()) == -1;
+
+ if (validFileName)
+ {
+ string path = Path.Combine(GetProfileBasePath(), ProfileName + ".json");
+
+ InputConfig config = null;
+
+ if (IsKeyboard)
+ {
+ config = (Configuration as InputConfiguration<Key, ConfigStickInputId>).GetConfig();
+ }
+ else if (IsController)
+ {
+ config = (Configuration as InputConfiguration<GamepadInputId, ConfigStickInputId>).GetConfig();
+ }
+
+ config.ControllerType = Controllers[_controller].Type;
+
+ string jsonString = JsonHelper.Serialize(config, SerializerContext.InputConfig);
+
+ await File.WriteAllTextAsync(path, jsonString);
+
+ LoadProfiles();
+ }
+ else
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogProfileInvalidProfileNameErrorMessage]);
+ }
+ }
+ }
+
+ public async void RemoveProfile()
+ {
+ if (Device == 0 || ProfileName == LocaleManager.Instance[LocaleKeys.ControllerSettingsProfileDefault] || ProfilesList.IndexOf(ProfileName) == -1)
+ {
+ return;
+ }
+
+ UserResult result = await ContentDialogHelper.CreateConfirmationDialog(
+ LocaleManager.Instance[LocaleKeys.DialogProfileDeleteProfileTitle],
+ LocaleManager.Instance[LocaleKeys.DialogProfileDeleteProfileMessage],
+ LocaleManager.Instance[LocaleKeys.InputDialogYes],
+ LocaleManager.Instance[LocaleKeys.InputDialogNo],
+ LocaleManager.Instance[LocaleKeys.RyujinxConfirm]);
+
+ if (result == UserResult.Yes)
+ {
+ string path = Path.Combine(GetProfileBasePath(), ProfileName + ".json");
+
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+
+ LoadProfiles();
+ }
+ }
+
+ public void Save()
+ {
+ IsModified = false;
+
+ List<InputConfig> newConfig = new();
+
+ newConfig.AddRange(ConfigurationState.Instance.Hid.InputConfig.Value);
+
+ newConfig.Remove(newConfig.Find(x => x == null));
+
+ if (Device == 0)
+ {
+ newConfig.Remove(newConfig.Find(x => x.PlayerIndex == this.PlayerId));
+ }
+ else
+ {
+ var device = Devices[Device];
+
+ if (device.Type == DeviceType.Keyboard)
+ {
+ var inputConfig = Configuration as InputConfiguration<Key, ConfigStickInputId>;
+ inputConfig.Id = device.Id;
+ }
+ else
+ {
+ var inputConfig = Configuration as InputConfiguration<GamepadInputId, ConfigStickInputId>;
+ inputConfig.Id = device.Id.Split(" ")[0];
+ }
+
+ var config = !IsController
+ ? (Configuration as InputConfiguration<Key, ConfigStickInputId>).GetConfig()
+ : (Configuration as InputConfiguration<GamepadInputId, ConfigStickInputId>).GetConfig();
+ config.ControllerType = Controllers[_controller].Type;
+ config.PlayerIndex = _playerId;
+
+ int i = newConfig.FindIndex(x => x.PlayerIndex == PlayerId);
+ if (i == -1)
+ {
+ newConfig.Add(config);
+ }
+ else
+ {
+ newConfig[i] = config;
+ }
+ }
+
+ _mainWindow.ViewModel.AppHost?.NpadManager.ReloadConfiguration(newConfig, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse);
+
+ // Atomically replace and signal input change.
+ // NOTE: Do not modify InputConfig.Value directly as other code depends on the on-change event.
+ ConfigurationState.Instance.Hid.InputConfig.Value = newConfig;
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ }
+
+ public void NotifyChange(string property)
+ {
+ OnPropertyChanged(property);
+ }
+
+ public void NotifyChanges()
+ {
+ OnPropertyChanged(nameof(Configuration));
+ OnPropertyChanged(nameof(IsController));
+ OnPropertyChanged(nameof(ShowSettings));
+ OnPropertyChanged(nameof(IsKeyboard));
+ OnPropertyChanged(nameof(IsRight));
+ OnPropertyChanged(nameof(IsLeft));
+ }
+
+ public void Dispose()
+ {
+ _mainWindow.InputManager.GamepadDriver.OnGamepadConnected -= HandleOnGamepadConnected;
+ _mainWindow.InputManager.GamepadDriver.OnGamepadDisconnected -= HandleOnGamepadDisconnected;
+
+ _mainWindow.ViewModel.AppHost?.NpadManager.UnblockInputUpdates();
+
+ SelectedGamepad?.Dispose();
+
+ AvaloniaKeyboardDriver.Dispose();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/DownloadableContentManagerViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/DownloadableContentManagerViewModel.cs
new file mode 100644
index 00000000..1d7da9a4
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/DownloadableContentManagerViewModel.cs
@@ -0,0 +1,338 @@
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Threading;
+using DynamicData;
+using LibHac.Common;
+using LibHac.Fs;
+using LibHac.Fs.Fsa;
+using LibHac.FsSystem;
+using LibHac.Tools.Fs;
+using LibHac.Tools.FsSystem;
+using LibHac.Tools.FsSystem.NcaUtils;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.Common.Configuration;
+using Ryujinx.Common.Logging;
+using Ryujinx.Common.Utilities;
+using Ryujinx.HLE.FileSystem;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Path = System.IO.Path;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class DownloadableContentManagerViewModel : BaseModel
+ {
+ private readonly List<DownloadableContentContainer> _downloadableContentContainerList;
+ private readonly string _downloadableContentJsonPath;
+
+ private VirtualFileSystem _virtualFileSystem;
+ private AvaloniaList<DownloadableContentModel> _downloadableContents = new();
+ private AvaloniaList<DownloadableContentModel> _views = new();
+ private AvaloniaList<DownloadableContentModel> _selectedDownloadableContents = new();
+
+ private string _search;
+ private ulong _titleId;
+ private string _titleName;
+
+ private static readonly DownloadableContentJsonSerializerContext SerializerContext = new(JsonHelper.GetDefaultSerializerOptions());
+
+ public AvaloniaList<DownloadableContentModel> DownloadableContents
+ {
+ get => _downloadableContents;
+ set
+ {
+ _downloadableContents = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(UpdateCount));
+ Sort();
+ }
+ }
+
+ public AvaloniaList<DownloadableContentModel> Views
+ {
+ get => _views;
+ set
+ {
+ _views = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public AvaloniaList<DownloadableContentModel> SelectedDownloadableContents
+ {
+ get => _selectedDownloadableContents;
+ set
+ {
+ _selectedDownloadableContents = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public string Search
+ {
+ get => _search;
+ set
+ {
+ _search = value;
+ OnPropertyChanged();
+ Sort();
+ }
+ }
+
+ public string UpdateCount
+ {
+ get => string.Format(LocaleManager.Instance[LocaleKeys.DlcWindowHeading], DownloadableContents.Count);
+ }
+
+ public DownloadableContentManagerViewModel(VirtualFileSystem virtualFileSystem, ulong titleId, string titleName)
+ {
+ _virtualFileSystem = virtualFileSystem;
+
+ _titleId = titleId;
+ _titleName = titleName;
+
+ _downloadableContentJsonPath = Path.Combine(AppDataManager.GamesDirPath, titleId.ToString("x16"), "dlc.json");
+
+ try
+ {
+ _downloadableContentContainerList = JsonHelper.DeserializeFromFile(_downloadableContentJsonPath, SerializerContext.ListDownloadableContentContainer);
+ }
+ catch
+ {
+ Logger.Error?.Print(LogClass.Configuration, "Downloadable Content JSON failed to deserialize.");
+ _downloadableContentContainerList = new List<DownloadableContentContainer>();
+ }
+
+ LoadDownloadableContents();
+ }
+
+ private void LoadDownloadableContents()
+ {
+ foreach (DownloadableContentContainer downloadableContentContainer in _downloadableContentContainerList)
+ {
+ if (File.Exists(downloadableContentContainer.ContainerPath))
+ {
+ using FileStream containerFile = File.OpenRead(downloadableContentContainer.ContainerPath);
+
+ PartitionFileSystem partitionFileSystem = new(containerFile.AsStorage());
+
+ _virtualFileSystem.ImportTickets(partitionFileSystem);
+
+ foreach (DownloadableContentNca downloadableContentNca in downloadableContentContainer.DownloadableContentNcaList)
+ {
+ using UniqueRef<IFile> ncaFile = new();
+
+ partitionFileSystem.OpenFile(ref ncaFile.Ref, downloadableContentNca.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
+
+ Nca nca = TryOpenNca(ncaFile.Get.AsStorage(), downloadableContentContainer.ContainerPath);
+ if (nca != null)
+ {
+ var content = new DownloadableContentModel(nca.Header.TitleId.ToString("X16"),
+ downloadableContentContainer.ContainerPath,
+ downloadableContentNca.FullPath,
+ downloadableContentNca.Enabled);
+
+ DownloadableContents.Add(content);
+
+ if (content.Enabled)
+ {
+ SelectedDownloadableContents.Add(content);
+ }
+
+ OnPropertyChanged(nameof(UpdateCount));
+ }
+ }
+ }
+ }
+
+ // NOTE: Save the list again to remove leftovers.
+ Save();
+ Sort();
+ }
+
+ public void Sort()
+ {
+ DownloadableContents.AsObservableChangeSet()
+ .Filter(Filter)
+ .Bind(out var view).AsObservableList();
+
+ _views.Clear();
+ _views.AddRange(view);
+ OnPropertyChanged(nameof(Views));
+ }
+
+ private bool Filter(object arg)
+ {
+ if (arg is DownloadableContentModel content)
+ {
+ return string.IsNullOrWhiteSpace(_search) || content.FileName.ToLower().Contains(_search.ToLower()) || content.TitleId.ToLower().Contains(_search.ToLower());
+ }
+
+ return false;
+ }
+
+ private Nca TryOpenNca(IStorage ncaStorage, string containerPath)
+ {
+ try
+ {
+ return new Nca(_virtualFileSystem.KeySet, ncaStorage);
+ }
+ catch (Exception ex)
+ {
+ Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ await ContentDialogHelper.CreateErrorDialog(string.Format(LocaleManager.Instance[LocaleKeys.DialogLoadNcaErrorMessage], ex.Message, containerPath));
+ });
+ }
+
+ return null;
+ }
+
+ public async void Add()
+ {
+ OpenFileDialog dialog = new OpenFileDialog()
+ {
+ Title = LocaleManager.Instance[LocaleKeys.SelectDlcDialogTitle],
+ AllowMultiple = true
+ };
+
+ dialog.Filters.Add(new FileDialogFilter
+ {
+ Name = "NSP",
+ Extensions = { "nsp" }
+ });
+
+ if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ string[] files = await dialog.ShowAsync(desktop.MainWindow);
+
+ if (files != null)
+ {
+ foreach (string file in files)
+ {
+ await AddDownloadableContent(file);
+ }
+ }
+ }
+ }
+
+ private async Task AddDownloadableContent(string path)
+ {
+ if (!File.Exists(path) || DownloadableContents.FirstOrDefault(x => x.ContainerPath == path) != null)
+ {
+ return;
+ }
+
+ using FileStream containerFile = File.OpenRead(path);
+
+ PartitionFileSystem partitionFileSystem = new(containerFile.AsStorage());
+ bool containsDownloadableContent = false;
+
+ _virtualFileSystem.ImportTickets(partitionFileSystem);
+
+ foreach (DirectoryEntryEx fileEntry in partitionFileSystem.EnumerateEntries("/", "*.nca"))
+ {
+ using var ncaFile = new UniqueRef<IFile>();
+
+ partitionFileSystem.OpenFile(ref ncaFile.Ref, fileEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
+
+ Nca nca = TryOpenNca(ncaFile.Get.AsStorage(), path);
+ if (nca == null)
+ {
+ continue;
+ }
+
+ if (nca.Header.ContentType == NcaContentType.PublicData)
+ {
+ if ((nca.Header.TitleId & 0xFFFFFFFFFFFFE000) != _titleId)
+ {
+ break;
+ }
+
+ var content = new DownloadableContentModel(nca.Header.TitleId.ToString("X16"), path, fileEntry.FullPath, true);
+ DownloadableContents.Add(content);
+ SelectedDownloadableContents.Add(content);
+
+ OnPropertyChanged(nameof(UpdateCount));
+ Sort();
+
+ containsDownloadableContent = true;
+ }
+ }
+
+ if (!containsDownloadableContent)
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogDlcNoDlcErrorMessage]);
+ }
+ }
+
+ public void Remove(DownloadableContentModel model)
+ {
+ DownloadableContents.Remove(model);
+ OnPropertyChanged(nameof(UpdateCount));
+ Sort();
+ }
+
+ public void RemoveAll()
+ {
+ DownloadableContents.Clear();
+ OnPropertyChanged(nameof(UpdateCount));
+ Sort();
+ }
+
+ public void EnableAll()
+ {
+ SelectedDownloadableContents = new(DownloadableContents);
+ }
+
+ public void DisableAll()
+ {
+ SelectedDownloadableContents.Clear();
+ }
+
+ public void Save()
+ {
+ _downloadableContentContainerList.Clear();
+
+ DownloadableContentContainer container = default;
+
+ foreach (DownloadableContentModel downloadableContent in DownloadableContents)
+ {
+ if (container.ContainerPath != downloadableContent.ContainerPath)
+ {
+ if (!string.IsNullOrWhiteSpace(container.ContainerPath))
+ {
+ _downloadableContentContainerList.Add(container);
+ }
+
+ container = new DownloadableContentContainer
+ {
+ ContainerPath = downloadableContent.ContainerPath,
+ DownloadableContentNcaList = new List<DownloadableContentNca>()
+ };
+ }
+
+ container.DownloadableContentNcaList.Add(new DownloadableContentNca
+ {
+ Enabled = downloadableContent.Enabled,
+ TitleId = Convert.ToUInt64(downloadableContent.TitleId, 16),
+ FullPath = downloadableContent.FullPath
+ });
+ }
+
+ if (!string.IsNullOrWhiteSpace(container.ContainerPath))
+ {
+ _downloadableContentContainerList.Add(container);
+ }
+
+ JsonHelper.SerializeToFile(_downloadableContentJsonPath, _downloadableContentContainerList, SerializerContext.ListDownloadableContentContainer);
+ }
+
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 00000000..14d7a0fe
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,1907 @@
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Threading;
+using DynamicData;
+using DynamicData.Binding;
+using LibHac.Common;
+using LibHac.Fs;
+using LibHac.Tools.FsSystem.NcaUtils;
+using Ryujinx.Ava.Common;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.Input;
+using Ryujinx.Ava.UI.Controls;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.Ava.UI.Renderer;
+using Ryujinx.Ava.UI.Windows;
+using Ryujinx.Common;
+using Ryujinx.Common.Configuration;
+using Ryujinx.Common.Logging;
+using Ryujinx.Cpu;
+using Ryujinx.HLE;
+using Ryujinx.HLE.FileSystem;
+using Ryujinx.HLE.HOS;
+using Ryujinx.HLE.HOS.Services.Account.Acc;
+using Ryujinx.HLE.Ui;
+using Ryujinx.Ui.App.Common;
+using Ryujinx.Ui.Common;
+using Ryujinx.Ui.Common.Configuration;
+using Ryujinx.Ui.Common.Helper;
+using SixLabors.ImageSharp.PixelFormats;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Path = System.IO.Path;
+using ShaderCacheLoadingState = Ryujinx.Graphics.Gpu.Shader.ShaderCacheState;
+using UserId = LibHac.Fs.UserId;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class MainWindowViewModel : BaseModel
+ {
+ private const int HotKeyPressDelayMs = 500;
+
+ private ObservableCollection<ApplicationData> _applications;
+ private string _aspectStatusText;
+
+ private string _loadHeading;
+ private string _cacheLoadStatus;
+ private string _searchText;
+ private Timer _searchTimer;
+ private string _dockedStatusText;
+ private string _fifoStatusText;
+ private string _gameStatusText;
+ private string _volumeStatusText;
+ private string _gpuStatusText;
+ private bool _isAmiiboRequested;
+ private bool _isGameRunning;
+ private bool _isFullScreen;
+ private int _progressMaximum;
+ private int _progressValue;
+ private long _lastFullscreenToggle = Environment.TickCount64;
+ private bool _showLoadProgress;
+ private bool _showMenuAndStatusBar = true;
+ private bool _showStatusSeparator;
+ private Brush _progressBarForegroundColor;
+ private Brush _progressBarBackgroundColor;
+ private Brush _vsyncColor;
+ private byte[] _selectedIcon;
+ private bool _isAppletMenuActive;
+ private int _statusBarProgressMaximum;
+ private int _statusBarProgressValue;
+ private bool _isPaused;
+ private bool _showContent = true;
+ private bool _isLoadingIndeterminate = true;
+ private bool _showAll;
+ private string _lastScannedAmiiboId;
+ private bool _statusBarVisible;
+ private ReadOnlyObservableCollection<ApplicationData> _appsObservableList;
+
+ private string _showUiKey = "F4";
+ private string _pauseKey = "F5";
+ private string _screenshotKey = "F8";
+ private float _volume;
+ private string _backendText;
+
+ private bool _canUpdate = true;
+ private Cursor _cursor;
+ private string _title;
+ private string _currentEmulatedGamePath;
+ private AutoResetEvent _rendererWaitEvent;
+ private WindowState _windowState;
+ private bool _isActive;
+
+ public ApplicationData ListSelectedApplication;
+ public ApplicationData GridSelectedApplication;
+
+ public event Action ReloadGameList;
+
+ private string TitleName { get; set; }
+ internal AppHost AppHost { get; set; }
+
+ public MainWindowViewModel()
+ {
+ Applications = new ObservableCollection<ApplicationData>();
+
+ Applications.ToObservableChangeSet()
+ .Filter(Filter)
+ .Sort(GetComparer())
+ .Bind(out _appsObservableList).AsObservableList();
+
+ _rendererWaitEvent = new AutoResetEvent(false);
+
+ if (Program.PreviewerDetached)
+ {
+ LoadConfigurableHotKeys();
+
+ Volume = ConfigurationState.Instance.System.AudioVolume;
+ }
+ }
+
+ public void Initialize(
+ ContentManager contentManager,
+ ApplicationLibrary applicationLibrary,
+ VirtualFileSystem virtualFileSystem,
+ AccountManager accountManager,
+ Ryujinx.Input.HLE.InputManager inputManager,
+ UserChannelPersistence userChannelPersistence,
+ LibHacHorizonManager libHacHorizonManager,
+ IHostUiHandler uiHandler,
+ Action<bool> showLoading,
+ Action<bool> switchToGameControl,
+ Action<Control> setMainContent,
+ TopLevel topLevel)
+ {
+ ContentManager = contentManager;
+ ApplicationLibrary = applicationLibrary;
+ VirtualFileSystem = virtualFileSystem;
+ AccountManager = accountManager;
+ InputManager = inputManager;
+ UserChannelPersistence = userChannelPersistence;
+ LibHacHorizonManager = libHacHorizonManager;
+ UiHandler = uiHandler;
+
+ ShowLoading = showLoading;
+ SwitchToGameControl = switchToGameControl;
+ SetMainContent = setMainContent;
+ TopLevel = topLevel;
+ }
+
+#region Properties
+
+ public string SearchText
+ {
+ get => _searchText;
+ set
+ {
+ _searchText = value;
+
+ _searchTimer?.Dispose();
+
+ _searchTimer = new Timer(TimerCallback, null, 1000, 0);
+ }
+ }
+
+ private void TimerCallback(object obj)
+ {
+ RefreshView();
+
+ _searchTimer.Dispose();
+ _searchTimer = null;
+ }
+
+ public bool CanUpdate
+ {
+ get => _canUpdate && EnableNonGameRunningControls && Modules.Updater.CanUpdate(false);
+ set
+ {
+ _canUpdate = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public Cursor Cursor
+ {
+ get => _cursor;
+ set
+ {
+ _cursor = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public ReadOnlyObservableCollection<ApplicationData> AppsObservableList
+ {
+ get => _appsObservableList;
+ set
+ {
+ _appsObservableList = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsPaused
+ {
+ get => _isPaused;
+ set
+ {
+ _isPaused = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public long LastFullscreenToggle
+ {
+ get => _lastFullscreenToggle;
+ set
+ {
+ _lastFullscreenToggle = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool StatusBarVisible
+ {
+ get => _statusBarVisible && EnableNonGameRunningControls;
+ set
+ {
+ _statusBarVisible = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool EnableNonGameRunningControls => !IsGameRunning;
+
+ public bool ShowFirmwareStatus => !ShowLoadProgress;
+
+ public bool IsGameRunning
+ {
+ get => _isGameRunning;
+ set
+ {
+ _isGameRunning = value;
+
+ if (!value)
+ {
+ ShowMenuAndStatusBar = false;
+ }
+
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(EnableNonGameRunningControls));
+ OnPropertyChanged(nameof(StatusBarVisible));
+ OnPropertyChanged(nameof(ShowFirmwareStatus));
+ }
+ }
+
+ public bool IsAmiiboRequested
+ {
+ get => _isAmiiboRequested && _isGameRunning;
+ set
+ {
+ _isAmiiboRequested = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool ShowLoadProgress
+ {
+ get => _showLoadProgress;
+ set
+ {
+ _showLoadProgress = value;
+
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(ShowFirmwareStatus));
+ }
+ }
+
+ public string GameStatusText
+ {
+ get => _gameStatusText;
+ set
+ {
+ _gameStatusText = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsFullScreen
+ {
+ get => _isFullScreen;
+ set
+ {
+ _isFullScreen = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool ShowAll
+ {
+ get => _showAll;
+ set
+ {
+ _showAll = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string LastScannedAmiiboId
+ {
+ get => _lastScannedAmiiboId;
+ set
+ {
+ _lastScannedAmiiboId = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public ApplicationData SelectedApplication
+ {
+ get
+ {
+ return Glyph switch
+ {
+ Glyph.List => ListSelectedApplication,
+ Glyph.Grid => GridSelectedApplication,
+ _ => null,
+ };
+ }
+ }
+
+ public bool EnabledUserSaveDirectory => !Utilities.IsZeros(SelectedApplication.ControlHolder.ByteSpan) && SelectedApplication.ControlHolder.Value.UserAccountSaveDataSize > 0;
+
+ public bool EnabledDeviceSaveDirectory => !Utilities.IsZeros(SelectedApplication.ControlHolder.ByteSpan) && SelectedApplication.ControlHolder.Value.DeviceSaveDataSize > 0;
+
+ public bool EnabledBcatSaveDirectory => !Utilities.IsZeros(SelectedApplication.ControlHolder.ByteSpan) && SelectedApplication.ControlHolder.Value.BcatDeliveryCacheStorageSize > 0;
+
+ public string LoadHeading
+ {
+ get => _loadHeading;
+ set
+ {
+ _loadHeading = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string CacheLoadStatus
+ {
+ get => _cacheLoadStatus;
+ set
+ {
+ _cacheLoadStatus = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public Brush ProgressBarBackgroundColor
+ {
+ get => _progressBarBackgroundColor;
+ set
+ {
+ _progressBarBackgroundColor = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public Brush ProgressBarForegroundColor
+ {
+ get => _progressBarForegroundColor;
+ set
+ {
+ _progressBarForegroundColor = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public Brush VsyncColor
+ {
+ get => _vsyncColor;
+ set
+ {
+ _vsyncColor = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public byte[] SelectedIcon
+ {
+ get => _selectedIcon;
+ set
+ {
+ _selectedIcon = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int ProgressMaximum
+ {
+ get => _progressMaximum;
+ set
+ {
+ _progressMaximum = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int ProgressValue
+ {
+ get => _progressValue;
+ set
+ {
+ _progressValue = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int StatusBarProgressMaximum
+ {
+ get => _statusBarProgressMaximum;
+ set
+ {
+ _statusBarProgressMaximum = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int StatusBarProgressValue
+ {
+ get => _statusBarProgressValue;
+ set
+ {
+ _statusBarProgressValue = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string FifoStatusText
+ {
+ get => _fifoStatusText;
+ set
+ {
+ _fifoStatusText = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string GpuNameText
+ {
+ get => _gpuStatusText;
+ set
+ {
+ _gpuStatusText = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string BackendText
+ {
+ get => _backendText;
+ set
+ {
+ _backendText = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string DockedStatusText
+ {
+ get => _dockedStatusText;
+ set
+ {
+ _dockedStatusText = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string AspectRatioStatusText
+ {
+ get => _aspectStatusText;
+ set
+ {
+ _aspectStatusText = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string VolumeStatusText
+ {
+ get => _volumeStatusText;
+ set
+ {
+ _volumeStatusText = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool VolumeMuted => _volume == 0;
+
+ public float Volume
+ {
+ get => _volume;
+ set
+ {
+ _volume = value;
+
+ if (_isGameRunning)
+ {
+ AppHost.Device.SetVolume(_volume);
+ }
+
+ OnPropertyChanged(nameof(VolumeStatusText));
+ OnPropertyChanged(nameof(VolumeMuted));
+ OnPropertyChanged();
+ }
+ }
+
+ public bool ShowStatusSeparator
+ {
+ get => _showStatusSeparator;
+ set
+ {
+ _showStatusSeparator = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool ShowMenuAndStatusBar
+ {
+ get => _showMenuAndStatusBar;
+ set
+ {
+ _showMenuAndStatusBar = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsLoadingIndeterminate
+ {
+ get => _isLoadingIndeterminate;
+ set
+ {
+ _isLoadingIndeterminate = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsActive
+ {
+ get => _isActive;
+ set
+ {
+ _isActive = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+
+ public bool ShowContent
+ {
+ get => _showContent;
+ set
+ {
+ _showContent = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsAppletMenuActive
+ {
+ get => _isAppletMenuActive && EnableNonGameRunningControls;
+ set
+ {
+ _isAppletMenuActive = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public WindowState WindowState
+ {
+ get => _windowState;
+ internal set
+ {
+ _windowState = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsGrid => Glyph == Glyph.Grid;
+ public bool IsList => Glyph == Glyph.List;
+
+ internal void Sort(bool isAscending)
+ {
+ IsAscending = isAscending;
+
+ RefreshView();
+ }
+
+ internal void Sort(ApplicationSort sort)
+ {
+ SortMode = sort;
+
+ RefreshView();
+ }
+
+ public bool StartGamesInFullscreen
+ {
+ get => ConfigurationState.Instance.Ui.StartFullscreen;
+ set
+ {
+ ConfigurationState.Instance.Ui.StartFullscreen.Value = value;
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool ShowConsole
+ {
+ get => ConfigurationState.Instance.Ui.ShowConsole;
+ set
+ {
+ ConfigurationState.Instance.Ui.ShowConsole.Value = value;
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string Title
+ {
+ get => _title;
+ set
+ {
+ _title = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool ShowConsoleVisible
+ {
+ get => ConsoleHelper.SetConsoleWindowStateSupported;
+ }
+
+ public bool ManageFileTypesVisible
+ {
+ get => FileAssociationHelper.IsTypeAssociationSupported;
+ }
+
+ public ObservableCollection<ApplicationData> Applications
+ {
+ get => _applications;
+ set
+ {
+ _applications = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public Glyph Glyph
+ {
+ get => (Glyph)ConfigurationState.Instance.Ui.GameListViewMode.Value;
+ set
+ {
+ ConfigurationState.Instance.Ui.GameListViewMode.Value = (int)value;
+
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(IsGrid));
+ OnPropertyChanged(nameof(IsList));
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ }
+ }
+
+ public bool ShowNames
+ {
+ get => ConfigurationState.Instance.Ui.ShowNames && ConfigurationState.Instance.Ui.GridSize > 1; set
+ {
+ ConfigurationState.Instance.Ui.ShowNames.Value = value;
+
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(GridSizeScale));
+ OnPropertyChanged(nameof(GridItemSelectorSize));
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ }
+ }
+
+ internal ApplicationSort SortMode
+ {
+ get => (ApplicationSort)ConfigurationState.Instance.Ui.ApplicationSort.Value;
+ private set
+ {
+ ConfigurationState.Instance.Ui.ApplicationSort.Value = (int)value;
+
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(SortName));
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ }
+ }
+
+ public int ListItemSelectorSize
+ {
+ get
+ {
+ return ConfigurationState.Instance.Ui.GridSize.Value switch
+ {
+ 1 => 78,
+ 2 => 100,
+ 3 => 120,
+ 4 => 140,
+ _ => 16,
+ };
+ }
+ }
+
+ public int GridItemSelectorSize
+ {
+ get
+ {
+ return ConfigurationState.Instance.Ui.GridSize.Value switch
+ {
+ 1 => 120,
+ 2 => ShowNames ? 210 : 150,
+ 3 => ShowNames ? 240 : 180,
+ 4 => ShowNames ? 280 : 220,
+ _ => 16,
+ };
+ }
+ }
+
+ public int GridSizeScale
+ {
+ get => ConfigurationState.Instance.Ui.GridSize;
+ set
+ {
+ ConfigurationState.Instance.Ui.GridSize.Value = value;
+
+ if (value < 2)
+ {
+ ShowNames = false;
+ }
+
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(IsGridSmall));
+ OnPropertyChanged(nameof(IsGridMedium));
+ OnPropertyChanged(nameof(IsGridLarge));
+ OnPropertyChanged(nameof(IsGridHuge));
+ OnPropertyChanged(nameof(ListItemSelectorSize));
+ OnPropertyChanged(nameof(GridItemSelectorSize));
+ OnPropertyChanged(nameof(ShowNames));
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ }
+ }
+
+ public string SortName
+ {
+ get
+ {
+ return SortMode switch
+ {
+ ApplicationSort.Title => LocaleManager.Instance[LocaleKeys.GameListHeaderApplication],
+ ApplicationSort.Developer => LocaleManager.Instance[LocaleKeys.GameListHeaderDeveloper],
+ ApplicationSort.LastPlayed => LocaleManager.Instance[LocaleKeys.GameListHeaderLastPlayed],
+ ApplicationSort.TotalTimePlayed => LocaleManager.Instance[LocaleKeys.GameListHeaderTimePlayed],
+ ApplicationSort.FileType => LocaleManager.Instance[LocaleKeys.GameListHeaderFileExtension],
+ ApplicationSort.FileSize => LocaleManager.Instance[LocaleKeys.GameListHeaderFileSize],
+ ApplicationSort.Path => LocaleManager.Instance[LocaleKeys.GameListHeaderPath],
+ ApplicationSort.Favorite => LocaleManager.Instance[LocaleKeys.CommonFavorite],
+ _ => string.Empty,
+ };
+ }
+ }
+
+ public bool IsAscending
+ {
+ get => ConfigurationState.Instance.Ui.IsAscendingOrder;
+ private set
+ {
+ ConfigurationState.Instance.Ui.IsAscendingOrder.Value = value;
+
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(SortMode));
+ OnPropertyChanged(nameof(SortName));
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ }
+ }
+
+ public KeyGesture ShowUiKey
+ {
+ get => KeyGesture.Parse(_showUiKey);
+ set
+ {
+ _showUiKey = value.ToString();
+
+ OnPropertyChanged();
+ }
+ }
+
+ public KeyGesture ScreenshotKey
+ {
+ get => KeyGesture.Parse(_screenshotKey);
+ set
+ {
+ _screenshotKey = value.ToString();
+
+ OnPropertyChanged();
+ }
+ }
+
+ public KeyGesture PauseKey
+ {
+ get => KeyGesture.Parse(_pauseKey); set
+ {
+ _pauseKey = value.ToString();
+
+ OnPropertyChanged();
+ }
+ }
+
+ public ContentManager ContentManager { get; private set; }
+ public ApplicationLibrary ApplicationLibrary { get; private set; }
+ public VirtualFileSystem VirtualFileSystem { get; private set; }
+ public AccountManager AccountManager { get; private set; }
+ public Ryujinx.Input.HLE.InputManager InputManager { get; private set; }
+ public UserChannelPersistence UserChannelPersistence { get; private set; }
+ public Action<bool> ShowLoading { get; private set; }
+ public Action<bool> SwitchToGameControl { get; private set; }
+ public Action<Control> SetMainContent { get; private set; }
+ public TopLevel TopLevel { get; private set; }
+ public RendererHost RendererHostControl { get; private set; }
+ public bool IsClosing { get; set; }
+ public LibHacHorizonManager LibHacHorizonManager { get; internal set; }
+ public IHostUiHandler UiHandler { get; internal set; }
+ public bool IsSortedByFavorite => SortMode == ApplicationSort.Favorite;
+ public bool IsSortedByTitle => SortMode == ApplicationSort.Title;
+ public bool IsSortedByDeveloper => SortMode == ApplicationSort.Developer;
+ public bool IsSortedByLastPlayed => SortMode == ApplicationSort.LastPlayed;
+ public bool IsSortedByTimePlayed => SortMode == ApplicationSort.TotalTimePlayed;
+ public bool IsSortedByType => SortMode == ApplicationSort.FileType;
+ public bool IsSortedBySize => SortMode == ApplicationSort.FileSize;
+ public bool IsSortedByPath => SortMode == ApplicationSort.Path;
+ public bool IsGridSmall => ConfigurationState.Instance.Ui.GridSize == 1;
+ public bool IsGridMedium => ConfigurationState.Instance.Ui.GridSize == 2;
+ public bool IsGridLarge => ConfigurationState.Instance.Ui.GridSize == 3;
+ public bool IsGridHuge => ConfigurationState.Instance.Ui.GridSize == 4;
+
+#endregion
+
+#region PrivateMethods
+
+ private IComparer<ApplicationData> GetComparer()
+ {
+ return SortMode switch
+ {
+ ApplicationSort.LastPlayed => new Models.Generic.LastPlayedSortComparer(IsAscending),
+ ApplicationSort.FileSize => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.FileSizeBytes)
+ : SortExpressionComparer<ApplicationData>.Descending(app => app.FileSizeBytes),
+ ApplicationSort.TotalTimePlayed => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.TimePlayedNum)
+ : SortExpressionComparer<ApplicationData>.Descending(app => app.TimePlayedNum),
+ ApplicationSort.Title => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.TitleName)
+ : SortExpressionComparer<ApplicationData>.Descending(app => app.TitleName),
+ ApplicationSort.Favorite => !IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.Favorite)
+ : SortExpressionComparer<ApplicationData>.Descending(app => app.Favorite),
+ ApplicationSort.Developer => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.Developer)
+ : SortExpressionComparer<ApplicationData>.Descending(app => app.Developer),
+ ApplicationSort.FileType => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.FileExtension)
+ : SortExpressionComparer<ApplicationData>.Descending(app => app.FileExtension),
+ ApplicationSort.Path => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.Path)
+ : SortExpressionComparer<ApplicationData>.Descending(app => app.Path),
+ _ => null,
+ };
+ }
+
+ private void RefreshView()
+ {
+ RefreshGrid();
+ }
+
+ private void RefreshGrid()
+ {
+ Applications.ToObservableChangeSet()
+ .Filter(Filter)
+ .Sort(GetComparer())
+ .Bind(out _appsObservableList).AsObservableList();
+
+ OnPropertyChanged(nameof(AppsObservableList));
+ }
+
+ private bool Filter(object arg)
+ {
+ if (arg is ApplicationData app)
+ {
+ return string.IsNullOrWhiteSpace(_searchText) || app.TitleName.ToLower().Contains(_searchText.ToLower());
+ }
+
+ return false;
+ }
+
+ private async Task HandleFirmwareInstallation(string filename)
+ {
+ try
+ {
+ SystemVersion firmwareVersion = ContentManager.VerifyFirmwarePackage(filename);
+
+ if (firmwareVersion == null)
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogFirmwareInstallerFirmwareNotFoundErrorMessage, filename));
+
+ return;
+ }
+
+ string dialogTitle = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogFirmwareInstallerFirmwareInstallTitle, firmwareVersion.VersionString);
+ string dialogMessage = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogFirmwareInstallerFirmwareInstallMessage, firmwareVersion.VersionString);
+
+ SystemVersion currentVersion = ContentManager.GetCurrentFirmwareVersion();
+ if (currentVersion != null)
+ {
+ dialogMessage += LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogFirmwareInstallerFirmwareInstallSubMessage, currentVersion.VersionString);
+ }
+
+ dialogMessage += LocaleManager.Instance[LocaleKeys.DialogFirmwareInstallerFirmwareInstallConfirmMessage];
+
+ UserResult result = await ContentDialogHelper.CreateConfirmationDialog(
+ dialogTitle,
+ dialogMessage,
+ LocaleManager.Instance[LocaleKeys.InputDialogYes],
+ LocaleManager.Instance[LocaleKeys.InputDialogNo],
+ LocaleManager.Instance[LocaleKeys.RyujinxConfirm]);
+
+ UpdateWaitWindow waitingDialog = new(dialogTitle, LocaleManager.Instance[LocaleKeys.DialogFirmwareInstallerFirmwareInstallWaitMessage]);
+
+ if (result == UserResult.Yes)
+ {
+ Logger.Info?.Print(LogClass.Application, $"Installing firmware {firmwareVersion.VersionString}");
+
+ Thread thread = new(() =>
+ {
+ Dispatcher.UIThread.InvokeAsync(delegate
+ {
+ waitingDialog.Show();
+ });
+
+ try
+ {
+ ContentManager.InstallFirmware(filename);
+
+ Dispatcher.UIThread.InvokeAsync(async delegate
+ {
+ waitingDialog.Close();
+
+ string message = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogFirmwareInstallerFirmwareInstallSuccessMessage, firmwareVersion.VersionString);
+
+ await ContentDialogHelper.CreateInfoDialog(dialogTitle, message, LocaleManager.Instance[LocaleKeys.InputDialogOk], "", LocaleManager.Instance[LocaleKeys.RyujinxInfo]);
+
+ Logger.Info?.Print(LogClass.Application, message);
+
+ // Purge Applet Cache.
+
+ DirectoryInfo miiEditorCacheFolder = new DirectoryInfo(Path.Combine(AppDataManager.GamesDirPath, "0100000000001009", "cache"));
+
+ if (miiEditorCacheFolder.Exists)
+ {
+ miiEditorCacheFolder.Delete(true);
+ }
+ });
+ }
+ catch (Exception ex)
+ {
+ Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ waitingDialog.Close();
+
+ await ContentDialogHelper.CreateErrorDialog(ex.Message);
+ });
+ }
+ finally
+ {
+ RefreshFirmwareStatus();
+ }
+ }) { Name = "GUI.FirmwareInstallerThread" };
+
+ thread.Start();
+ }
+ }
+ catch (LibHac.Common.Keys.MissingKeyException ex)
+ {
+ if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ Logger.Error?.Print(LogClass.Application, ex.ToString());
+
+ async void Action() => await UserErrorDialog.ShowUserErrorDialog(UserError.NoKeys, (desktop.MainWindow as MainWindow));
+
+ Dispatcher.UIThread.Post(Action);
+ }
+ }
+ catch (Exception ex)
+ {
+ await ContentDialogHelper.CreateErrorDialog(ex.Message);
+ }
+ }
+
+ private void ProgressHandler<T>(T state, int current, int total) where T : Enum
+ {
+ Dispatcher.UIThread.Post((() =>
+ {
+ ProgressMaximum = total;
+ ProgressValue = current;
+
+ switch (state)
+ {
+ case LoadState ptcState:
+ CacheLoadStatus = $"{current} / {total}";
+ switch (ptcState)
+ {
+ case LoadState.Unloaded:
+ case LoadState.Loading:
+ LoadHeading = LocaleManager.Instance[LocaleKeys.CompilingPPTC];
+ IsLoadingIndeterminate = false;
+ break;
+ case LoadState.Loaded:
+ LoadHeading = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.LoadingHeading, TitleName);
+ IsLoadingIndeterminate = true;
+ CacheLoadStatus = "";
+ break;
+ }
+ break;
+ case ShaderCacheLoadingState shaderCacheState:
+ CacheLoadStatus = $"{current} / {total}";
+ switch (shaderCacheState)
+ {
+ case ShaderCacheLoadingState.Start:
+ case ShaderCacheLoadingState.Loading:
+ LoadHeading = LocaleManager.Instance[LocaleKeys.CompilingShaders];
+ IsLoadingIndeterminate = false;
+ break;
+ case ShaderCacheLoadingState.Loaded:
+ LoadHeading = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.LoadingHeading, TitleName);
+ IsLoadingIndeterminate = true;
+ CacheLoadStatus = "";
+ break;
+ }
+ break;
+ default:
+ throw new ArgumentException($"Unknown Progress Handler type {typeof(T)}");
+ }
+ }));
+ }
+
+ private async void ExtractLogo()
+ {
+ if (SelectedApplication != null)
+ {
+ await ApplicationHelper.ExtractSection(NcaSectionType.Logo, SelectedApplication.Path, SelectedApplication.TitleName);
+ }
+ }
+
+ private async void ExtractRomFs()
+ {
+ if (SelectedApplication != null)
+ {
+ await ApplicationHelper.ExtractSection(NcaSectionType.Data, SelectedApplication.Path, SelectedApplication.TitleName);
+ }
+ }
+
+ private async void ExtractExeFs()
+ {
+ if (SelectedApplication != null)
+ {
+ await ApplicationHelper.ExtractSection(NcaSectionType.Code, SelectedApplication.Path, SelectedApplication.TitleName);
+ }
+ }
+
+ private void PrepareLoadScreen()
+ {
+ using MemoryStream stream = new(SelectedIcon);
+ using var gameIconBmp = SixLabors.ImageSharp.Image.Load<Bgra32>(stream);
+
+ var dominantColor = IconColorPicker.GetFilteredColor(gameIconBmp).ToPixel<Bgra32>();
+
+ const float colorMultiple = 0.5f;
+
+ Color progressFgColor = Color.FromRgb(dominantColor.R, dominantColor.G, dominantColor.B);
+ Color progressBgColor = Color.FromRgb(
+ (byte)(dominantColor.R * colorMultiple),
+ (byte)(dominantColor.G * colorMultiple),
+ (byte)(dominantColor.B * colorMultiple));
+
+ ProgressBarForegroundColor = new SolidColorBrush(progressFgColor);
+ ProgressBarBackgroundColor = new SolidColorBrush(progressBgColor);
+ }
+
+ private void InitializeGame()
+ {
+ RendererHostControl.WindowCreated += RendererHost_Created;
+
+ AppHost.StatusUpdatedEvent += Update_StatusBar;
+ AppHost.AppExit += AppHost_AppExit;
+
+ _rendererWaitEvent.WaitOne();
+
+ AppHost?.Start();
+
+ AppHost?.DisposeContext();
+ }
+
+ private void HandleRelaunch()
+ {
+ if (UserChannelPersistence.PreviousIndex != -1 && UserChannelPersistence.ShouldRestart)
+ {
+ UserChannelPersistence.ShouldRestart = false;
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ LoadApplication(_currentEmulatedGamePath);
+ });
+ }
+ else
+ {
+ // Otherwise, clear state.
+ UserChannelPersistence = new UserChannelPersistence();
+ _currentEmulatedGamePath = null;
+ }
+ }
+
+ private void Update_StatusBar(object sender, StatusUpdatedEventArgs args)
+ {
+ if (ShowMenuAndStatusBar && !ShowLoadProgress)
+ {
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ Avalonia.Application.Current.Styles.TryGetResource(args.VSyncEnabled
+ ? "VsyncEnabled"
+ : "VsyncDisabled", out object color);
+
+ if (color is not null)
+ {
+ VsyncColor = new SolidColorBrush((Color)color);
+ }
+
+ DockedStatusText = args.DockedMode;
+ AspectRatioStatusText = args.AspectRatio;
+ GameStatusText = args.GameStatus;
+ VolumeStatusText = args.VolumeStatus;
+ FifoStatusText = args.FifoStatus;
+ GpuNameText = args.GpuName;
+ BackendText = args.GpuBackend;
+
+ ShowStatusSeparator = true;
+ });
+ }
+ }
+
+ private void RendererHost_Created(object sender, EventArgs e)
+ {
+ ShowLoading(false);
+
+ _rendererWaitEvent.Set();
+ }
+
+#endregion
+
+#region PublicMethods
+
+ public void SetUIProgressHandlers(Switch emulationContext)
+ {
+ if (emulationContext.Processes.ActiveApplication.DiskCacheLoadState != null)
+ {
+ emulationContext.Processes.ActiveApplication.DiskCacheLoadState.StateChanged -= ProgressHandler;
+ emulationContext.Processes.ActiveApplication.DiskCacheLoadState.StateChanged += ProgressHandler;
+ }
+
+ emulationContext.Gpu.ShaderCacheStateChanged -= ProgressHandler;
+ emulationContext.Gpu.ShaderCacheStateChanged += ProgressHandler;
+ }
+
+ public void LoadConfigurableHotKeys()
+ {
+ if (AvaloniaKeyboardMappingHelper.TryGetAvaKey((Ryujinx.Input.Key)ConfigurationState.Instance.Hid.Hotkeys.Value.ShowUi, out var showUiKey))
+ {
+ ShowUiKey = new KeyGesture(showUiKey);
+ }
+
+ if (AvaloniaKeyboardMappingHelper.TryGetAvaKey((Ryujinx.Input.Key)ConfigurationState.Instance.Hid.Hotkeys.Value.Screenshot, out var screenshotKey))
+ {
+ ScreenshotKey = new KeyGesture(screenshotKey);
+ }
+
+ if (AvaloniaKeyboardMappingHelper.TryGetAvaKey((Ryujinx.Input.Key)ConfigurationState.Instance.Hid.Hotkeys.Value.Pause, out var pauseKey))
+ {
+ PauseKey = new KeyGesture(pauseKey);
+ }
+ }
+
+ public void TakeScreenshot()
+ {
+ AppHost.ScreenshotRequested = true;
+ }
+
+ public void HideUi()
+ {
+ ShowMenuAndStatusBar = false;
+ }
+
+ public void SetListMode()
+ {
+ Glyph = Glyph.List;
+ }
+
+ public void SetGridMode()
+ {
+ Glyph = Glyph.Grid;
+ }
+
+ public async void InstallFirmwareFromFile()
+ {
+ if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ OpenFileDialog dialog = new() { AllowMultiple = false };
+ dialog.Filters.Add(new FileDialogFilter { Name = LocaleManager.Instance[LocaleKeys.FileDialogAllTypes], Extensions = { "xci", "zip" } });
+ dialog.Filters.Add(new FileDialogFilter { Name = "XCI", Extensions = { "xci" } });
+ dialog.Filters.Add(new FileDialogFilter { Name = "ZIP", Extensions = { "zip" } });
+
+ string[] file = await dialog.ShowAsync(desktop.MainWindow);
+
+ if (file != null && file.Length > 0)
+ {
+ await HandleFirmwareInstallation(file[0]);
+ }
+ }
+ }
+
+ public async void InstallFirmwareFromFolder()
+ {
+ if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ OpenFolderDialog dialog = new();
+
+ string folder = await dialog.ShowAsync(desktop.MainWindow);
+
+ if (!string.IsNullOrEmpty(folder))
+ {
+ await HandleFirmwareInstallation(folder);
+ }
+ }
+ }
+
+ public static void OpenRyujinxFolder()
+ {
+ OpenHelper.OpenFolder(AppDataManager.BaseDirPath);
+ }
+
+ public static void OpenLogsFolder()
+ {
+ string logPath = Path.Combine(ReleaseInformation.GetBaseApplicationDirectory(), "Logs");
+
+ new DirectoryInfo(logPath).Create();
+
+ OpenHelper.OpenFolder(logPath);
+ }
+
+ public void ToggleDockMode()
+ {
+ if (IsGameRunning)
+ {
+ ConfigurationState.Instance.System.EnableDockedMode.Value = !ConfigurationState.Instance.System.EnableDockedMode.Value;
+ }
+ }
+
+ public async void ExitCurrentState()
+ {
+ if (WindowState == WindowState.FullScreen)
+ {
+ ToggleFullscreen();
+ }
+ else if (IsGameRunning)
+ {
+ await Task.Delay(100);
+
+ AppHost?.ShowExitPrompt();
+ }
+ }
+
+ public void ChangeLanguage(object languageCode)
+ {
+ LocaleManager.Instance.LoadLanguage((string)languageCode);
+
+ if (Program.PreviewerDetached)
+ {
+ ConfigurationState.Instance.Ui.LanguageCode.Value = (string)languageCode;
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ }
+ }
+
+ public void ToggleFileType(string fileType)
+ {
+ _ = fileType switch
+ {
+ "NSP" => ConfigurationState.Instance.Ui.ShownFileTypes.NSP.Value = !ConfigurationState.Instance.Ui.ShownFileTypes.NSP,
+ "PFS0" => ConfigurationState.Instance.Ui.ShownFileTypes.PFS0.Value = !ConfigurationState.Instance.Ui.ShownFileTypes.PFS0,
+ "XCI" => ConfigurationState.Instance.Ui.ShownFileTypes.XCI.Value = !ConfigurationState.Instance.Ui.ShownFileTypes.XCI,
+ "NCA" => ConfigurationState.Instance.Ui.ShownFileTypes.NCA.Value = !ConfigurationState.Instance.Ui.ShownFileTypes.NCA,
+ "NRO" => ConfigurationState.Instance.Ui.ShownFileTypes.NRO.Value = !ConfigurationState.Instance.Ui.ShownFileTypes.NRO,
+ "NSO" => ConfigurationState.Instance.Ui.ShownFileTypes.NSO.Value = !ConfigurationState.Instance.Ui.ShownFileTypes.NSO,
+ _ => throw new ArgumentOutOfRangeException(fileType),
+ };
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ LoadApplications();
+ }
+
+ public async void ManageProfiles()
+ {
+ await NavigationDialogHost.Show(AccountManager, ContentManager, VirtualFileSystem, LibHacHorizonManager.RyujinxClient);
+ }
+
+ public void OpenPtcDirectory()
+ {
+ ApplicationData selection = SelectedApplication;
+ if (selection != null)
+ {
+ string ptcDir = Path.Combine(AppDataManager.GamesDirPath, selection.TitleId, "cache", "cpu");
+ string mainPath = Path.Combine(ptcDir, "0");
+ string backupPath = Path.Combine(ptcDir, "1");
+
+ if (!Directory.Exists(ptcDir))
+ {
+ Directory.CreateDirectory(ptcDir);
+ Directory.CreateDirectory(mainPath);
+ Directory.CreateDirectory(backupPath);
+ }
+
+ OpenHelper.OpenFolder(ptcDir);
+ }
+ }
+
+ public async void PurgePtcCache()
+ {
+ ApplicationData selection = SelectedApplication;
+ if (selection != null)
+ {
+ DirectoryInfo mainDir = new(Path.Combine(AppDataManager.GamesDirPath, selection.TitleId, "cache", "cpu", "0"));
+ DirectoryInfo backupDir = new(Path.Combine(AppDataManager.GamesDirPath, selection.TitleId, "cache", "cpu", "1"));
+
+ // FIXME: Found a way to reproduce the bold effect on the title name (fork?).
+ UserResult result = await ContentDialogHelper.CreateConfirmationDialog(LocaleManager.Instance[LocaleKeys.DialogWarning],
+ LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogPPTCDeletionMessage, selection.TitleName),
+ LocaleManager.Instance[LocaleKeys.InputDialogYes],
+ LocaleManager.Instance[LocaleKeys.InputDialogNo],
+ LocaleManager.Instance[LocaleKeys.RyujinxConfirm]);
+
+ List<FileInfo> cacheFiles = new();
+
+ if (mainDir.Exists)
+ {
+ cacheFiles.AddRange(mainDir.EnumerateFiles("*.cache"));
+ }
+
+ if (backupDir.Exists)
+ {
+ cacheFiles.AddRange(backupDir.EnumerateFiles("*.cache"));
+ }
+
+ if (cacheFiles.Count > 0 && result == UserResult.Yes)
+ {
+ foreach (FileInfo file in cacheFiles)
+ {
+ try
+ {
+ file.Delete();
+ }
+ catch (Exception e)
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogPPTCDeletionErrorMessage, file.Name, e));
+ }
+ }
+ }
+ }
+ }
+
+ public void OpenShaderCacheDirectory()
+ {
+ ApplicationData selection = SelectedApplication;
+ if (selection != null)
+ {
+ string shaderCacheDir = Path.Combine(AppDataManager.GamesDirPath, selection.TitleId, "cache", "shader");
+
+ if (!Directory.Exists(shaderCacheDir))
+ {
+ Directory.CreateDirectory(shaderCacheDir);
+ }
+
+ OpenHelper.OpenFolder(shaderCacheDir);
+ }
+ }
+
+ public void SimulateWakeUpMessage()
+ {
+ AppHost.Device.System.SimulateWakeUpMessage();
+ }
+
+ public async void PurgeShaderCache()
+ {
+ ApplicationData selection = SelectedApplication;
+ if (selection != null)
+ {
+ DirectoryInfo shaderCacheDir = new(Path.Combine(AppDataManager.GamesDirPath, selection.TitleId, "cache", "shader"));
+
+ // FIXME: Found a way to reproduce the bold effect on the title name (fork?).
+ UserResult result = await ContentDialogHelper.CreateConfirmationDialog(LocaleManager.Instance[LocaleKeys.DialogWarning],
+ LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogShaderDeletionMessage, selection.TitleName),
+ LocaleManager.Instance[LocaleKeys.InputDialogYes],
+ LocaleManager.Instance[LocaleKeys.InputDialogNo],
+ LocaleManager.Instance[LocaleKeys.RyujinxConfirm]);
+
+ List<DirectoryInfo> oldCacheDirectories = new();
+ List<FileInfo> newCacheFiles = new();
+
+ if (shaderCacheDir.Exists)
+ {
+ oldCacheDirectories.AddRange(shaderCacheDir.EnumerateDirectories("*"));
+ newCacheFiles.AddRange(shaderCacheDir.GetFiles("*.toc"));
+ newCacheFiles.AddRange(shaderCacheDir.GetFiles("*.data"));
+ }
+
+ if ((oldCacheDirectories.Count > 0 || newCacheFiles.Count > 0) && result == UserResult.Yes)
+ {
+ foreach (DirectoryInfo directory in oldCacheDirectories)
+ {
+ try
+ {
+ directory.Delete(true);
+ }
+ catch (Exception e)
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogPPTCDeletionErrorMessage, directory.Name, e));
+ }
+ }
+ }
+
+ foreach (FileInfo file in newCacheFiles)
+ {
+ try
+ {
+ file.Delete();
+ }
+ catch (Exception e)
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.ShaderCachePurgeError, file.Name, e));
+ }
+ }
+ }
+ }
+
+ public void ToggleFavorite()
+ {
+ ApplicationData selection = SelectedApplication;
+ if (selection != null)
+ {
+ selection.Favorite = !selection.Favorite;
+
+ ApplicationLibrary.LoadAndSaveMetaData(selection.TitleId, appMetadata =>
+ {
+ appMetadata.Favorite = selection.Favorite;
+ });
+
+ RefreshView();
+ }
+ }
+
+ public void OpenUserSaveDirectory()
+ {
+ OpenSaveDirectory(SaveDataType.Account, userId: new UserId((ulong)AccountManager.LastOpenedUser.UserId.High, (ulong)AccountManager.LastOpenedUser.UserId.Low));
+ }
+
+ public void OpenDeviceSaveDirectory()
+ {
+ OpenSaveDirectory(SaveDataType.Device, userId: default);
+ }
+
+ public void OpenBcatSaveDirectory()
+ {
+ OpenSaveDirectory(SaveDataType.Bcat, userId: default);
+ }
+
+ private void OpenSaveDirectory(SaveDataType saveDataType, UserId userId)
+ {
+ if (SelectedApplication != null)
+ {
+ if (!ulong.TryParse(SelectedApplication.TitleId, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ulong titleIdNumber))
+ {
+ Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogRyujinxErrorMessage], LocaleManager.Instance[LocaleKeys.DialogInvalidTitleIdErrorMessage]);
+ });
+
+ return;
+ }
+
+ var saveDataFilter = SaveDataFilter.Make(titleIdNumber, saveDataType, userId, saveDataId: default, index: default);
+
+ ApplicationHelper.OpenSaveDir(in saveDataFilter, titleIdNumber, SelectedApplication.ControlHolder, SelectedApplication.TitleName);
+ }
+ }
+
+ public void OpenModsDirectory()
+ {
+ if (SelectedApplication != null)
+ {
+ string modsBasePath = VirtualFileSystem.ModLoader.GetModsBasePath();
+ string titleModsPath = VirtualFileSystem.ModLoader.GetTitleDir(modsBasePath, SelectedApplication.TitleId);
+
+ OpenHelper.OpenFolder(titleModsPath);
+ }
+ }
+
+ public void OpenSdModsDirectory()
+ {
+ if (SelectedApplication != null)
+ {
+ string sdModsBasePath = VirtualFileSystem.ModLoader.GetSdModsBasePath();
+ string titleModsPath = VirtualFileSystem.ModLoader.GetTitleDir(sdModsBasePath, SelectedApplication.TitleId);
+
+ OpenHelper.OpenFolder(titleModsPath);
+ }
+ }
+
+ public async void OpenTitleUpdateManager()
+ {
+ if (SelectedApplication != null)
+ {
+ await TitleUpdateWindow.Show(VirtualFileSystem, ulong.Parse(SelectedApplication.TitleId, NumberStyles.HexNumber), SelectedApplication.TitleName);
+ }
+ }
+
+ public async void OpenDownloadableContentManager()
+ {
+ if (SelectedApplication != null)
+ {
+ await DownloadableContentManagerWindow.Show(VirtualFileSystem, ulong.Parse(SelectedApplication.TitleId, NumberStyles.HexNumber), SelectedApplication.TitleName);
+ }
+ }
+
+ public async void OpenCheatManager()
+ {
+ if (SelectedApplication != null)
+ {
+ await new CheatWindow(VirtualFileSystem, SelectedApplication.TitleId, SelectedApplication.TitleName).ShowDialog(TopLevel as Window);
+ }
+ }
+
+ public async void LoadApplications()
+ {
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ Applications.Clear();
+
+ StatusBarVisible = true;
+ StatusBarProgressMaximum = 0;
+ StatusBarProgressValue = 0;
+
+ LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBarGamesLoaded, 0, 0);
+ });
+
+ ReloadGameList?.Invoke();
+ }
+
+ public async void OpenFile()
+ {
+ if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ OpenFileDialog dialog = new()
+ {
+ Title = LocaleManager.Instance[LocaleKeys.OpenFileDialogTitle]
+ };
+
+ dialog.Filters.Add(new FileDialogFilter
+ {
+ Name = LocaleManager.Instance[LocaleKeys.AllSupportedFormats],
+ Extensions =
+ {
+ "nsp",
+ "pfs0",
+ "xci",
+ "nca",
+ "nro",
+ "nso"
+ }
+ });
+
+ dialog.Filters.Add(new FileDialogFilter { Name = "NSP", Extensions = { "nsp" } });
+ dialog.Filters.Add(new FileDialogFilter { Name = "PFS0", Extensions = { "pfs0" } });
+ dialog.Filters.Add(new FileDialogFilter { Name = "XCI", Extensions = { "xci" } });
+ dialog.Filters.Add(new FileDialogFilter { Name = "NCA", Extensions = { "nca" } });
+ dialog.Filters.Add(new FileDialogFilter { Name = "NRO", Extensions = { "nro" } });
+ dialog.Filters.Add(new FileDialogFilter { Name = "NSO", Extensions = { "nso" } });
+
+ string[] files = await dialog.ShowAsync(desktop.MainWindow);
+
+ if (files != null && files.Length > 0)
+ {
+ LoadApplication(files[0]);
+ }
+ }
+ }
+
+ public async void OpenFolder()
+ {
+ if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ OpenFolderDialog dialog = new()
+ {
+ Title = LocaleManager.Instance[LocaleKeys.OpenFolderDialogTitle]
+ };
+
+ string folder = await dialog.ShowAsync(desktop.MainWindow);
+
+ if (!string.IsNullOrWhiteSpace(folder) && Directory.Exists(folder))
+ {
+ LoadApplication(folder);
+ }
+ }
+ }
+
+ public async void LoadApplication(string path, bool startFullscreen = false, string titleName = "")
+ {
+ if (AppHost != null)
+ {
+ await ContentDialogHelper.CreateInfoDialog(
+ LocaleManager.Instance[LocaleKeys.DialogLoadAppGameAlreadyLoadedMessage],
+ LocaleManager.Instance[LocaleKeys.DialogLoadAppGameAlreadyLoadedSubMessage],
+ LocaleManager.Instance[LocaleKeys.InputDialogOk],
+ "",
+ LocaleManager.Instance[LocaleKeys.RyujinxInfo]);
+
+ return;
+ }
+
+#if RELEASE
+ await PerformanceCheck();
+#endif
+
+ Logger.RestartTime();
+
+ if (SelectedIcon == null)
+ {
+ SelectedIcon = ApplicationLibrary.GetApplicationIcon(path);
+ }
+
+ PrepareLoadScreen();
+
+ RendererHostControl = new RendererHost();
+
+ AppHost = new AppHost(
+ RendererHostControl,
+ InputManager,
+ path,
+ VirtualFileSystem,
+ ContentManager,
+ AccountManager,
+ UserChannelPersistence,
+ this,
+ TopLevel);
+
+ async void Action()
+ {
+ if (!await AppHost.LoadGuestApplication())
+ {
+ AppHost.DisposeContext();
+ AppHost = null;
+
+ return;
+ }
+
+ CanUpdate = false;
+
+ LoadHeading = TitleName = titleName;
+
+ if (string.IsNullOrWhiteSpace(titleName))
+ {
+ LoadHeading = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.LoadingHeading, AppHost.Device.Processes.ActiveApplication.Name);
+ TitleName = AppHost.Device.Processes.ActiveApplication.Name;
+ }
+
+ SwitchToRenderer(startFullscreen);
+
+ _currentEmulatedGamePath = path;
+
+ Thread gameThread = new(InitializeGame) { Name = "GUI.WindowThread" };
+ gameThread.Start();
+ }
+
+ Dispatcher.UIThread.Post(Action);
+ }
+
+ public void SwitchToRenderer(bool startFullscreen)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ SwitchToGameControl(startFullscreen);
+
+ SetMainContent(RendererHostControl);
+
+ RendererHostControl.Focus();
+ });
+ }
+
+ public void UpdateGameMetadata(string titleId)
+ {
+ ApplicationLibrary.LoadAndSaveMetaData(titleId, appMetadata =>
+ {
+ if (DateTime.TryParse(appMetadata.LastPlayed, out DateTime lastPlayedDateTime))
+ {
+ double sessionTimePlayed = DateTime.UtcNow.Subtract(lastPlayedDateTime).TotalSeconds;
+
+ appMetadata.TimePlayed += Math.Round(sessionTimePlayed, MidpointRounding.AwayFromZero);
+ }
+ });
+ }
+
+ public void RefreshFirmwareStatus()
+ {
+ SystemVersion version = null;
+ try
+ {
+ version = ContentManager.GetCurrentFirmwareVersion();
+ }
+ catch (Exception) { }
+
+ bool hasApplet = false;
+
+ if (version != null)
+ {
+ LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBarSystemVersion, version.VersionString);
+
+ hasApplet = version.Major > 3;
+ }
+ else
+ {
+ LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBarSystemVersion, "0.0");
+ }
+
+ IsAppletMenuActive = hasApplet;
+ }
+
+ public void AppHost_AppExit(object sender, EventArgs e)
+ {
+ if (IsClosing)
+ {
+ return;
+ }
+
+ IsGameRunning = false;
+
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ ShowMenuAndStatusBar = true;
+ ShowContent = true;
+ ShowLoadProgress = false;
+ IsLoadingIndeterminate = false;
+ CanUpdate = true;
+ Cursor = Cursor.Default;
+
+ SetMainContent(null);
+
+ AppHost = null;
+
+ HandleRelaunch();
+ });
+
+ RendererHostControl.WindowCreated -= RendererHost_Created;
+ RendererHostControl = null;
+
+ SelectedIcon = null;
+
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ Title = $"Ryujinx {Program.Version}";
+ });
+ }
+
+ public void ToggleFullscreen()
+ {
+ if (Environment.TickCount64 - LastFullscreenToggle < HotKeyPressDelayMs)
+ {
+ return;
+ }
+
+ LastFullscreenToggle = Environment.TickCount64;
+
+ if (WindowState == WindowState.FullScreen)
+ {
+ WindowState = WindowState.Normal;
+
+ if (IsGameRunning)
+ {
+ ShowMenuAndStatusBar = true;
+ }
+ }
+ else
+ {
+ WindowState = WindowState.FullScreen;
+
+ if (IsGameRunning)
+ {
+ ShowMenuAndStatusBar = false;
+ }
+ }
+
+ IsFullScreen = WindowState == WindowState.FullScreen;
+ }
+
+ public static void SaveConfig()
+ {
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ }
+
+ public static async Task PerformanceCheck()
+ {
+ if (ConfigurationState.Instance.Logger.EnableTrace.Value)
+ {
+ string mainMessage = LocaleManager.Instance[LocaleKeys.DialogPerformanceCheckLoggingEnabledMessage];
+ string secondaryMessage = LocaleManager.Instance[LocaleKeys.DialogPerformanceCheckLoggingEnabledConfirmMessage];
+
+ UserResult result = await ContentDialogHelper.CreateConfirmationDialog(
+ mainMessage,
+ secondaryMessage,
+ LocaleManager.Instance[LocaleKeys.InputDialogYes],
+ LocaleManager.Instance[LocaleKeys.InputDialogNo],
+ LocaleManager.Instance[LocaleKeys.RyujinxConfirm]);
+
+ if (result == UserResult.Yes)
+ {
+ ConfigurationState.Instance.Logger.EnableTrace.Value = false;
+
+ SaveConfig();
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(ConfigurationState.Instance.Graphics.ShadersDumpPath.Value))
+ {
+ string mainMessage = LocaleManager.Instance[LocaleKeys.DialogPerformanceCheckShaderDumpEnabledMessage];
+ string secondaryMessage = LocaleManager.Instance[LocaleKeys.DialogPerformanceCheckShaderDumpEnabledConfirmMessage];
+
+ UserResult result = await ContentDialogHelper.CreateConfirmationDialog(
+ mainMessage,
+ secondaryMessage,
+ LocaleManager.Instance[LocaleKeys.InputDialogYes],
+ LocaleManager.Instance[LocaleKeys.InputDialogNo],
+ LocaleManager.Instance[LocaleKeys.RyujinxConfirm]);
+
+ if (result == UserResult.Yes)
+ {
+ ConfigurationState.Instance.Graphics.ShadersDumpPath.Value = "";
+
+ SaveConfig();
+ }
+ }
+ }
+
+#endregion
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/SettingsViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/SettingsViewModel.cs
new file mode 100644
index 00000000..232c9d43
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/SettingsViewModel.cs
@@ -0,0 +1,585 @@
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Threading;
+using DynamicData;
+using LibHac.Tools.FsSystem;
+using Ryujinx.Audio.Backends.OpenAL;
+using Ryujinx.Audio.Backends.SDL2;
+using Ryujinx.Audio.Backends.SoundIo;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Windows;
+using Ryujinx.Common.Configuration;
+using Ryujinx.Common.Configuration.Hid;
+using Ryujinx.Common.GraphicsDriver;
+using Ryujinx.Common.Logging;
+using Ryujinx.Graphics.Vulkan;
+using Ryujinx.HLE.FileSystem;
+using Ryujinx.HLE.HOS.Services.Time.TimeZone;
+using Ryujinx.Ui.Common.Configuration;
+using Ryujinx.Ui.Common.Configuration.System;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Net.NetworkInformation;
+using TimeZone = Ryujinx.Ava.UI.Models.TimeZone;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class SettingsViewModel : BaseModel
+ {
+ private readonly VirtualFileSystem _virtualFileSystem;
+ private readonly ContentManager _contentManager;
+ private TimeZoneContentManager _timeZoneContentManager;
+
+ private readonly List<string> _validTzRegions;
+
+ private readonly Dictionary<string, string> _networkInterfaces;
+
+ private float _customResolutionScale;
+ private int _resolutionScale;
+ private int _graphicsBackendMultithreadingIndex;
+ private float _volume;
+ private bool _isVulkanAvailable = true;
+ private bool _directoryChanged;
+ private List<string> _gpuIds = new();
+ private KeyboardHotkeys _keyboardHotkeys;
+ private int _graphicsBackendIndex;
+ private string _customThemePath;
+ private int _scalingFilter;
+ private int _scalingFilterLevel;
+
+ public event Action CloseWindow;
+ public event Action SaveSettingsEvent;
+ private int _networkInterfaceIndex;
+
+ public int ResolutionScale
+ {
+ get => _resolutionScale;
+ set
+ {
+ _resolutionScale = value;
+
+ OnPropertyChanged(nameof(CustomResolutionScale));
+ OnPropertyChanged(nameof(IsCustomResolutionScaleActive));
+ }
+ }
+
+ public int GraphicsBackendMultithreadingIndex
+ {
+ get => _graphicsBackendMultithreadingIndex;
+ set
+ {
+ _graphicsBackendMultithreadingIndex = value;
+
+ if (_graphicsBackendMultithreadingIndex != (int)ConfigurationState.Instance.Graphics.BackendThreading.Value)
+ {
+ Dispatcher.UIThread.Post(async () =>
+ {
+ await ContentDialogHelper.CreateInfoDialog(LocaleManager.Instance[LocaleKeys.DialogSettingsBackendThreadingWarningMessage],
+ "",
+ "",
+ LocaleManager.Instance[LocaleKeys.InputDialogOk],
+ LocaleManager.Instance[LocaleKeys.DialogSettingsBackendThreadingWarningTitle]);
+ });
+ }
+
+ OnPropertyChanged();
+ }
+ }
+
+ public float CustomResolutionScale
+ {
+ get => _customResolutionScale;
+ set
+ {
+ _customResolutionScale = MathF.Round(value, 1);
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsVulkanAvailable
+ {
+ get => _isVulkanAvailable;
+ set
+ {
+ _isVulkanAvailable = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsOpenGLAvailable => !OperatingSystem.IsMacOS();
+
+ public bool IsHypervisorAvailable => OperatingSystem.IsMacOS() && RuntimeInformation.ProcessArchitecture == Architecture.Arm64;
+
+ public bool DirectoryChanged
+ {
+ get => _directoryChanged;
+ set
+ {
+ _directoryChanged = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsMacOS => OperatingSystem.IsMacOS();
+
+ public bool EnableDiscordIntegration { get; set; }
+ public bool CheckUpdatesOnStart { get; set; }
+ public bool ShowConfirmExit { get; set; }
+ public bool HideCursorOnIdle { get; set; }
+ public bool EnableDockedMode { get; set; }
+ public bool EnableKeyboard { get; set; }
+ public bool EnableMouse { get; set; }
+ public bool EnableVsync { get; set; }
+ public bool EnablePptc { get; set; }
+ public bool EnableInternetAccess { get; set; }
+ public bool EnableFsIntegrityChecks { get; set; }
+ public bool IgnoreMissingServices { get; set; }
+ public bool ExpandDramSize { get; set; }
+ public bool EnableShaderCache { get; set; }
+ public bool EnableTextureRecompression { get; set; }
+ public bool EnableMacroHLE { get; set; }
+ public bool EnableFileLog { get; set; }
+ public bool EnableStub { get; set; }
+ public bool EnableInfo { get; set; }
+ public bool EnableWarn { get; set; }
+ public bool EnableError { get; set; }
+ public bool EnableTrace { get; set; }
+ public bool EnableGuest { get; set; }
+ public bool EnableFsAccessLog { get; set; }
+ public bool EnableDebug { get; set; }
+ public bool IsOpenAlEnabled { get; set; }
+ public bool IsSoundIoEnabled { get; set; }
+ public bool IsSDL2Enabled { get; set; }
+ public bool EnableCustomTheme { get; set; }
+ public bool IsCustomResolutionScaleActive => _resolutionScale == 4;
+ public bool IsScalingFilterActive => _scalingFilter == (int)Ryujinx.Common.Configuration.ScalingFilter.Fsr;
+
+ public bool IsVulkanSelected => GraphicsBackendIndex == 0;
+ public bool UseHypervisor { get; set; }
+
+ public string TimeZone { get; set; }
+ public string ShaderDumpPath { get; set; }
+
+ public string CustomThemePath
+ {
+ get
+ {
+ return _customThemePath;
+ }
+ set
+ {
+ _customThemePath = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int Language { get; set; }
+ public int Region { get; set; }
+ public int FsGlobalAccessLogMode { get; set; }
+ public int AudioBackend { get; set; }
+ public int MaxAnisotropy { get; set; }
+ public int AspectRatio { get; set; }
+ public int AntiAliasingEffect { get; set; }
+ public string ScalingFilterLevelText => ScalingFilterLevel.ToString("0");
+ public int ScalingFilterLevel
+ {
+ get => _scalingFilterLevel;
+ set
+ {
+ _scalingFilterLevel = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(ScalingFilterLevelText));
+ }
+ }
+ public int OpenglDebugLevel { get; set; }
+ public int MemoryMode { get; set; }
+ public int BaseStyleIndex { get; set; }
+ public int GraphicsBackendIndex
+ {
+ get => _graphicsBackendIndex;
+ set
+ {
+ _graphicsBackendIndex = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(IsVulkanSelected));
+ }
+ }
+ public int ScalingFilter
+ {
+ get => _scalingFilter;
+ set
+ {
+ _scalingFilter = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(IsScalingFilterActive));
+ }
+ }
+
+ public int PreferredGpuIndex { get; set; }
+
+ public float Volume
+ {
+ get => _volume;
+ set
+ {
+ _volume = value;
+
+ ConfigurationState.Instance.System.AudioVolume.Value = _volume / 100;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public DateTimeOffset DateOffset { get; set; }
+ public TimeSpan TimeOffset { get; set; }
+ internal AvaloniaList<TimeZone> TimeZones { get; set; }
+ public AvaloniaList<string> GameDirectories { get; set; }
+ public ObservableCollection<ComboBoxItem> AvailableGpus { get; set; }
+
+ public AvaloniaList<string> NetworkInterfaceList
+ {
+ get => new AvaloniaList<string>(_networkInterfaces.Keys);
+ }
+
+ public KeyboardHotkeys KeyboardHotkeys
+ {
+ get => _keyboardHotkeys;
+ set
+ {
+ _keyboardHotkeys = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int NetworkInterfaceIndex
+ {
+ get => _networkInterfaceIndex;
+ set
+ {
+ _networkInterfaceIndex = value != -1 ? value : 0;
+ ConfigurationState.Instance.Multiplayer.LanInterfaceId.Value = _networkInterfaces[NetworkInterfaceList[_networkInterfaceIndex]];
+ }
+ }
+
+ public SettingsViewModel(VirtualFileSystem virtualFileSystem, ContentManager contentManager) : this()
+ {
+ _virtualFileSystem = virtualFileSystem;
+ _contentManager = contentManager;
+ if (Program.PreviewerDetached)
+ {
+ LoadTimeZones();
+ }
+ }
+
+ public SettingsViewModel()
+ {
+ GameDirectories = new AvaloniaList<string>();
+ TimeZones = new AvaloniaList<TimeZone>();
+ AvailableGpus = new ObservableCollection<ComboBoxItem>();
+ _validTzRegions = new List<string>();
+ _networkInterfaces = new Dictionary<string, string>();
+
+ CheckSoundBackends();
+ PopulateNetworkInterfaces();
+
+ if (Program.PreviewerDetached)
+ {
+ LoadAvailableGpus();
+ LoadCurrentConfiguration();
+ }
+ }
+
+ public void CheckSoundBackends()
+ {
+ IsOpenAlEnabled = OpenALHardwareDeviceDriver.IsSupported;
+ IsSoundIoEnabled = SoundIoHardwareDeviceDriver.IsSupported;
+ IsSDL2Enabled = SDL2HardwareDeviceDriver.IsSupported;
+ }
+
+ private void LoadAvailableGpus()
+ {
+ _gpuIds = new List<string>();
+ List<string> names = new();
+ var devices = VulkanRenderer.GetPhysicalDevices();
+
+ if (devices.Length == 0)
+ {
+ IsVulkanAvailable = false;
+ GraphicsBackendIndex = 1;
+ }
+ else
+ {
+ foreach (var device in devices)
+ {
+ _gpuIds.Add(device.Id);
+ names.Add($"{device.Name} {(device.IsDiscrete ? "(dGPU)" : "")}");
+ }
+ }
+
+ AvailableGpus.Clear();
+ AvailableGpus.AddRange(names.Select(x => new ComboBoxItem { Content = x }));
+ }
+
+ public void LoadTimeZones()
+ {
+ _timeZoneContentManager = new TimeZoneContentManager();
+
+ _timeZoneContentManager.InitializeInstance(_virtualFileSystem, _contentManager, IntegrityCheckLevel.None);
+
+ foreach ((int offset, string location, string abbr) in _timeZoneContentManager.ParseTzOffsets())
+ {
+ int hours = Math.DivRem(offset, 3600, out int seconds);
+ int minutes = Math.Abs(seconds) / 60;
+
+ string abbr2 = abbr.StartsWith('+') || abbr.StartsWith('-') ? string.Empty : abbr;
+
+ TimeZones.Add(new TimeZone($"UTC{hours:+0#;-0#;+00}:{minutes:D2}", location, abbr2));
+
+ _validTzRegions.Add(location);
+ }
+ }
+
+ private void PopulateNetworkInterfaces()
+ {
+ _networkInterfaces.Clear();
+ _networkInterfaces.Add(LocaleManager.Instance[LocaleKeys.NetworkInterfaceDefault], "0");
+
+ foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces())
+ {
+ _networkInterfaces.Add(networkInterface.Name, networkInterface.Id);
+ }
+ }
+
+ public void ValidateAndSetTimeZone(string location)
+ {
+ if (_validTzRegions.Contains(location))
+ {
+ TimeZone = location;
+ }
+ }
+
+ public void LoadCurrentConfiguration()
+ {
+ ConfigurationState config = ConfigurationState.Instance;
+
+ // User Interface
+ EnableDiscordIntegration = config.EnableDiscordIntegration;
+ CheckUpdatesOnStart = config.CheckUpdatesOnStart;
+ ShowConfirmExit = config.ShowConfirmExit;
+ HideCursorOnIdle = config.HideCursorOnIdle;
+
+ GameDirectories.Clear();
+ GameDirectories.AddRange(config.Ui.GameDirs.Value);
+
+ EnableCustomTheme = config.Ui.EnableCustomTheme;
+ CustomThemePath = config.Ui.CustomThemePath;
+ BaseStyleIndex = config.Ui.BaseStyle == "Light" ? 0 : 1;
+
+ // Input
+ EnableDockedMode = config.System.EnableDockedMode;
+ EnableKeyboard = config.Hid.EnableKeyboard;
+ EnableMouse = config.Hid.EnableMouse;
+
+ // Keyboard Hotkeys
+ KeyboardHotkeys = config.Hid.Hotkeys.Value;
+
+ // System
+ Region = (int)config.System.Region.Value;
+ Language = (int)config.System.Language.Value;
+ TimeZone = config.System.TimeZone;
+
+ DateTime dateTimeOffset = DateTime.Now.AddSeconds(config.System.SystemTimeOffset);
+
+ DateOffset = dateTimeOffset.Date;
+ TimeOffset = dateTimeOffset.TimeOfDay;
+ EnableVsync = config.Graphics.EnableVsync;
+ EnableFsIntegrityChecks = config.System.EnableFsIntegrityChecks;
+ ExpandDramSize = config.System.ExpandRam;
+ IgnoreMissingServices = config.System.IgnoreMissingServices;
+
+ // CPU
+ EnablePptc = config.System.EnablePtc;
+ MemoryMode = (int)config.System.MemoryManagerMode.Value;
+ UseHypervisor = config.System.UseHypervisor;
+
+ // Graphics
+ GraphicsBackendIndex = (int)config.Graphics.GraphicsBackend.Value;
+ PreferredGpuIndex = _gpuIds.Contains(config.Graphics.PreferredGpu) ? _gpuIds.IndexOf(config.Graphics.PreferredGpu) : 0;
+ EnableShaderCache = config.Graphics.EnableShaderCache;
+ EnableTextureRecompression = config.Graphics.EnableTextureRecompression;
+ EnableMacroHLE = config.Graphics.EnableMacroHLE;
+ ResolutionScale = config.Graphics.ResScale == -1 ? 4 : config.Graphics.ResScale - 1;
+ CustomResolutionScale = config.Graphics.ResScaleCustom;
+ MaxAnisotropy = config.Graphics.MaxAnisotropy == -1 ? 0 : (int)(MathF.Log2(config.Graphics.MaxAnisotropy));
+ AspectRatio = (int)config.Graphics.AspectRatio.Value;
+ GraphicsBackendMultithreadingIndex = (int)config.Graphics.BackendThreading.Value;
+ ShaderDumpPath = config.Graphics.ShadersDumpPath;
+ AntiAliasingEffect = (int)config.Graphics.AntiAliasing.Value;
+ ScalingFilter = (int)config.Graphics.ScalingFilter.Value;
+ ScalingFilterLevel = config.Graphics.ScalingFilterLevel.Value;
+
+ // Audio
+ AudioBackend = (int)config.System.AudioBackend.Value;
+ Volume = config.System.AudioVolume * 100;
+
+ // Network
+ EnableInternetAccess = config.System.EnableInternetAccess;
+
+ // Logging
+ EnableFileLog = config.Logger.EnableFileLog;
+ EnableStub = config.Logger.EnableStub;
+ EnableInfo = config.Logger.EnableInfo;
+ EnableWarn = config.Logger.EnableWarn;
+ EnableError = config.Logger.EnableError;
+ EnableTrace = config.Logger.EnableTrace;
+ EnableGuest = config.Logger.EnableGuest;
+ EnableDebug = config.Logger.EnableDebug;
+ EnableFsAccessLog = config.Logger.EnableFsAccessLog;
+ FsGlobalAccessLogMode = config.System.FsGlobalAccessLogMode;
+ OpenglDebugLevel = (int)config.Logger.GraphicsDebugLevel.Value;
+
+ NetworkInterfaceIndex = _networkInterfaces.Values.ToList().IndexOf(config.Multiplayer.LanInterfaceId.Value);
+ }
+
+ public void SaveSettings()
+ {
+ ConfigurationState config = ConfigurationState.Instance;
+
+ // User Interface
+ config.EnableDiscordIntegration.Value = EnableDiscordIntegration;
+ config.CheckUpdatesOnStart.Value = CheckUpdatesOnStart;
+ config.ShowConfirmExit.Value = ShowConfirmExit;
+ config.HideCursorOnIdle.Value = HideCursorOnIdle;
+
+ if (_directoryChanged)
+ {
+ List<string> gameDirs = new(GameDirectories);
+ config.Ui.GameDirs.Value = gameDirs;
+ }
+
+ config.Ui.EnableCustomTheme.Value = EnableCustomTheme;
+ config.Ui.CustomThemePath.Value = CustomThemePath;
+ config.Ui.BaseStyle.Value = BaseStyleIndex == 0 ? "Light" : "Dark";
+
+ // Input
+ config.System.EnableDockedMode.Value = EnableDockedMode;
+ config.Hid.EnableKeyboard.Value = EnableKeyboard;
+ config.Hid.EnableMouse.Value = EnableMouse;
+
+ // Keyboard Hotkeys
+ config.Hid.Hotkeys.Value = KeyboardHotkeys;
+
+ // System
+ config.System.Region.Value = (Region)Region;
+ config.System.Language.Value = (Language)Language;
+
+ if (_validTzRegions.Contains(TimeZone))
+ {
+ config.System.TimeZone.Value = TimeZone;
+ }
+
+ TimeSpan systemTimeOffset = DateOffset - DateTime.Now;
+
+ config.System.SystemTimeOffset.Value = systemTimeOffset.Seconds;
+ config.Graphics.EnableVsync.Value = EnableVsync;
+ config.System.EnableFsIntegrityChecks.Value = EnableFsIntegrityChecks;
+ config.System.ExpandRam.Value = ExpandDramSize;
+ config.System.IgnoreMissingServices.Value = IgnoreMissingServices;
+
+ // CPU
+ config.System.EnablePtc.Value = EnablePptc;
+ config.System.MemoryManagerMode.Value = (MemoryManagerMode)MemoryMode;
+ config.System.UseHypervisor.Value = UseHypervisor;
+
+ // Graphics
+ config.Graphics.GraphicsBackend.Value = (GraphicsBackend)GraphicsBackendIndex;
+ config.Graphics.PreferredGpu.Value = _gpuIds.ElementAtOrDefault(PreferredGpuIndex);
+ config.Graphics.EnableShaderCache.Value = EnableShaderCache;
+ config.Graphics.EnableTextureRecompression.Value = EnableTextureRecompression;
+ config.Graphics.EnableMacroHLE.Value = EnableMacroHLE;
+ config.Graphics.ResScale.Value = ResolutionScale == 4 ? -1 : ResolutionScale + 1;
+ config.Graphics.ResScaleCustom.Value = CustomResolutionScale;
+ config.Graphics.MaxAnisotropy.Value = MaxAnisotropy == 0 ? -1 : MathF.Pow(2, MaxAnisotropy);
+ config.Graphics.AspectRatio.Value = (AspectRatio)AspectRatio;
+ config.Graphics.AntiAliasing.Value = (AntiAliasing)AntiAliasingEffect;
+ config.Graphics.ScalingFilter.Value = (ScalingFilter)ScalingFilter;
+ config.Graphics.ScalingFilterLevel.Value = ScalingFilterLevel;
+
+ if (ConfigurationState.Instance.Graphics.BackendThreading != (BackendThreading)GraphicsBackendMultithreadingIndex)
+ {
+ DriverUtilities.ToggleOGLThreading(GraphicsBackendMultithreadingIndex == (int)BackendThreading.Off);
+ }
+
+ config.Graphics.BackendThreading.Value = (BackendThreading)GraphicsBackendMultithreadingIndex;
+ config.Graphics.ShadersDumpPath.Value = ShaderDumpPath;
+
+ // Audio
+ AudioBackend audioBackend = (AudioBackend)AudioBackend;
+ if (audioBackend != config.System.AudioBackend.Value)
+ {
+ config.System.AudioBackend.Value = audioBackend;
+
+ Logger.Info?.Print(LogClass.Application, $"AudioBackend toggled to: {audioBackend}");
+ }
+
+ config.System.AudioVolume.Value = Volume / 100;
+
+ // Network
+ config.System.EnableInternetAccess.Value = EnableInternetAccess;
+
+ // Logging
+ config.Logger.EnableFileLog.Value = EnableFileLog;
+ config.Logger.EnableStub.Value = EnableStub;
+ config.Logger.EnableInfo.Value = EnableInfo;
+ config.Logger.EnableWarn.Value = EnableWarn;
+ config.Logger.EnableError.Value = EnableError;
+ config.Logger.EnableTrace.Value = EnableTrace;
+ config.Logger.EnableGuest.Value = EnableGuest;
+ config.Logger.EnableDebug.Value = EnableDebug;
+ config.Logger.EnableFsAccessLog.Value = EnableFsAccessLog;
+ config.System.FsGlobalAccessLogMode.Value = FsGlobalAccessLogMode;
+ config.Logger.GraphicsDebugLevel.Value = (GraphicsDebugLevel)OpenglDebugLevel;
+
+ config.Multiplayer.LanInterfaceId.Value = _networkInterfaces[NetworkInterfaceList[NetworkInterfaceIndex]];
+
+ config.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+
+ MainWindow.UpdateGraphicsConfig();
+
+ SaveSettingsEvent?.Invoke();
+
+ _directoryChanged = false;
+ }
+
+ public void RevertIfNotSaved()
+ {
+ Program.ReloadConfig();
+ }
+
+ public void ApplyButton()
+ {
+ SaveSettings();
+ }
+
+ public void OkButton()
+ {
+ SaveSettings();
+ CloseWindow?.Invoke();
+ }
+
+ public void CancelButton()
+ {
+ RevertIfNotSaved();
+ CloseWindow?.Invoke();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/TitleUpdateViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/TitleUpdateViewModel.cs
new file mode 100644
index 00000000..1f4e3c62
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/TitleUpdateViewModel.cs
@@ -0,0 +1,252 @@
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Threading;
+using LibHac.Common;
+using LibHac.Fs;
+using LibHac.Fs.Fsa;
+using LibHac.FsSystem;
+using LibHac.Ns;
+using LibHac.Tools.FsSystem;
+using LibHac.Tools.FsSystem.NcaUtils;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.Common.Configuration;
+using Ryujinx.Common.Logging;
+using Ryujinx.Common.Utilities;
+using Ryujinx.HLE.FileSystem;
+using Ryujinx.HLE.HOS;
+using Ryujinx.Ui.App.Common;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Path = System.IO.Path;
+using SpanHelpers = LibHac.Common.SpanHelpers;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class TitleUpdateViewModel : BaseModel
+ {
+ public TitleUpdateMetadata _titleUpdateWindowData;
+ public readonly string _titleUpdateJsonPath;
+ private VirtualFileSystem _virtualFileSystem { get; }
+ private ulong _titleId { get; }
+ private string _titleName { get; }
+
+ private AvaloniaList<TitleUpdateModel> _titleUpdates = new();
+ private AvaloniaList<object> _views = new();
+ private object _selectedUpdate;
+
+ private static readonly TitleUpdateMetadataJsonSerializerContext SerializerContext = new(JsonHelper.GetDefaultSerializerOptions());
+
+ public AvaloniaList<TitleUpdateModel> TitleUpdates
+ {
+ get => _titleUpdates;
+ set
+ {
+ _titleUpdates = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public AvaloniaList<object> Views
+ {
+ get => _views;
+ set
+ {
+ _views = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public object SelectedUpdate
+ {
+ get => _selectedUpdate;
+ set
+ {
+ _selectedUpdate = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public TitleUpdateViewModel(VirtualFileSystem virtualFileSystem, ulong titleId, string titleName)
+ {
+ _virtualFileSystem = virtualFileSystem;
+
+ _titleId = titleId;
+ _titleName = titleName;
+
+ _titleUpdateJsonPath = Path.Combine(AppDataManager.GamesDirPath, titleId.ToString("x16"), "updates.json");
+
+ try
+ {
+ _titleUpdateWindowData = JsonHelper.DeserializeFromFile(_titleUpdateJsonPath, SerializerContext.TitleUpdateMetadata);
+ }
+ catch
+ {
+ Logger.Warning?.Print(LogClass.Application, $"Failed to deserialize title update data for {_titleId} at {_titleUpdateJsonPath}");
+
+ _titleUpdateWindowData = new TitleUpdateMetadata
+ {
+ Selected = "",
+ Paths = new List<string>()
+ };
+
+ Save();
+ }
+
+ LoadUpdates();
+ }
+
+ private void LoadUpdates()
+ {
+ foreach (string path in _titleUpdateWindowData.Paths)
+ {
+ AddUpdate(path);
+ }
+
+ TitleUpdateModel selected = TitleUpdates.FirstOrDefault(x => x.Path == _titleUpdateWindowData.Selected, null);
+
+ SelectedUpdate = selected;
+
+ // NOTE: Save the list again to remove leftovers.
+ Save();
+ SortUpdates();
+ }
+
+ public void SortUpdates()
+ {
+ var list = TitleUpdates.ToList();
+
+ list.Sort((first, second) =>
+ {
+ if (string.IsNullOrEmpty(first.Control.DisplayVersionString.ToString()))
+ {
+ return -1;
+ }
+ else if (string.IsNullOrEmpty(second.Control.DisplayVersionString.ToString()))
+ {
+ return 1;
+ }
+
+ return Version.Parse(first.Control.DisplayVersionString.ToString()).CompareTo(Version.Parse(second.Control.DisplayVersionString.ToString())) * -1;
+ });
+
+ Views.Clear();
+ Views.Add(new BaseModel());
+ Views.AddRange(list);
+
+ if (SelectedUpdate == null)
+ {
+ SelectedUpdate = Views[0];
+ }
+ else if (!TitleUpdates.Contains(SelectedUpdate))
+ {
+ if (Views.Count > 1)
+ {
+ SelectedUpdate = Views[1];
+ }
+ else
+ {
+ SelectedUpdate = Views[0];
+ }
+ }
+ }
+
+ private void AddUpdate(string path)
+ {
+ if (File.Exists(path) && TitleUpdates.All(x => x.Path != path))
+ {
+ using FileStream file = new(path, FileMode.Open, FileAccess.Read);
+
+ try
+ {
+ (Nca patchNca, Nca controlNca) = ApplicationLibrary.GetGameUpdateDataFromPartition(_virtualFileSystem, new PartitionFileSystem(file.AsStorage()), _titleId.ToString("x16"), 0);
+
+ if (controlNca != null && patchNca != null)
+ {
+ ApplicationControlProperty controlData = new();
+
+ using UniqueRef<IFile> nacpFile = new();
+
+ controlNca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None).OpenFile(ref nacpFile.Ref, "/control.nacp".ToU8Span(), OpenMode.Read).ThrowIfFailure();
+ nacpFile.Get.Read(out _, 0, SpanHelpers.AsByteSpan(ref controlData), ReadOption.None).ThrowIfFailure();
+
+ TitleUpdates.Add(new TitleUpdateModel(controlData, path));
+ }
+ else
+ {
+ Dispatcher.UIThread.Post(async () =>
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogUpdateAddUpdateErrorMessage]);
+ });
+ }
+ }
+ catch (Exception ex)
+ {
+ Dispatcher.UIThread.Post(async () =>
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogLoadNcaErrorMessage, ex.Message, path));
+ });
+ }
+ }
+ }
+
+ public void RemoveUpdate(TitleUpdateModel update)
+ {
+ TitleUpdates.Remove(update);
+
+ SortUpdates();
+ }
+
+ public async void Add()
+ {
+ OpenFileDialog dialog = new()
+ {
+ Title = LocaleManager.Instance[LocaleKeys.SelectUpdateDialogTitle],
+ AllowMultiple = true
+ };
+
+ dialog.Filters.Add(new FileDialogFilter
+ {
+ Name = "NSP",
+ Extensions = { "nsp" }
+ });
+
+ if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ string[] files = await dialog.ShowAsync(desktop.MainWindow);
+
+ if (files != null)
+ {
+ foreach (string file in files)
+ {
+ AddUpdate(file);
+ }
+ }
+ }
+
+ SortUpdates();
+ }
+
+ public void Save()
+ {
+ _titleUpdateWindowData.Paths.Clear();
+ _titleUpdateWindowData.Selected = "";
+
+ foreach (TitleUpdateModel update in TitleUpdates)
+ {
+ _titleUpdateWindowData.Paths.Add(update.Path);
+
+ if (update == SelectedUpdate)
+ {
+ _titleUpdateWindowData.Selected = update.Path;
+ }
+ }
+
+ JsonHelper.SerializeToFile(_titleUpdateJsonPath, _titleUpdateWindowData, SerializerContext.TitleUpdateMetadata);
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/UserFirmwareAvatarSelectorViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/UserFirmwareAvatarSelectorViewModel.cs
new file mode 100644
index 00000000..558cad5a
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/UserFirmwareAvatarSelectorViewModel.cs
@@ -0,0 +1,230 @@
+using Avalonia.Media;
+using LibHac.Common;
+using LibHac.Fs;
+using LibHac.Fs.Fsa;
+using LibHac.FsSystem;
+using LibHac.Ncm;
+using LibHac.Tools.Fs;
+using LibHac.Tools.FsSystem;
+using LibHac.Tools.FsSystem.NcaUtils;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.HLE.FileSystem;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using Color = Avalonia.Media.Color;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ internal class UserFirmwareAvatarSelectorViewModel : BaseModel
+ {
+ private static readonly Dictionary<string, byte[]> _avatarStore = new();
+
+ private ObservableCollection<ProfileImageModel> _images;
+ private Color _backgroundColor = Colors.White;
+
+ private int _selectedIndex;
+ private byte[] _selectedImage;
+
+ public UserFirmwareAvatarSelectorViewModel()
+ {
+ _images = new ObservableCollection<ProfileImageModel>();
+
+ LoadImagesFromStore();
+ }
+
+ public Color BackgroundColor
+ {
+ get => _backgroundColor;
+ set
+ {
+ _backgroundColor = value;
+ OnPropertyChanged();
+ ChangeImageBackground();
+ }
+ }
+
+ public ObservableCollection<ProfileImageModel> Images
+ {
+ get => _images;
+ set
+ {
+ _images = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public int SelectedIndex
+ {
+ get => _selectedIndex;
+ set
+ {
+ _selectedIndex = value;
+
+ if (_selectedIndex == -1)
+ {
+ SelectedImage = null;
+ }
+ else
+ {
+ SelectedImage = _images[_selectedIndex].Data;
+ }
+
+ OnPropertyChanged();
+ }
+ }
+
+ public byte[] SelectedImage
+ {
+ get => _selectedImage;
+ private set => _selectedImage = value;
+ }
+
+ private void LoadImagesFromStore()
+ {
+ Images.Clear();
+
+ foreach (var image in _avatarStore)
+ {
+ Images.Add(new ProfileImageModel(image.Key, image.Value));
+ }
+ }
+
+ private void ChangeImageBackground()
+ {
+ foreach (var image in Images)
+ {
+ image.BackgroundColor = new SolidColorBrush(BackgroundColor);
+ }
+ }
+
+ public static void PreloadAvatars(ContentManager contentManager, VirtualFileSystem virtualFileSystem)
+ {
+ if (_avatarStore.Count > 0)
+ {
+ return;
+ }
+
+ string contentPath = contentManager.GetInstalledContentPath(0x010000000000080A, StorageId.BuiltInSystem, NcaContentType.Data);
+ string avatarPath = virtualFileSystem.SwitchPathToSystemPath(contentPath);
+
+ if (!string.IsNullOrWhiteSpace(avatarPath))
+ {
+ using (IStorage ncaFileStream = new LocalStorage(avatarPath, FileAccess.Read, FileMode.Open))
+ {
+ Nca nca = new(virtualFileSystem.KeySet, ncaFileStream);
+ IFileSystem romfs = nca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.ErrorOnInvalid);
+
+ foreach (DirectoryEntryEx item in romfs.EnumerateEntries())
+ {
+ // TODO: Parse DatabaseInfo.bin and table.bin files for more accuracy.
+ if (item.Type == DirectoryEntryType.File && item.FullPath.Contains("chara") && item.FullPath.Contains("szs"))
+ {
+ using var file = new UniqueRef<IFile>();
+
+ romfs.OpenFile(ref file.Ref, ("/" + item.FullPath).ToU8Span(), OpenMode.Read).ThrowIfFailure();
+
+ using (MemoryStream stream = new())
+ using (MemoryStream streamPng = new())
+ {
+ file.Get.AsStream().CopyTo(stream);
+
+ stream.Position = 0;
+
+ Image avatarImage = Image.LoadPixelData<Rgba32>(DecompressYaz0(stream), 256, 256);
+
+ avatarImage.SaveAsPng(streamPng);
+
+ _avatarStore.Add(item.FullPath, streamPng.ToArray());
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private static byte[] DecompressYaz0(Stream stream)
+ {
+ using (BinaryReader reader = new(stream))
+ {
+ reader.ReadInt32(); // Magic
+
+ uint decodedLength = BinaryPrimitives.ReverseEndianness(reader.ReadUInt32());
+
+ reader.ReadInt64(); // Padding
+
+ byte[] input = new byte[stream.Length - stream.Position];
+ stream.Read(input, 0, input.Length);
+
+ uint inputOffset = 0;
+
+ byte[] output = new byte[decodedLength];
+ uint outputOffset = 0;
+
+ ushort mask = 0;
+ byte header = 0;
+
+ while (outputOffset < decodedLength)
+ {
+ if ((mask >>= 1) == 0)
+ {
+ header = input[inputOffset++];
+ mask = 0x80;
+ }
+
+ if ((header & mask) != 0)
+ {
+ if (outputOffset == output.Length)
+ {
+ break;
+ }
+
+ output[outputOffset++] = input[inputOffset++];
+ }
+ else
+ {
+ byte byte1 = input[inputOffset++];
+ byte byte2 = input[inputOffset++];
+
+ uint dist = (uint)((byte1 & 0xF) << 8) | byte2;
+ uint position = outputOffset - (dist + 1);
+
+ uint length = (uint)byte1 >> 4;
+ if (length == 0)
+ {
+ length = (uint)input[inputOffset++] + 0x12;
+ }
+ else
+ {
+ length += 2;
+ }
+
+ uint gap = outputOffset - position;
+ uint nonOverlappingLength = length;
+
+ if (nonOverlappingLength > gap)
+ {
+ nonOverlappingLength = gap;
+ }
+
+ Buffer.BlockCopy(output, (int)position, output, (int)outputOffset, (int)nonOverlappingLength);
+ outputOffset += nonOverlappingLength;
+ position += nonOverlappingLength;
+ length -= nonOverlappingLength;
+
+ while (length-- > 0)
+ {
+ output[outputOffset++] = output[position++];
+ }
+ }
+ }
+
+ return output;
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/UserProfileImageSelectorViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/UserProfileImageSelectorViewModel.cs
new file mode 100644
index 00000000..7261631c
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/UserProfileImageSelectorViewModel.cs
@@ -0,0 +1,18 @@
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ internal class UserProfileImageSelectorViewModel : BaseModel
+ {
+ private bool _firmwareFound;
+
+ public bool FirmwareFound
+ {
+ get => _firmwareFound;
+
+ set
+ {
+ _firmwareFound = value;
+ OnPropertyChanged();
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/UserProfileViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/UserProfileViewModel.cs
new file mode 100644
index 00000000..8f997efc
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/UserProfileViewModel.cs
@@ -0,0 +1,25 @@
+using Microsoft.IdentityModel.Tokens;
+using System;
+using System.Collections.ObjectModel;
+using UserProfile = Ryujinx.Ava.UI.Models.UserProfile;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class UserProfileViewModel : BaseModel, IDisposable
+ {
+ public UserProfileViewModel()
+ {
+ Profiles = new ObservableCollection<BaseModel>();
+ LostProfiles = new ObservableCollection<UserProfile>();
+ IsEmpty = LostProfiles.IsNullOrEmpty();
+ }
+
+ public ObservableCollection<BaseModel> Profiles { get; set; }
+
+ public ObservableCollection<UserProfile> LostProfiles { get; set; }
+
+ public bool IsEmpty { get; set; }
+
+ public void Dispose() { }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/UserSaveManagerViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/UserSaveManagerViewModel.cs
new file mode 100644
index 00000000..097634a8
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/UserSaveManagerViewModel.cs
@@ -0,0 +1,120 @@
+using DynamicData;
+using DynamicData.Binding;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.HLE.HOS.Services.Account.Acc;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class UserSaveManagerViewModel : BaseModel
+ {
+ private int _sortIndex;
+ private int _orderIndex;
+ private string _search;
+ private ObservableCollection<SaveModel> _saves = new();
+ private ObservableCollection<SaveModel> _views = new();
+ private AccountManager _accountManager;
+
+ public string SaveManagerHeading => LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.SaveManagerHeading, _accountManager.LastOpenedUser.Name, _accountManager.LastOpenedUser.UserId);
+
+ public int SortIndex
+ {
+ get => _sortIndex;
+ set
+ {
+ _sortIndex = value;
+ OnPropertyChanged();
+ Sort();
+ }
+ }
+
+ public int OrderIndex
+ {
+ get => _orderIndex;
+ set
+ {
+ _orderIndex = value;
+ OnPropertyChanged();
+ Sort();
+ }
+ }
+
+ public string Search
+ {
+ get => _search;
+ set
+ {
+ _search = value;
+ OnPropertyChanged();
+ Sort();
+ }
+ }
+
+ public ObservableCollection<SaveModel> Saves
+ {
+ get => _saves;
+ set
+ {
+ _saves = value;
+ OnPropertyChanged();
+ Sort();
+ }
+ }
+
+ public ObservableCollection<SaveModel> Views
+ {
+ get => _views;
+ set
+ {
+ _views = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public UserSaveManagerViewModel(AccountManager accountManager)
+ {
+ _accountManager = accountManager;
+ }
+
+ public void Sort()
+ {
+ Saves.AsObservableChangeSet()
+ .Filter(Filter)
+ .Sort(GetComparer())
+ .Bind(out var view).AsObservableList();
+
+ _views.Clear();
+ _views.AddRange(view);
+ OnPropertyChanged(nameof(Views));
+ }
+
+ private bool Filter(object arg)
+ {
+ if (arg is SaveModel save)
+ {
+ return string.IsNullOrWhiteSpace(_search) || save.Title.ToLower().Contains(_search.ToLower());
+ }
+
+ return false;
+ }
+
+ private IComparer<SaveModel> GetComparer()
+ {
+ switch (SortIndex)
+ {
+ case 0:
+ return OrderIndex == 0
+ ? SortExpressionComparer<SaveModel>.Ascending(save => save.Title)
+ : SortExpressionComparer<SaveModel>.Descending(save => save.Title);
+ case 1:
+ return OrderIndex == 0
+ ? SortExpressionComparer<SaveModel>.Ascending(save => save.Size)
+ : SortExpressionComparer<SaveModel>.Descending(save => save.Size);
+ default:
+ return null;
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Main/MainMenuBarView.axaml b/src/Ryujinx.Ava/UI/Views/Main/MainMenuBarView.axaml
new file mode 100644
index 00000000..d5b5efcd
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Main/MainMenuBarView.axaml
@@ -0,0 +1,164 @@
+<UserControl
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ mc:Ignorable="d"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ x:DataType="viewModels:MainWindowViewModel"
+ x:Class="Ryujinx.Ava.UI.Views.Main.MainMenuBarView"
+ x:CompileBindings="True">
+ <Design.DataContext>
+ <viewModels:MainWindowViewModel />
+ </Design.DataContext>
+ <DockPanel HorizontalAlignment="Stretch">
+ <Menu
+ Name="Menu"
+ Height="35"
+ Margin="0"
+ HorizontalAlignment="Left">
+ <Menu.ItemsPanel>
+ <ItemsPanelTemplate>
+ <DockPanel Margin="0" HorizontalAlignment="Stretch" />
+ </ItemsPanelTemplate>
+ </Menu.ItemsPanel>
+ <MenuItem VerticalAlignment="Center" Header="{locale:Locale MenuBarFile}">
+ <MenuItem
+ Command="{ReflectionBinding OpenFile}"
+ Header="{locale:Locale MenuBarFileOpenFromFile}"
+ IsEnabled="{Binding EnableNonGameRunningControls}"
+ ToolTip.Tip="{locale:Locale LoadApplicationFileTooltip}" />
+ <MenuItem
+ Command="{ReflectionBinding OpenFolder}"
+ Header="{locale:Locale MenuBarFileOpenUnpacked}"
+ IsEnabled="{Binding EnableNonGameRunningControls}"
+ ToolTip.Tip="{locale:Locale LoadApplicationFolderTooltip}" />
+ <MenuItem Header="{locale:Locale MenuBarFileOpenApplet}" IsEnabled="{Binding IsAppletMenuActive}">
+ <MenuItem
+ Click="OpenMiiApplet"
+ Header="Mii Edit Applet"
+ ToolTip.Tip="{locale:Locale MenuBarFileOpenAppletOpenMiiAppletToolTip}" />
+ </MenuItem>
+ <Separator />
+ <MenuItem
+ Command="{ReflectionBinding OpenRyujinxFolder}"
+ Header="{locale:Locale MenuBarFileOpenEmuFolder}"
+ ToolTip.Tip="{locale:Locale OpenRyujinxFolderTooltip}" />
+ <MenuItem
+ Command="{ReflectionBinding OpenLogsFolder}"
+ Header="{locale:Locale MenuBarFileOpenLogsFolder}"
+ ToolTip.Tip="{locale:Locale OpenRyujinxLogsTooltip}" />
+ <Separator />
+ <MenuItem
+ Click="CloseWindow"
+ Header="{locale:Locale MenuBarFileExit}"
+ ToolTip.Tip="{locale:Locale ExitTooltip}" />
+ </MenuItem>
+ <MenuItem VerticalAlignment="Center" Header="{locale:Locale MenuBarOptions}">
+ <MenuItem
+ Command="{ReflectionBinding ToggleFullscreen}"
+ Header="{locale:Locale MenuBarOptionsToggleFullscreen}"
+ InputGesture="F11" />
+ <MenuItem>
+ <MenuItem.Icon>
+ <CheckBox IsChecked="{Binding StartGamesInFullscreen, Mode=TwoWay}"
+ MinWidth="250">
+ <TextBlock Text="{locale:Locale MenuBarOptionsStartGamesInFullscreen}"/>
+ </CheckBox>
+ </MenuItem.Icon>
+ </MenuItem>
+ <MenuItem IsVisible="{Binding ShowConsoleVisible}">
+ <MenuItem.Icon>
+ <CheckBox IsChecked="{Binding ShowConsole, Mode=TwoWay}"
+ MinWidth="250">
+ <TextBlock Text="{locale:Locale MenuBarOptionsShowConsole}"/>
+ </CheckBox>
+ </MenuItem.Icon>
+ </MenuItem>
+ <Separator />
+ <MenuItem Name="ChangeLanguageMenuItem" Header="{locale:Locale MenuBarOptionsChangeLanguage}" />
+ <MenuItem Name="ToggleFileTypesMenuItem" Header="{locale:Locale MenuBarShowFileTypes}" />
+ <Separator />
+ <MenuItem
+ Click="OpenSettings"
+ Header="{locale:Locale MenuBarOptionsSettings}"
+ ToolTip.Tip="{locale:Locale OpenSettingsTooltip}" />
+ <MenuItem
+ Command="{ReflectionBinding ManageProfiles}"
+ Header="{locale:Locale MenuBarOptionsManageUserProfiles}"
+ IsEnabled="{Binding EnableNonGameRunningControls}"
+ ToolTip.Tip="{locale:Locale OpenProfileManagerTooltip}" />
+ </MenuItem>
+ <MenuItem
+ Name="ActionsMenuItem"
+ VerticalAlignment="Center"
+ Header="{locale:Locale MenuBarActions}"
+ IsEnabled="{Binding IsGameRunning}">
+ <MenuItem
+ Click="PauseEmulation_Click"
+ Header="{locale:Locale MenuBarOptionsPauseEmulation}"
+ InputGesture="{Binding PauseKey}"
+ IsEnabled="{Binding !IsPaused}"
+ IsVisible="{Binding !IsPaused}" />
+ <MenuItem
+ Click="ResumeEmulation_Click"
+ Header="{locale:Locale MenuBarOptionsResumeEmulation}"
+ InputGesture="{Binding PauseKey}"
+ IsEnabled="{Binding IsPaused}"
+ IsVisible="{Binding IsPaused}" />
+ <MenuItem
+ Click="StopEmulation_Click"
+ Header="{locale:Locale MenuBarOptionsStopEmulation}"
+ InputGesture="Escape"
+ IsEnabled="{Binding IsGameRunning}"
+ ToolTip.Tip="{locale:Locale StopEmulationTooltip}" />
+ <MenuItem Command="{ReflectionBinding SimulateWakeUpMessage}" Header="{locale:Locale MenuBarOptionsSimulateWakeUpMessage}" />
+ <Separator />
+ <MenuItem
+ Name="ScanAmiiboMenuItem"
+ AttachedToVisualTree="ScanAmiiboMenuItem_AttachedToVisualTree"
+ Click="OpenAmiiboWindow"
+ Header="{locale:Locale MenuBarActionsScanAmiibo}"
+ IsEnabled="{Binding IsAmiiboRequested}" />
+ <MenuItem
+ Command="{ReflectionBinding TakeScreenshot}"
+ Header="{locale:Locale MenuBarFileToolsTakeScreenshot}"
+ InputGesture="{Binding ScreenshotKey}"
+ IsEnabled="{Binding IsGameRunning}" />
+ <MenuItem
+ Command="{ReflectionBinding HideUi}"
+ Header="{locale:Locale MenuBarFileToolsHideUi}"
+ InputGesture="{Binding ShowUiKey}"
+ IsEnabled="{Binding IsGameRunning}" />
+ <MenuItem
+ Click="OpenCheatManagerForCurrentApp"
+ Header="{locale:Locale GameListContextMenuManageCheat}"
+ IsEnabled="{Binding IsGameRunning}" />
+ </MenuItem>
+ <MenuItem VerticalAlignment="Center" Header="{locale:Locale MenuBarTools}">
+ <MenuItem Header="{locale:Locale MenuBarToolsInstallFirmware}" IsEnabled="{Binding EnableNonGameRunningControls}">
+ <MenuItem Command="{ReflectionBinding InstallFirmwareFromFile}" Header="{locale:Locale MenuBarFileToolsInstallFirmwareFromFile}" />
+ <MenuItem Command="{ReflectionBinding InstallFirmwareFromFolder}" Header="{locale:Locale MenuBarFileToolsInstallFirmwareFromDirectory}" />
+ </MenuItem>
+ <MenuItem Header="{locale:Locale MenuBarToolsManageFileTypes}" IsVisible="{Binding ManageFileTypesVisible}">
+ <MenuItem Header="{locale:Locale MenuBarToolsInstallFileTypes}" Click="InstallFileTypes_Click"/>
+ <MenuItem Header="{locale:Locale MenuBarToolsUninstallFileTypes}" Click="UninstallFileTypes_Click"/>
+ </MenuItem>
+ </MenuItem>
+ <MenuItem VerticalAlignment="Center" Header="{locale:Locale MenuBarHelp}">
+ <MenuItem
+ Name="UpdateMenuItem"
+ IsEnabled="{Binding CanUpdate}"
+ Click="CheckForUpdates"
+ Header="{locale:Locale MenuBarHelpCheckForUpdates}"
+ ToolTip.Tip="{locale:Locale CheckUpdatesTooltip}" />
+ <Separator />
+ <MenuItem
+ Click="OpenAboutWindow"
+ Header="{locale:Locale MenuBarHelpAbout}"
+ ToolTip.Tip="{locale:Locale OpenAboutTooltip}" />
+ </MenuItem>
+ </Menu>
+ </DockPanel>
+</UserControl>
diff --git a/src/Ryujinx.Ava/UI/Views/Main/MainMenuBarView.axaml.cs b/src/Ryujinx.Ava/UI/Views/Main/MainMenuBarView.axaml.cs
new file mode 100644
index 00000000..bdf2cf9f
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Main/MainMenuBarView.axaml.cs
@@ -0,0 +1,236 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using LibHac.Ncm;
+using LibHac.Tools.FsSystem.NcaUtils;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.Ava.UI.Windows;
+using Ryujinx.Common;
+using Ryujinx.Common.Utilities;
+using Ryujinx.HLE.HOS;
+using Ryujinx.Modules;
+using Ryujinx.Ui.Common;
+using Ryujinx.Ui.Common.Configuration;
+using Ryujinx.Ui.Common.Helper;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Ryujinx.Ava.UI.Views.Main
+{
+ public partial class MainMenuBarView : UserControl
+ {
+ public MainWindow Window { get; private set; }
+ public MainWindowViewModel ViewModel { get; private set; }
+
+ public MainMenuBarView()
+ {
+ InitializeComponent();
+
+ ToggleFileTypesMenuItem.Items = GenerateToggleFileTypeItems();
+ ChangeLanguageMenuItem.Items = GenerateLanguageMenuItems();
+ }
+
+ private CheckBox[] GenerateToggleFileTypeItems()
+ {
+ List<CheckBox> checkBoxes = new();
+
+ foreach (var item in Enum.GetValues(typeof (FileTypes)))
+ {
+ string fileName = Enum.GetName(typeof (FileTypes), item);
+ checkBoxes.Add(new CheckBox()
+ {
+ Content = $".{fileName}",
+ IsChecked = ((FileTypes)item).GetConfigValue(ConfigurationState.Instance.Ui.ShownFileTypes),
+ Command = MiniCommand.Create(() => ViewModel.ToggleFileType(fileName))
+ });
+ }
+
+ return checkBoxes.ToArray();
+ }
+
+ private MenuItem[] GenerateLanguageMenuItems()
+ {
+ List<MenuItem> menuItems = new();
+
+ string localePath = "Ryujinx.Ava/Assets/Locales";
+ string localeExt = ".json";
+
+ string[] localesPath = EmbeddedResources.GetAllAvailableResources(localePath, localeExt);
+
+ Array.Sort(localesPath);
+
+ foreach (string locale in localesPath)
+ {
+ string languageCode = Path.GetFileNameWithoutExtension(locale).Split('.').Last();
+ string languageJson = EmbeddedResources.ReadAllText($"{localePath}/{languageCode}{localeExt}");
+ var strings = JsonHelper.Deserialize(languageJson, CommonJsonContext.Default.StringDictionary);
+
+ if (!strings.TryGetValue("Language", out string languageName))
+ {
+ languageName = languageCode;
+ }
+
+ MenuItem menuItem = new()
+ {
+ Header = languageName,
+ Command = MiniCommand.Create(() =>
+ {
+ ViewModel.ChangeLanguage(languageCode);
+ })
+ };
+
+ menuItems.Add(menuItem);
+ }
+
+ return menuItems.ToArray();
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+
+ if (VisualRoot is MainWindow window)
+ {
+ Window = window;
+ }
+
+ ViewModel = Window.ViewModel;
+ DataContext = ViewModel;
+ }
+
+ private async void StopEmulation_Click(object sender, RoutedEventArgs e)
+ {
+ await Window.ViewModel.AppHost?.ShowExitPrompt();
+ }
+
+ private async void PauseEmulation_Click(object sender, RoutedEventArgs e)
+ {
+ await Task.Run(() =>
+ {
+ Window.ViewModel.AppHost?.Pause();
+ });
+ }
+
+ private async void ResumeEmulation_Click(object sender, RoutedEventArgs e)
+ {
+ await Task.Run(() =>
+ {
+ Window.ViewModel.AppHost?.Resume();
+ });
+ }
+
+ public async void OpenSettings(object sender, RoutedEventArgs e)
+ {
+ Window.SettingsWindow = new(Window.VirtualFileSystem, Window.ContentManager);
+
+ await Window.SettingsWindow.ShowDialog(Window);
+
+ ViewModel.LoadConfigurableHotKeys();
+ }
+
+ public void OpenMiiApplet(object sender, RoutedEventArgs e)
+ {
+ string contentPath = ViewModel.ContentManager.GetInstalledContentPath(0x0100000000001009, StorageId.BuiltInSystem, NcaContentType.Program);
+
+ if (!string.IsNullOrEmpty(contentPath))
+ {
+ ViewModel.LoadApplication(contentPath, false, "Mii Applet");
+ }
+ }
+
+ public async void OpenAmiiboWindow(object sender, RoutedEventArgs e)
+ {
+ if (!ViewModel.IsAmiiboRequested)
+ {
+ return;
+ }
+
+ if (ViewModel.AppHost.Device.System.SearchingForAmiibo(out int deviceId))
+ {
+ string titleId = ViewModel.AppHost.Device.Processes.ActiveApplication.ProgramIdText.ToUpper();
+ AmiiboWindow window = new(ViewModel.ShowAll, ViewModel.LastScannedAmiiboId, titleId);
+
+ await window.ShowDialog(Window);
+
+ if (window.IsScanned)
+ {
+ ViewModel.ShowAll = window.ViewModel.ShowAllAmiibo;
+ ViewModel.LastScannedAmiiboId = window.ScannedAmiibo.GetId();
+
+ ViewModel.AppHost.Device.System.ScanAmiibo(deviceId, ViewModel.LastScannedAmiiboId, window.ViewModel.UseRandomUuid);
+ }
+ }
+ }
+
+ public async void OpenCheatManagerForCurrentApp(object sender, RoutedEventArgs e)
+ {
+ if (!ViewModel.IsGameRunning)
+ {
+ return;
+ }
+
+ string name = ViewModel.AppHost.Device.Processes.ActiveApplication.ApplicationControlProperties.Title[(int)ViewModel.AppHost.Device.System.State.DesiredTitleLanguage].NameString.ToString();
+
+ await new CheatWindow(Window.VirtualFileSystem, ViewModel.AppHost.Device.Processes.ActiveApplication.ProgramIdText, name).ShowDialog(Window);
+
+ ViewModel.AppHost.Device.EnableCheats();
+ }
+
+ private void ScanAmiiboMenuItem_AttachedToVisualTree(object sender, VisualTreeAttachmentEventArgs e)
+ {
+ if (sender is MenuItem)
+ {
+ ViewModel.IsAmiiboRequested = Window.ViewModel.AppHost.Device.System.SearchingForAmiibo(out _);
+ }
+ }
+
+ private async void InstallFileTypes_Click(object sender, RoutedEventArgs e)
+ {
+ if (FileAssociationHelper.Install())
+ {
+ await ContentDialogHelper.CreateInfoDialog(LocaleManager.Instance[LocaleKeys.DialogInstallFileTypesSuccessMessage],
+ string.Empty, LocaleManager.Instance[LocaleKeys.InputDialogOk], string.Empty, string.Empty);
+ }
+ else
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogInstallFileTypesErrorMessage]);
+ }
+ }
+
+ private async void UninstallFileTypes_Click(object sender, RoutedEventArgs e)
+ {
+ if (FileAssociationHelper.Uninstall())
+ {
+ await ContentDialogHelper.CreateInfoDialog(LocaleManager.Instance[LocaleKeys.DialogUninstallFileTypesSuccessMessage],
+ string.Empty, LocaleManager.Instance[LocaleKeys.InputDialogOk], string.Empty, string.Empty);
+ }
+ else
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogUninstallFileTypesErrorMessage]);
+ }
+ }
+
+ public async void CheckForUpdates(object sender, RoutedEventArgs e)
+ {
+ if (Updater.CanUpdate(true))
+ {
+ await Updater.BeginParse(Window, true);
+ }
+ }
+
+ public async void OpenAboutWindow(object sender, RoutedEventArgs e)
+ {
+ await AboutWindow.Show();
+ }
+
+ public void CloseWindow(object sender, RoutedEventArgs e)
+ {
+ Window.Close();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Main/MainStatusBarView.axaml b/src/Ryujinx.Ava/UI/Views/Main/MainStatusBarView.axaml
new file mode 100644
index 00000000..16705695
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Main/MainStatusBarView.axaml
@@ -0,0 +1,232 @@
+<UserControl
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+ x:Class="Ryujinx.Ava.UI.Views.Main.MainStatusBarView"
+ x:CompileBindings="True"
+ x:DataType="viewModels:MainWindowViewModel">
+ <Design.DataContext>
+ <viewModels:MainWindowViewModel />
+ </Design.DataContext>
+ <Grid
+ Name="StatusBar"
+ Margin="0"
+ MinHeight="22"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Bottom"
+ Background="{DynamicResource ThemeContentBackgroundColor}"
+ DockPanel.Dock="Bottom"
+ IsVisible="{Binding ShowMenuAndStatusBar}">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition Width="*" />
+ <ColumnDefinition Width="Auto" />
+ </Grid.ColumnDefinitions>
+ <StackPanel
+ Grid.Column="0"
+ Margin="5"
+ VerticalAlignment="Center"
+ IsVisible="{Binding EnableNonGameRunningControls}">
+ <Grid Margin="0">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition />
+ </Grid.ColumnDefinitions>
+ <Button
+ Width="25"
+ Height="25"
+ MinWidth="0"
+ Margin="0,0,5,0"
+ VerticalAlignment="Center"
+ Background="Transparent"
+ Command="{ReflectionBinding LoadApplications}">
+ <ui:SymbolIcon
+ Width="50"
+ Height="100"
+ Symbol="Refresh" />
+ </Button>
+ <TextBlock
+ Name="LoadStatus"
+ Grid.Column="1"
+ Margin="0,0,5,0"
+ VerticalAlignment="Center"
+ IsVisible="{Binding EnableNonGameRunningControls}"
+ Text="{locale:Locale StatusBarGamesLoaded}" />
+ <ProgressBar
+ Name="LoadProgressBar"
+ Grid.Column="2"
+ Height="6"
+ VerticalAlignment="Center"
+ Foreground="{DynamicResource HighlightColor}"
+ IsVisible="{Binding StatusBarVisible}"
+ Maximum="{Binding StatusBarProgressMaximum}"
+ Value="{Binding StatusBarProgressValue}" />
+ </Grid>
+ </StackPanel>
+ <StackPanel
+ Grid.Column="1"
+ Margin="0,2"
+ HorizontalAlignment="Left"
+ VerticalAlignment="Center"
+ IsVisible="{Binding IsGameRunning}"
+ MaxHeight="18"
+ Orientation="Horizontal">
+ <TextBlock
+ Name="VsyncStatus"
+ Margin="5,0,5,0"
+ HorizontalAlignment="Left"
+ VerticalAlignment="Center"
+ Foreground="{Binding VsyncColor}"
+ IsVisible="{Binding !ShowLoadProgress}"
+ PointerReleased="VsyncStatus_PointerReleased"
+ Text="VSync"
+ TextAlignment="Left" />
+ <Border
+ Width="2"
+ Height="12"
+ Margin="0"
+ BorderBrush="Gray"
+ BorderThickness="1"
+ IsVisible="{Binding !ShowLoadProgress}" />
+ <TextBlock
+ Name="DockedStatus"
+ Margin="5,0,5,0"
+ HorizontalAlignment="Left"
+ VerticalAlignment="Center"
+ IsVisible="{Binding !ShowLoadProgress}"
+ PointerReleased="DockedStatus_PointerReleased"
+ Text="{Binding DockedStatusText}"
+ TextAlignment="Left" />
+ <Border
+ Width="2"
+ Height="12"
+ Margin="0"
+ BorderBrush="Gray"
+ BorderThickness="1"
+ IsVisible="{Binding !ShowLoadProgress}" />
+ <TextBlock
+ Name="AspectRatioStatus"
+ Margin="5,0,5,0"
+ HorizontalAlignment="Left"
+ VerticalAlignment="Center"
+ IsVisible="{Binding !ShowLoadProgress}"
+ PointerReleased="AspectRatioStatus_PointerReleased"
+ Text="{Binding AspectRatioStatusText}"
+ TextAlignment="Left" />
+ <Border
+ Width="2"
+ Height="12"
+ Margin="0"
+ BorderBrush="Gray"
+ BorderThickness="1"
+ IsVisible="{Binding !ShowLoadProgress}" />
+ <ui:ToggleSplitButton
+ Name="VolumeStatus"
+ Padding="5,0,5,0"
+ HorizontalAlignment="Left"
+ VerticalAlignment="Center"
+ VerticalContentAlignment="Center"
+ Background="{DynamicResource ThemeContentBackgroundColor}"
+ BorderThickness="0"
+ Content="{Binding VolumeStatusText}"
+ IsChecked="{Binding VolumeMuted}"
+ IsVisible="{Binding !ShowLoadProgress}">
+ <ui:ToggleSplitButton.Flyout>
+ <Flyout Placement="Bottom" ShowMode="TransientWithDismissOnPointerMoveAway">
+ <Grid Margin="0">
+ <Slider
+ MaxHeight="40"
+ Width="150"
+ Margin="0"
+ Padding="0"
+ IsSnapToTickEnabled="True"
+ LargeChange="0.05"
+ Maximum="1"
+ Minimum="0"
+ SmallChange="0.01"
+ TickFrequency="0.05"
+ ToolTip.Tip="{locale:Locale AudioVolumeTooltip}"
+ Value="{Binding Volume}" />
+ </Grid>
+ </Flyout>
+ </ui:ToggleSplitButton.Flyout>
+ </ui:ToggleSplitButton>
+ <Border
+ Width="2"
+ Height="12"
+ Margin="0"
+ BorderBrush="Gray"
+ BorderThickness="1"
+ IsVisible="{Binding !ShowLoadProgress}" />
+ <TextBlock
+ Margin="5,0,5,0"
+ HorizontalAlignment="Left"
+ VerticalAlignment="Center"
+ IsVisible="{Binding !ShowLoadProgress}"
+ Text="{Binding GameStatusText}"
+ TextAlignment="Left" />
+ <Border
+ Width="2"
+ Height="12"
+ Margin="0"
+ BorderBrush="Gray"
+ BorderThickness="1"
+ IsVisible="{Binding !ShowLoadProgress}" />
+ <TextBlock
+ Margin="5,0,5,0"
+ HorizontalAlignment="Left"
+ VerticalAlignment="Center"
+ IsVisible="{Binding !ShowLoadProgress}"
+ Text="{Binding FifoStatusText}"
+ TextAlignment="Left" />
+ <Border
+ Width="2"
+ Height="12"
+ Margin="0"
+ BorderBrush="Gray"
+ BorderThickness="1"
+ IsVisible="{Binding !ShowLoadProgress}" />
+ <TextBlock
+ Margin="5,0,5,0"
+ HorizontalAlignment="Left"
+ VerticalAlignment="Center"
+ IsVisible="{Binding !ShowLoadProgress}"
+ Text="{Binding BackendText}"
+ TextAlignment="Left" />
+ <Border
+ Width="2"
+ Height="12"
+ Margin="0"
+ BorderBrush="Gray"
+ BorderThickness="1"
+ IsVisible="{Binding !ShowLoadProgress}" />
+ <TextBlock
+ Margin="5,0,5,0"
+ HorizontalAlignment="Left"
+ VerticalAlignment="Center"
+ IsVisible="{Binding !ShowLoadProgress}"
+ Text="{Binding GpuNameText}"
+ TextAlignment="Left" />
+ </StackPanel>
+ <StackPanel
+ Grid.Column="3"
+ Margin="0,0,5,0"
+ VerticalAlignment="Center"
+ IsVisible="{Binding ShowFirmwareStatus}"
+ Orientation="Horizontal">
+ <TextBlock
+ Name="FirmwareStatus"
+ Margin="0"
+ HorizontalAlignment="Right"
+ VerticalAlignment="Center"
+ Text="{locale:Locale StatusBarSystemVersion}" />
+ </StackPanel>
+ </Grid>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Main/MainStatusBarView.axaml.cs b/src/Ryujinx.Ava/UI/Views/Main/MainStatusBarView.axaml.cs
new file mode 100644
index 00000000..473de0ee
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Main/MainStatusBarView.axaml.cs
@@ -0,0 +1,52 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Ryujinx.Ava.UI.Windows;
+using Ryujinx.Common.Configuration;
+using Ryujinx.Common.Logging;
+using Ryujinx.Ui.Common.Configuration;
+using System;
+
+namespace Ryujinx.Ava.UI.Views.Main
+{
+ public partial class MainStatusBarView : UserControl
+ {
+ public MainWindow Window;
+
+ public MainStatusBarView()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+
+ if (VisualRoot is MainWindow window)
+ {
+ Window = window;
+ }
+
+ DataContext = Window.ViewModel;
+ }
+
+ private void VsyncStatus_PointerReleased(object sender, PointerReleasedEventArgs e)
+ {
+ Window.ViewModel.AppHost.Device.EnableDeviceVsync = !Window.ViewModel.AppHost.Device.EnableDeviceVsync;
+
+ Logger.Info?.Print(LogClass.Application, $"VSync toggled to: {Window.ViewModel.AppHost.Device.EnableDeviceVsync}");
+ }
+
+ private void DockedStatus_PointerReleased(object sender, PointerReleasedEventArgs e)
+ {
+ ConfigurationState.Instance.System.EnableDockedMode.Value = !ConfigurationState.Instance.System.EnableDockedMode.Value;
+ }
+
+ private void AspectRatioStatus_PointerReleased(object sender, PointerReleasedEventArgs e)
+ {
+ AspectRatio aspectRatio = ConfigurationState.Instance.Graphics.AspectRatio.Value;
+
+ ConfigurationState.Instance.Graphics.AspectRatio.Value = (int)aspectRatio + 1 > Enum.GetNames(typeof(AspectRatio)).Length - 1 ? AspectRatio.Fixed4x3 : aspectRatio + 1;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Main/MainViewControls.axaml b/src/Ryujinx.Ava/UI/Views/Main/MainViewControls.axaml
new file mode 100644
index 00000000..ac8ede7a
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Main/MainViewControls.axaml
@@ -0,0 +1,175 @@
+<UserControl
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+ x:Class="Ryujinx.Ava.UI.Views.Main.MainViewControls"
+ x:CompileBindings="True"
+ x:DataType="viewModels:MainWindowViewModel">
+ <Design.DataContext>
+ <viewModels:MainWindowViewModel />
+ </Design.DataContext>
+ <DockPanel
+ Margin="0,0,0,5"
+ HorizontalAlignment="Stretch">
+ <Button
+ Width="40"
+ MinWidth="40"
+ Margin="5,2,0,2"
+ VerticalAlignment="Stretch"
+ Command="{ReflectionBinding SetListMode}"
+ IsEnabled="{Binding IsGrid}">
+ <ui:FontIcon
+ Margin="0"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Center"
+ FontFamily="avares://FluentAvalonia/Fonts#Symbols"
+ Glyph="{helpers:GlyphValueConverter List}" />
+ </Button>
+ <Button
+ Width="40"
+ MinWidth="40"
+ Margin="5,2,5,2"
+ VerticalAlignment="Stretch"
+ Command="{ReflectionBinding SetGridMode}"
+ IsEnabled="{Binding IsList}">
+ <ui:FontIcon
+ Margin="0"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Center"
+ FontFamily="avares://FluentAvalonia/Fonts#Symbols"
+ Glyph="{helpers:GlyphValueConverter Grid}" />
+ </Button>
+ <TextBlock
+ Margin="10,0"
+ VerticalAlignment="Center"
+ Text="{locale:Locale IconSize}"
+ ToolTip.Tip="{locale:Locale IconSizeTooltip}" />
+ <Slider
+ Width="150"
+ Height="35"
+ Margin="5,-10,5,0"
+ VerticalAlignment="Center"
+ IsSnapToTickEnabled="True"
+ Maximum="4"
+ Minimum="1"
+ TickFrequency="1"
+ ToolTip.Tip="{locale:Locale IconSizeTooltip}"
+ Value="{Binding GridSizeScale}" />
+ <CheckBox
+ Margin="0"
+ VerticalAlignment="Center"
+ IsChecked="{Binding ShowNames, Mode=TwoWay}"
+ IsVisible="{Binding IsGrid}">
+ <TextBlock Margin="5,3,0,0" Text="{locale:Locale CommonShowNames}" />
+ </CheckBox>
+ <TextBox
+ Name="SearchBox"
+ MinWidth="200"
+ Margin="5,0,5,0"
+ HorizontalAlignment="Right"
+ VerticalAlignment="Center"
+ DockPanel.Dock="Right"
+ KeyUp="SearchBox_OnKeyUp"
+ Text="{Binding SearchText}"
+ Watermark="{locale:Locale MenuSearch}" />
+ <ui:DropDownButton
+ Width="150"
+ HorizontalAlignment="Right"
+ VerticalAlignment="Center"
+ Content="{Binding SortName}"
+ DockPanel.Dock="Right">
+ <ui:DropDownButton.Flyout>
+ <Flyout Placement="Bottom">
+ <StackPanel
+ Margin="0"
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical">
+ <StackPanel>
+ <RadioButton
+ Checked="Sort_Checked"
+ Content="{locale:Locale CommonFavorite}"
+ GroupName="Sort"
+ IsChecked="{Binding IsSortedByFavorite, Mode=OneTime}"
+ Tag="Favorite" />
+ <RadioButton
+ Checked="Sort_Checked"
+ Content="{locale:Locale GameListHeaderApplication}"
+ GroupName="Sort"
+ IsChecked="{Binding IsSortedByTitle, Mode=OneTime}"
+ Tag="Title" />
+ <RadioButton
+ Checked="Sort_Checked"
+ Content="{locale:Locale GameListHeaderDeveloper}"
+ GroupName="Sort"
+ IsChecked="{Binding IsSortedByDeveloper, Mode=OneTime}"
+ Tag="Developer" />
+ <RadioButton
+ Checked="Sort_Checked"
+ Content="{locale:Locale GameListHeaderTimePlayed}"
+ GroupName="Sort"
+ IsChecked="{Binding IsSortedByTimePlayed, Mode=OneTime}"
+ Tag="TotalTimePlayed" />
+ <RadioButton
+ Checked="Sort_Checked"
+ Content="{locale:Locale GameListHeaderLastPlayed}"
+ GroupName="Sort"
+ IsChecked="{Binding IsSortedByLastPlayed, Mode=OneTime}"
+ Tag="LastPlayed" />
+ <RadioButton
+ Checked="Sort_Checked"
+ Content="{locale:Locale GameListHeaderFileExtension}"
+ GroupName="Sort"
+ IsChecked="{Binding IsSortedByType, Mode=OneTime}"
+ Tag="FileType" />
+ <RadioButton
+ Checked="Sort_Checked"
+ Content="{locale:Locale GameListHeaderFileSize}"
+ GroupName="Sort"
+ IsChecked="{Binding IsSortedBySize, Mode=OneTime}"
+ Tag="FileSize" />
+ <RadioButton
+ Checked="Sort_Checked"
+ Content="{locale:Locale GameListHeaderPath}"
+ GroupName="Sort"
+ IsChecked="{Binding IsSortedByPath, Mode=OneTime}"
+ Tag="Path" />
+ </StackPanel>
+ <Border
+ Width="60"
+ Height="2"
+ Margin="5"
+ HorizontalAlignment="Stretch"
+ BorderBrush="White"
+ BorderThickness="0,1,0,0">
+ <Separator Height="0" HorizontalAlignment="Stretch" />
+ </Border>
+ <RadioButton
+ Checked="Order_Checked"
+ Content="{locale:Locale OrderAscending}"
+ GroupName="Order"
+ IsChecked="{Binding IsAscending, Mode=OneTime}"
+ Tag="Ascending" />
+ <RadioButton
+ Checked="Order_Checked"
+ Content="{locale:Locale OrderDescending}"
+ GroupName="Order"
+ IsChecked="{Binding !IsAscending, Mode=OneTime}"
+ Tag="Descending" />
+ </StackPanel>
+ </Flyout>
+ </ui:DropDownButton.Flyout>
+ </ui:DropDownButton>
+ <TextBlock
+ Margin="10,0"
+ HorizontalAlignment="Right"
+ VerticalAlignment="Center"
+ DockPanel.Dock="Right"
+ Text="{locale:Locale CommonSort}" />
+ </DockPanel>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Main/MainViewControls.axaml.cs b/src/Ryujinx.Ava/UI/Views/Main/MainViewControls.axaml.cs
new file mode 100644
index 00000000..fd757893
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Main/MainViewControls.axaml.cs
@@ -0,0 +1,54 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Ryujinx.Ava.Common;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.Ava.UI.Windows;
+using System;
+
+namespace Ryujinx.Ava.UI.Views.Main
+{
+ public partial class MainViewControls : UserControl
+ {
+ public MainWindowViewModel ViewModel;
+
+ public MainViewControls()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+
+ if (VisualRoot is MainWindow window)
+ {
+ ViewModel = window.ViewModel;
+ }
+
+ DataContext = ViewModel;
+ }
+
+ public void Sort_Checked(object sender, RoutedEventArgs args)
+ {
+ if (sender is RadioButton button)
+ {
+ ViewModel.Sort(Enum.Parse<ApplicationSort>(button.Tag.ToString()));
+ }
+ }
+
+ public void Order_Checked(object sender, RoutedEventArgs args)
+ {
+ if (sender is RadioButton button)
+ {
+ ViewModel.Sort(button.Tag.ToString() != "Descending");
+ }
+ }
+
+ private void SearchBox_OnKeyUp(object sender, KeyEventArgs e)
+ {
+ ViewModel.SearchText = SearchBox.Text;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsAudioView.axaml b/src/Ryujinx.Ava/UI/Views/Settings/SettingsAudioView.axaml
new file mode 100644
index 00000000..35283353
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsAudioView.axaml
@@ -0,0 +1,81 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Views.Settings.SettingsAudioView"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ mc:Ignorable="d"
+ x:CompileBindings="True"
+ x:DataType="viewModels:SettingsViewModel">
+ <Design.DataContext>
+ <viewModels:SettingsViewModel />
+ </Design.DataContext>
+ <ScrollViewer
+ Name="AudioPage"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ HorizontalScrollBarVisibility="Disabled"
+ VerticalScrollBarVisibility="Auto">
+ <Border Classes="settings">
+ <StackPanel
+ Margin="10"
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical"
+ Spacing="10">
+ <TextBlock Classes="h1" Text="{locale:Locale SettingsTabAudio}" />
+ <StackPanel Margin="10,0,0,0" Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center"
+ Text="{locale:Locale SettingsTabSystemAudioBackend}"
+ ToolTip.Tip="{locale:Locale AudioBackendTooltip}"
+ Width="250" />
+ <ComboBox SelectedIndex="{Binding AudioBackend}"
+ Width="350"
+ HorizontalContentAlignment="Left">
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemAudioBackendDummy}" />
+ </ComboBoxItem>
+ <ComboBoxItem IsEnabled="{Binding IsOpenAlEnabled}">
+ <TextBlock Text="{locale:Locale SettingsTabSystemAudioBackendOpenAL}" />
+ </ComboBoxItem>
+ <ComboBoxItem IsEnabled="{Binding IsSoundIoEnabled}">
+ <TextBlock Text="{locale:Locale SettingsTabSystemAudioBackendSoundIO}" />
+ </ComboBoxItem>
+ <ComboBoxItem IsEnabled="{Binding IsSDL2Enabled}">
+ <TextBlock Text="{locale:Locale SettingsTabSystemAudioBackendSDL2}" />
+ </ComboBoxItem>
+ </ComboBox>
+ </StackPanel>
+ <StackPanel Margin="10,0,0,0" Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center"
+ Text="{locale:Locale SettingsTabSystemAudioVolume}"
+ ToolTip.Tip="{locale:Locale AudioVolumeTooltip}"
+ Width="250" />
+ <ui:NumberBox Value="{Binding Volume}"
+ ToolTip.Tip="{locale:Locale AudioVolumeTooltip}"
+ Width="350"
+ SmallChange="1"
+ LargeChange="10"
+ SimpleNumberFormat="F0"
+ SpinButtonPlacementMode="Inline"
+ Minimum="0"
+ Maximum="100" />
+ </StackPanel>
+ <StackPanel Margin="10,0,0,0" Orientation="Horizontal">
+ <Slider Value="{Binding Volume}"
+ Margin="250,0,0,0"
+ ToolTip.Tip="{locale:Locale AudioVolumeTooltip}"
+ Minimum="0"
+ Maximum="100"
+ SmallChange="5"
+ TickFrequency="5"
+ IsSnapToTickEnabled="True"
+ LargeChange="10"
+ Width="350" />
+ </StackPanel>
+ </StackPanel>
+ </Border>
+ </ScrollViewer>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsAudioView.axaml.cs b/src/Ryujinx.Ava/UI/Views/Settings/SettingsAudioView.axaml.cs
new file mode 100644
index 00000000..026c7fdf
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsAudioView.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.Controls;
+
+namespace Ryujinx.Ava.UI.Views.Settings
+{
+ public partial class SettingsAudioView : UserControl
+ {
+ public SettingsAudioView()
+ {
+ InitializeComponent();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsCPUView.axaml b/src/Ryujinx.Ava/UI/Views/Settings/SettingsCPUView.axaml
new file mode 100644
index 00000000..e98b963c
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsCPUView.axaml
@@ -0,0 +1,78 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Views.Settings.SettingsCPUView"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ mc:Ignorable="d"
+ x:CompileBindings="True"
+ x:DataType="viewModels:SettingsViewModel">
+ <Design.DataContext>
+ <viewModels:SettingsViewModel />
+ </Design.DataContext>
+ <ScrollViewer
+ Name="CpuPage"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ HorizontalScrollBarVisibility="Disabled"
+ VerticalScrollBarVisibility="Auto">
+ <Border Classes="settings">
+ <StackPanel
+ Margin="10"
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical"
+ Spacing="10">
+ <TextBlock Classes="h1" Text="{locale:Locale SettingsTabCpuCache}" />
+ <StackPanel
+ Margin="10,0,0,0"
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical">
+ <CheckBox IsChecked="{Binding EnablePptc}">
+ <TextBlock Text="{locale:Locale SettingsTabSystemEnablePptc}"
+ ToolTip.Tip="{locale:Locale PptcToggleTooltip}" />
+ </CheckBox>
+ </StackPanel>
+ <Separator Height="1" />
+ <TextBlock Classes="h1" Text="{locale:Locale SettingsTabCpuMemory}" />
+ <StackPanel
+ Margin="10,0,0,0"
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical">
+ <StackPanel Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center"
+ Text="{locale:Locale SettingsTabSystemMemoryManagerMode}"
+ ToolTip.Tip="{locale:Locale MemoryManagerTooltip}"
+ Width="250" />
+ <ComboBox SelectedIndex="{Binding MemoryMode}"
+ ToolTip.Tip="{locale:Locale MemoryManagerTooltip}"
+ HorizontalContentAlignment="Left"
+ Width="350">
+ <ComboBoxItem
+ ToolTip.Tip="{locale:Locale MemoryManagerSoftwareTooltip}">
+ <TextBlock
+ Text="{locale:Locale SettingsTabSystemMemoryManagerModeSoftware}" />
+ </ComboBoxItem>
+ <ComboBoxItem
+ ToolTip.Tip="{locale:Locale MemoryManagerHostTooltip}">
+ <TextBlock Text="{locale:Locale SettingsTabSystemMemoryManagerModeHost}" />
+ </ComboBoxItem>
+ <ComboBoxItem
+ ToolTip.Tip="{locale:Locale MemoryManagerUnsafeTooltip}">
+ <TextBlock
+ Text="{locale:Locale SettingsTabSystemMemoryManagerModeHostUnchecked}" />
+ </ComboBoxItem>
+ </ComboBox>
+ </StackPanel>
+ <CheckBox IsChecked="{Binding UseHypervisor}"
+ IsVisible="{Binding IsHypervisorAvailable}"
+ ToolTip.Tip="{locale:Locale UseHypervisorTooltip}">
+ <TextBlock Text="{locale:Locale SettingsTabSystemUseHypervisor}"
+ ToolTip.Tip="{locale:Locale UseHypervisorTooltip}" />
+ </CheckBox>
+ </StackPanel>
+ </StackPanel>
+ </Border>
+ </ScrollViewer>
+</UserControl>
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsCPUView.axaml.cs b/src/Ryujinx.Ava/UI/Views/Settings/SettingsCPUView.axaml.cs
new file mode 100644
index 00000000..5c5b6079
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsCPUView.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.Controls;
+
+namespace Ryujinx.Ava.UI.Views.Settings
+{
+ public partial class SettingsCPUView : UserControl
+ {
+ public SettingsCPUView()
+ {
+ InitializeComponent();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsGraphicsView.axaml b/src/Ryujinx.Ava/UI/Views/Settings/SettingsGraphicsView.axaml
new file mode 100644
index 00000000..8e4122f3
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsGraphicsView.axaml
@@ -0,0 +1,296 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Views.Settings.SettingsGraphicsView"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ Design.Width="1000"
+ mc:Ignorable="d"
+ x:CompileBindings="True"
+ x:DataType="viewModels:SettingsViewModel">
+ <Design.DataContext>
+ <viewModels:SettingsViewModel />
+ </Design.DataContext>
+ <ScrollViewer
+ Name="GraphicsPage"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ HorizontalScrollBarVisibility="Disabled"
+ VerticalScrollBarVisibility="Auto">
+ <Border Classes="settings">
+ <StackPanel
+ Margin="10"
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical"
+ Spacing="10">
+ <TextBlock Classes="h1" Text="{locale:Locale SettingsTabGraphicsAPI}" />
+ <StackPanel Margin="10,0,0,0" Orientation="Vertical" Spacing="10">
+ <StackPanel Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center"
+ ToolTip.Tip="{locale:Locale SettingsTabGraphicsBackendTooltip}"
+ Text="{locale:Locale SettingsTabGraphicsBackend}"
+ Width="250" />
+ <ComboBox Width="350"
+ HorizontalContentAlignment="Left"
+ ToolTip.Tip="{locale:Locale SettingsTabGraphicsBackendTooltip}"
+ SelectedIndex="{Binding GraphicsBackendIndex}">
+ <ComboBoxItem IsVisible="{Binding IsVulkanAvailable}">
+ <TextBlock Text="Vulkan" />
+ </ComboBoxItem>
+ <ComboBoxItem IsEnabled="{Binding IsOpenGLAvailable}">
+ <TextBlock Text="OpenGL" />
+ </ComboBoxItem>
+ </ComboBox>
+ </StackPanel>
+ <StackPanel Orientation="Horizontal" IsVisible="{Binding IsVulkanSelected}">
+ <TextBlock VerticalAlignment="Center"
+ ToolTip.Tip="{locale:Locale SettingsTabGraphicsPreferredGpuTooltip}"
+ Text="{locale:Locale SettingsTabGraphicsPreferredGpu}"
+ Width="250" />
+ <ComboBox Width="350"
+ HorizontalContentAlignment="Left"
+ ToolTip.Tip="{locale:Locale SettingsTabGraphicsPreferredGpuTooltip}"
+ SelectedIndex="{Binding PreferredGpuIndex}"
+ Items="{Binding AvailableGpus}"/>
+ </StackPanel>
+ </StackPanel>
+ <Separator Height="1" />
+ <TextBlock Classes="h1" Text="{locale:Locale SettingsTabGraphicsFeatures}" />
+ <StackPanel Margin="10,0,0,0" Orientation="Vertical" Spacing="10">
+ <StackPanel Orientation="Vertical">
+ <CheckBox IsChecked="{Binding EnableShaderCache}"
+ ToolTip.Tip="{locale:Locale ShaderCacheToggleTooltip}">
+ <TextBlock Text="{locale:Locale SettingsTabGraphicsEnableShaderCache}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding EnableTextureRecompression}"
+ ToolTip.Tip="{locale:Locale SettingsEnableTextureRecompressionTooltip}">
+ <TextBlock Text="{locale:Locale SettingsEnableTextureRecompression}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding EnableMacroHLE}"
+ ToolTip.Tip="{locale:Locale SettingsEnableMacroHLETooltip}">
+ <TextBlock Text="{locale:Locale SettingsEnableMacroHLE}" />
+ </CheckBox>
+ </StackPanel>
+ <StackPanel Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center"
+ ToolTip.Tip="{locale:Locale ResolutionScaleTooltip}"
+ Text="{locale:Locale SettingsTabGraphicsResolutionScale}"
+ Width="250" />
+ <ComboBox SelectedIndex="{Binding ResolutionScale}"
+ Width="350"
+ HorizontalContentAlignment="Left"
+ ToolTip.Tip="{locale:Locale ResolutionScaleTooltip}">
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabGraphicsResolutionScaleNative}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabGraphicsResolutionScale2x}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabGraphicsResolutionScale3x}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabGraphicsResolutionScale4x}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabGraphicsResolutionScaleCustom}" />
+ </ComboBoxItem>
+ </ComboBox>
+ <ui:NumberBox
+ Margin="10,0,0,0"
+ ToolTip.Tip="{locale:Locale ResolutionScaleEntryTooltip}"
+ MinWidth="150"
+ SmallChange="0.1"
+ LargeChange="1"
+ SimpleNumberFormat="F2"
+ SpinButtonPlacementMode="Inline"
+ IsVisible="{Binding IsCustomResolutionScaleActive}"
+ Maximum="100"
+ Minimum="0.1"
+ Value="{Binding CustomResolutionScale}" />
+ </StackPanel>
+ <StackPanel
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical"
+ Spacing="10">
+ <StackPanel Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center"
+ ToolTip.Tip="{locale:Locale GraphicsAATooltip}"
+ Text="{locale:Locale GraphicsAALabel}"
+ Width="250" />
+ <ComboBox Width="350"
+ HorizontalContentAlignment="Left"
+ ToolTip.Tip="{locale:Locale GraphicsAATooltip}"
+ SelectedIndex="{Binding AntiAliasingEffect}">
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabLoggingGraphicsBackendLogLevelNone}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="FXAA" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SmaaLow}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SmaaMedium}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SmaaHigh}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SmaaUltra}" />
+ </ComboBoxItem>
+ </ComboBox>
+ </StackPanel>
+ </StackPanel>
+ <StackPanel
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical"
+ Spacing="10">
+ <StackPanel Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center"
+ ToolTip.Tip="{locale:Locale GraphicsScalingFilterTooltip}"
+ Text="{locale:Locale GraphicsScalingFilterLabel}"
+ Width="250" />
+ <ComboBox Width="350"
+ HorizontalContentAlignment="Left"
+ ToolTip.Tip="{locale:Locale GraphicsScalingFilterTooltip}"
+ SelectedIndex="{Binding ScalingFilter}">
+ <ComboBoxItem>
+ <TextBlock Text="Bilinear" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="Nearest" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="FSR" />
+ </ComboBoxItem>
+ </ComboBox>
+ <Slider Value="{Binding ScalingFilterLevel}"
+ ToolTip.Tip="{locale:Locale GraphicsScalingFilterLevelTooltip}"
+ MinWidth="150"
+ Margin="10,-3,0,0"
+ Height="32"
+ Padding="0,-5"
+ IsVisible="{Binding IsScalingFilterActive}"
+ TickFrequency="1"
+ IsSnapToTickEnabled="True"
+ LargeChange="10"
+ SmallChange="1"
+ VerticalAlignment="Center"
+ Minimum="0"
+ Maximum="100" />
+ <TextBlock Margin="5,0"
+ Width="40"
+ IsVisible="{Binding IsScalingFilterActive}"
+ Text="{Binding ScalingFilterLevelText}"/>
+ </StackPanel>
+ </StackPanel>
+ <StackPanel Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center"
+ ToolTip.Tip="{locale:Locale AnisotropyTooltip}"
+ Text="{locale:Locale SettingsTabGraphicsAnisotropicFiltering}"
+ Width="250" />
+ <ComboBox SelectedIndex="{Binding MaxAnisotropy}"
+ Width="350"
+ HorizontalContentAlignment="Left"
+ ToolTip.Tip="{locale:Locale AnisotropyTooltip}">
+ <ComboBoxItem>
+ <TextBlock
+ Text="{locale:Locale SettingsTabGraphicsAnisotropicFilteringAuto}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabGraphicsAnisotropicFiltering2x}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabGraphicsAnisotropicFiltering4x}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabGraphicsAnisotropicFiltering8x}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock
+ Text="{locale:Locale SettingsTabGraphicsAnisotropicFiltering16x}" />
+ </ComboBoxItem>
+ </ComboBox>
+ </StackPanel>
+ <StackPanel Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center"
+ ToolTip.Tip="{locale:Locale AspectRatioTooltip}"
+ Text="{locale:Locale SettingsTabGraphicsAspectRatio}"
+ Width="250" />
+ <ComboBox SelectedIndex="{Binding AspectRatio}"
+ Width="350"
+ HorizontalContentAlignment="Left"
+ ToolTip.Tip="{locale:Locale AspectRatioTooltip}">
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabGraphicsAspectRatio4x3}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabGraphicsAspectRatio16x9}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabGraphicsAspectRatio16x10}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabGraphicsAspectRatio21x9}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabGraphicsAspectRatio32x9}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabGraphicsAspectRatioStretch}" />
+ </ComboBoxItem>
+ </ComboBox>
+ </StackPanel>
+ </StackPanel>
+ <StackPanel
+ Margin="10,0,0,0"
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical"
+ Spacing="10">
+ <StackPanel Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center"
+ ToolTip.Tip="{locale:Locale GraphicsBackendThreadingTooltip}"
+ Text="{locale:Locale SettingsTabGraphicsBackendMultithreading}"
+ Width="250" />
+ <ComboBox Width="350"
+ HorizontalContentAlignment="Left"
+ ToolTip.Tip="{locale:Locale GalThreadingTooltip}"
+ SelectedIndex="{Binding GraphicsBackendMultithreadingIndex}">
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale CommonAuto}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale CommonOff}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale CommonOn}" />
+ </ComboBoxItem>
+ </ComboBox>
+ </StackPanel>
+ </StackPanel>
+ <Separator Height="1" />
+ <TextBlock Classes="h1" Text="{locale:Locale SettingsTabGraphicsDeveloperOptions}" />
+ <StackPanel
+ Margin="10,0,0,0"
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical"
+ Spacing="10">
+ <StackPanel Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center"
+ ToolTip.Tip="{locale:Locale ShaderDumpPathTooltip}"
+ Text="{locale:Locale SettingsTabGraphicsShaderDumpPath}"
+ Width="250" />
+ <TextBox Text="{Binding ShaderDumpPath}"
+ Width="350"
+ ToolTip.Tip="{locale:Locale ShaderDumpPathTooltip}" />
+ </StackPanel>
+ </StackPanel>
+ </StackPanel>
+ </Border>
+ </ScrollViewer>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsGraphicsView.axaml.cs b/src/Ryujinx.Ava/UI/Views/Settings/SettingsGraphicsView.axaml.cs
new file mode 100644
index 00000000..8fe08552
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsGraphicsView.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.Controls;
+
+namespace Ryujinx.Ava.UI.Views.Settings
+{
+ public partial class SettingsGraphicsView : UserControl
+ {
+ public SettingsGraphicsView()
+ {
+ InitializeComponent();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsHotkeysView.axaml b/src/Ryujinx.Ava/UI/Views/Settings/SettingsHotkeysView.axaml
new file mode 100644
index 00000000..361125bf
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsHotkeysView.axaml
@@ -0,0 +1,104 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Views.Settings.SettingsHotkeysView"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
+ mc:Ignorable="d"
+ x:CompileBindings="True"
+ x:DataType="viewModels:SettingsViewModel"
+ Focusable="True">
+ <Design.DataContext>
+ <viewModels:SettingsViewModel />
+ </Design.DataContext>
+ <UserControl.Resources>
+ <helpers:KeyValueConverter x:Key="Key" />
+ </UserControl.Resources>
+ <ScrollViewer
+ Name="HotkeysPage"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ HorizontalScrollBarVisibility="Disabled"
+ VerticalScrollBarVisibility="Auto">
+ <Border Classes="settings">
+ <StackPanel Margin="10" Orientation="Vertical" Spacing="10">
+ <TextBlock Classes="h1" Text="{locale:Locale SettingsTabHotkeysHotkeys}" />
+ <StackPanel Margin="10,0,0,0" Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center" Text="{locale:Locale SettingsTabHotkeysToggleVsyncHotkey}" Width="230" />
+ <ToggleButton Width="90" Height="27" Checked="Button_Checked" Unchecked="Button_Unchecked">
+ <TextBlock
+ Text="{Binding KeyboardHotkeys.ToggleVsync, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <StackPanel Margin="10,0,0,0" Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center" Text="{locale:Locale SettingsTabHotkeysScreenshotHotkey}" Width="230" />
+ <ToggleButton Width="90" Height="27" Checked="Button_Checked" Unchecked="Button_Unchecked">
+ <TextBlock
+ Text="{Binding KeyboardHotkeys.Screenshot, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <StackPanel Margin="10,0,0,0" Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center" Text="{locale:Locale SettingsTabHotkeysShowUiHotkey}" Width="230" />
+ <ToggleButton Width="90" Height="27" Checked="Button_Checked" Unchecked="Button_Unchecked">
+ <TextBlock
+ Text="{Binding KeyboardHotkeys.ShowUi, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <StackPanel Margin="10,0,0,0" Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center" Text="{locale:Locale SettingsTabHotkeysPauseHotkey}" Width="230" />
+ <ToggleButton Width="90" Height="27" Checked="Button_Checked" Unchecked="Button_Unchecked">
+ <TextBlock
+ Text="{Binding KeyboardHotkeys.Pause, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <StackPanel Margin="10,0,0,0" Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center" Text="{locale:Locale SettingsTabHotkeysToggleMuteHotkey}" Width="230" />
+ <ToggleButton Width="90" Height="27" Checked="Button_Checked" Unchecked="Button_Unchecked">
+ <TextBlock
+ Text="{Binding KeyboardHotkeys.ToggleMute, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <StackPanel Margin="10,0,0,0" Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center" Text="{locale:Locale SettingsTabHotkeysResScaleUpHotkey}" Width="230" />
+ <ToggleButton Width="90" Height="27" Checked="Button_Checked" Unchecked="Button_Unchecked">
+ <TextBlock
+ Text="{Binding KeyboardHotkeys.ResScaleUp, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <StackPanel Margin="10,0,0,0" Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center" Text="{locale:Locale SettingsTabHotkeysResScaleDownHotkey}" Width="230" />
+ <ToggleButton Width="90" Height="27" Checked="Button_Checked" Unchecked="Button_Unchecked">
+ <TextBlock
+ Text="{Binding KeyboardHotkeys.ResScaleDown, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <StackPanel Margin="10,0,0,0" Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center" Text="{locale:Locale SettingsTabHotkeysVolumeUpHotkey}" Width="230" />
+ <ToggleButton Width="90" Height="27" Checked="Button_Checked" Unchecked="Button_Unchecked">
+ <TextBlock
+ Text="{Binding KeyboardHotkeys.VolumeUp, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <StackPanel Margin="10,0,0,0" Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center" Text="{locale:Locale SettingsTabHotkeysVolumeDownHotkey}" Width="230" />
+ <ToggleButton Width="90" Height="27" Checked="Button_Checked" Unchecked="Button_Unchecked">
+ <TextBlock
+ Text="{Binding KeyboardHotkeys.VolumeDown, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ </StackPanel>
+ </Border>
+ </ScrollViewer>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsHotkeysView.axaml.cs b/src/Ryujinx.Ava/UI/Views/Settings/SettingsHotkeysView.axaml.cs
new file mode 100644
index 00000000..702f73e8
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsHotkeysView.axaml.cs
@@ -0,0 +1,81 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Ryujinx.Ava.Input;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Input;
+using Ryujinx.Input.Assigner;
+
+namespace Ryujinx.Ava.UI.Views.Settings
+{
+ public partial class SettingsHotkeysView : UserControl
+ {
+ private ButtonKeyAssigner _currentAssigner;
+ private IGamepadDriver AvaloniaKeyboardDriver;
+
+ public SettingsHotkeysView()
+ {
+ InitializeComponent();
+ AvaloniaKeyboardDriver = new AvaloniaKeyboardDriver(this);
+ }
+
+ private void MouseClick(object sender, PointerPressedEventArgs e)
+ {
+ bool shouldUnbind = e.GetCurrentPoint(this).Properties.IsMiddleButtonPressed;
+
+ _currentAssigner?.Cancel(shouldUnbind);
+
+ PointerPressed -= MouseClick;
+ }
+
+ private void Button_Checked(object sender, RoutedEventArgs e)
+ {
+ if (sender is ToggleButton button)
+ {
+ if (_currentAssigner != null && button == _currentAssigner.ToggledButton)
+ {
+ return;
+ }
+
+ if (_currentAssigner == null && button.IsChecked != null && (bool)button.IsChecked)
+ {
+ _currentAssigner = new ButtonKeyAssigner(button);
+
+ FocusManager.Instance?.Focus(this, NavigationMethod.Pointer);
+
+ PointerPressed += MouseClick;
+
+ var keyboard = (IKeyboard)AvaloniaKeyboardDriver.GetGamepad(AvaloniaKeyboardDriver.GamepadsIds[0]);
+ IButtonAssigner assigner = new KeyboardKeyAssigner(keyboard);
+
+ _currentAssigner.GetInputAndAssign(assigner);
+ }
+ else
+ {
+ if (_currentAssigner != null)
+ {
+ ToggleButton oldButton = _currentAssigner.ToggledButton;
+
+ _currentAssigner.Cancel();
+ _currentAssigner = null;
+
+ button.IsChecked = false;
+ }
+ }
+ }
+ }
+
+ private void Button_Unchecked(object sender, RoutedEventArgs e)
+ {
+ _currentAssigner?.Cancel();
+ _currentAssigner = null;
+ }
+
+ public void Dispose()
+ {
+ _currentAssigner?.Cancel();
+ _currentAssigner = null;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsInputView.axaml b/src/Ryujinx.Ava/UI/Views/Settings/SettingsInputView.axaml
new file mode 100644
index 00000000..1c774bda
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsInputView.axaml
@@ -0,0 +1,46 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Views.Settings.SettingsInputView"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:window="clr-namespace:Ryujinx.Ava.UI.Windows"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ mc:Ignorable="d"
+ x:CompileBindings="True"
+ x:DataType="viewModels:SettingsViewModel">
+ <Design.DataContext>
+ <viewModels:SettingsViewModel />
+ </Design.DataContext>
+ <ScrollViewer
+ Name="InputPage"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ HorizontalScrollBarVisibility="Disabled"
+ VerticalScrollBarVisibility="Auto">
+ <Border Classes="settings">
+ <StackPanel Margin="4" Orientation="Vertical">
+ <StackPanel Orientation="Horizontal">
+ <CheckBox Margin="5,0"
+ ToolTip.Tip="{locale:Locale DockModeToggleTooltip}"
+ IsChecked="{Binding EnableDockedMode}">
+ <TextBlock VerticalAlignment="Center"
+ Text="{locale:Locale SettingsTabInputEnableDockedMode}" />
+ </CheckBox>
+ <CheckBox Margin="5,0"
+ ToolTip.Tip="{locale:Locale DirectKeyboardTooltip}"
+ IsChecked="{Binding EnableKeyboard}">
+ <TextBlock Text="{locale:Locale SettingsTabInputDirectKeyboardAccess}" />
+ </CheckBox>
+ <CheckBox Margin="5,0"
+ ToolTip.Tip="{locale:Locale DirectMouseTooltip}"
+ IsChecked="{Binding EnableMouse}">
+ <TextBlock Text="{locale:Locale SettingsTabInputDirectMouseAccess}" />
+ </CheckBox>
+ </StackPanel>
+ <window:ControllerSettingsWindow Name="ControllerSettings" Margin="0" MinHeight="600" />
+ </StackPanel>
+ </Border>
+ </ScrollViewer>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsInputView.axaml.cs b/src/Ryujinx.Ava/UI/Views/Settings/SettingsInputView.axaml.cs
new file mode 100644
index 00000000..0c2105ec
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsInputView.axaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Controls;
+
+namespace Ryujinx.Ava.UI.Views.Settings
+{
+ public partial class SettingsInputView : UserControl
+ {
+ public SettingsInputView()
+ {
+ InitializeComponent();
+ }
+
+ public void Dispose()
+ {
+ ControllerSettings.Dispose();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsLoggingView.axaml b/src/Ryujinx.Ava/UI/Views/Settings/SettingsLoggingView.axaml
new file mode 100644
index 00000000..948e7181
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsLoggingView.axaml
@@ -0,0 +1,121 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Views.Settings.SettingsLoggingView"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ mc:Ignorable="d"
+ x:CompileBindings="True"
+ x:DataType="viewModels:SettingsViewModel">
+ <Design.DataContext>
+ <viewModels:SettingsViewModel />
+ </Design.DataContext>
+ <ScrollViewer
+ Name="LoggingPage"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ HorizontalScrollBarVisibility="Disabled"
+ VerticalScrollBarVisibility="Auto">
+ <Border Classes="settings">
+ <StackPanel
+ Margin="10"
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical"
+ Spacing="10">
+ <TextBlock Classes="h1" Text="{locale:Locale SettingsTabLoggingLogging}" />
+ <StackPanel Margin="10,0,0,0" Orientation="Vertical">
+ <CheckBox IsChecked="{Binding EnableFileLog}"
+ ToolTip.Tip="{locale:Locale FileLogTooltip}">
+ <TextBlock Text="{locale:Locale SettingsTabLoggingEnableLoggingToFile}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding EnableStub}"
+ ToolTip.Tip="{locale:Locale StubLogTooltip}">
+ <TextBlock Text="{locale:Locale SettingsTabLoggingEnableStubLogs}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding EnableInfo}"
+ ToolTip.Tip="{locale:Locale InfoLogTooltip}">
+ <TextBlock Text="{locale:Locale SettingsTabLoggingEnableInfoLogs}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding EnableWarn}"
+ ToolTip.Tip="{locale:Locale WarnLogTooltip}">
+ <TextBlock Text="{locale:Locale SettingsTabLoggingEnableWarningLogs}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding EnableError}"
+ ToolTip.Tip="{locale:Locale ErrorLogTooltip}">
+ <TextBlock Text="{locale:Locale SettingsTabLoggingEnableErrorLogs}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding EnableGuest}"
+ ToolTip.Tip="{locale:Locale GuestLogTooltip}">
+ <TextBlock Text="{locale:Locale SettingsTabLoggingEnableGuestLogs}" />
+ </CheckBox>
+ </StackPanel>
+ <Separator Height="1" />
+ <StackPanel Orientation="Vertical" Spacing="2">
+ <TextBlock Classes="h1" Text="{locale:Locale SettingsTabLoggingDeveloperOptions}" />
+ <TextBlock Foreground="{DynamicResource SecondaryTextColor}" Text="{locale:Locale SettingsTabLoggingDeveloperOptionsNote}" />
+ </StackPanel>
+ <StackPanel
+ Margin="10,0,0,0"
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical"
+ Spacing="10">
+ <StackPanel Orientation="Vertical">
+ <CheckBox IsChecked="{Binding EnableTrace}"
+ ToolTip.Tip="{locale:Locale TraceLogTooltip}">
+ <TextBlock Text="{locale:Locale SettingsTabLoggingEnableTraceLogs}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding EnableFsAccessLog}"
+ ToolTip.Tip="{locale:Locale FileAccessLogTooltip}">
+ <TextBlock Text="{locale:Locale SettingsTabLoggingEnableFsAccessLogs}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding EnableDebug}"
+ ToolTip.Tip="{locale:Locale DebugLogTooltip}">
+ <TextBlock Text="{locale:Locale SettingsTabLoggingEnableDebugLogs}" />
+ </CheckBox>
+ <StackPanel Margin="0,10,0,0" Orientation="Horizontal" VerticalAlignment="Stretch">
+ <TextBlock VerticalAlignment="Center"
+ ToolTip.Tip="{locale:Locale FSAccessLogModeTooltip}"
+ Text="{locale:Locale SettingsTabLoggingFsGlobalAccessLogMode}"
+ Width="285" />
+ <ui:NumberBox
+ Maximum="3"
+ Minimum="0"
+ Width="150"
+ SpinButtonPlacementMode="Inline"
+ SmallChange="1"
+ LargeChange="1"
+ Value="{Binding FsGlobalAccessLogMode}" />
+ </StackPanel>
+ <StackPanel Margin="0,10,0,0" Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center"
+ Text="{locale:Locale SettingsTabLoggingGraphicsBackendLogLevel}"
+ ToolTip.Tip="{locale:Locale OpenGlLogLevel}"
+ Width="285" />
+ <ComboBox SelectedIndex="{Binding OpenglDebugLevel}"
+ Width="150"
+ HorizontalContentAlignment="Left"
+ ToolTip.Tip="{locale:Locale OpenGlLogLevel}">
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabLoggingGraphicsBackendLogLevelNone}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabLoggingGraphicsBackendLogLevelError}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock
+ Text="{locale:Locale SettingsTabLoggingGraphicsBackendLogLevelPerformance}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabLoggingGraphicsBackendLogLevelAll}" />
+ </ComboBoxItem>
+ </ComboBox>
+ </StackPanel>
+ </StackPanel>
+ </StackPanel>
+ </StackPanel>
+ </Border>
+ </ScrollViewer>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsLoggingView.axaml.cs b/src/Ryujinx.Ava/UI/Views/Settings/SettingsLoggingView.axaml.cs
new file mode 100644
index 00000000..2ec476ac
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsLoggingView.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.Controls;
+
+namespace Ryujinx.Ava.UI.Views.Settings
+{
+ public partial class SettingsLoggingView : UserControl
+ {
+ public SettingsLoggingView()
+ {
+ InitializeComponent();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsNetworkView.axaml b/src/Ryujinx.Ava/UI/Views/Settings/SettingsNetworkView.axaml
new file mode 100644
index 00000000..ab8a7f6d
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsNetworkView.axaml
@@ -0,0 +1,46 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Views.Settings.SettingsNetworkView"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ mc:Ignorable="d"
+ x:CompileBindings="True"
+ x:DataType="viewModels:SettingsViewModel">
+ <Design.DataContext>
+ <viewModels:SettingsViewModel />
+ </Design.DataContext>
+ <ScrollViewer
+ Name="NetworkPage"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ HorizontalScrollBarVisibility="Disabled"
+ VerticalScrollBarVisibility="Auto">
+ <Border Classes="settings">
+ <StackPanel
+ Margin="10"
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical"
+ Spacing="10">
+ <TextBlock Classes="h1" Text="{locale:Locale SettingsTabNetworkConnection}" />
+ <CheckBox Margin="10,0,0,0" IsChecked="{Binding EnableInternetAccess}">
+ <TextBlock Text="{locale:Locale SettingsTabSystemEnableInternetAccess}"
+ ToolTip.Tip="{locale:Locale EnableInternetAccessTooltip}" />
+ </CheckBox>
+ <StackPanel Margin="10,0,0,0" Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center"
+ Text="{locale:Locale SettingsTabNetworkInterface}"
+ ToolTip.Tip="{locale:Locale NetworkInterfaceTooltip}"
+ Width="200" />
+ <ComboBox SelectedIndex="{Binding NetworkInterfaceIndex}"
+ ToolTip.Tip="{locale:Locale NetworkInterfaceTooltip}"
+ HorizontalContentAlignment="Left"
+ Items="{Binding NetworkInterfaceList}"
+ Width="250" />
+ </StackPanel>
+ </StackPanel>
+ </Border>
+ </ScrollViewer>
+</UserControl>
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsNetworkView.axaml.cs b/src/Ryujinx.Ava/UI/Views/Settings/SettingsNetworkView.axaml.cs
new file mode 100644
index 00000000..d7407b9d
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsNetworkView.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.Controls;
+
+namespace Ryujinx.Ava.UI.Views.Settings
+{
+ public partial class SettingsNetworkView : UserControl
+ {
+ public SettingsNetworkView()
+ {
+ InitializeComponent();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsSystemView.axaml b/src/Ryujinx.Ava/UI/Views/Settings/SettingsSystemView.axaml
new file mode 100644
index 00000000..1d4f040f
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsSystemView.axaml
@@ -0,0 +1,195 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Views.Settings.SettingsSystemView"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ mc:Ignorable="d"
+ x:CompileBindings="True"
+ x:DataType="viewModels:SettingsViewModel">
+ <Design.DataContext>
+ <viewModels:SettingsViewModel />
+ </Design.DataContext>
+ <ScrollViewer
+ Name="SystemPage"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ HorizontalScrollBarVisibility="Disabled"
+ VerticalScrollBarVisibility="Auto">
+ <Border Classes="settings">
+ <StackPanel
+ Margin="10"
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical"
+ Spacing="10">
+ <TextBlock Classes="h1" Text="{locale:Locale SettingsTabSystemCore}" />
+ <StackPanel Margin="10,0,0,0" Orientation="Vertical">
+ <StackPanel Margin="0,0,0,10" Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center"
+ Text="{locale:Locale SettingsTabSystemSystemRegion}"
+ Width="250" />
+ <ComboBox SelectedIndex="{Binding Region}"
+ ToolTip.Tip="{locale:Locale RegionTooltip}"
+ HorizontalContentAlignment="Left"
+ Width="350">
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemRegionJapan}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemRegionUSA}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemRegionEurope}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemRegionAustralia}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemRegionChina}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemRegionKorea}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemRegionTaiwan}" />
+ </ComboBoxItem>
+ </ComboBox>
+ </StackPanel>
+ <StackPanel Margin="0,0,0,10" Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center"
+ Text="{locale:Locale SettingsTabSystemSystemLanguage}"
+ ToolTip.Tip="{locale:Locale LanguageTooltip}"
+ Width="250" />
+ <ComboBox SelectedIndex="{Binding Language}"
+ ToolTip.Tip="{locale:Locale LanguageTooltip}"
+ HorizontalContentAlignment="Left"
+ Width="350">
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemLanguageJapanese}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock
+ Text="{locale:Locale SettingsTabSystemSystemLanguageAmericanEnglish}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemLanguageFrench}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemLanguageGerman}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemLanguageItalian}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemLanguageSpanish}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemLanguageChinese}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemLanguageKorean}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemLanguageDutch}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemLanguagePortuguese}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemLanguageRussian}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabSystemSystemLanguageTaiwanese}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock
+ Text="{locale:Locale SettingsTabSystemSystemLanguageBritishEnglish}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock
+ Text="{locale:Locale SettingsTabSystemSystemLanguageCanadianFrench}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock
+ Text="{locale:Locale SettingsTabSystemSystemLanguageLatinAmericanSpanish}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock
+ Text="{locale:Locale SettingsTabSystemSystemLanguageSimplifiedChinese}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock
+ Text="{locale:Locale SettingsTabSystemSystemLanguageTraditionalChinese}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock
+ Text="{locale:Locale SettingsTabSystemSystemLanguageBrazilianPortuguese}" />
+ </ComboBoxItem>
+ </ComboBox>
+ </StackPanel>
+ <StackPanel Margin="0,0,0,10" Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center"
+ Text="{locale:Locale SettingsTabSystemSystemTimeZone}"
+ ToolTip.Tip="{locale:Locale TimezoneTooltip}"
+ Width="250" />
+ <AutoCompleteBox
+ Name="TimeZoneBox"
+ Width="350"
+ MaxDropDownHeight="500"
+ FilterMode="Contains"
+ Items="{Binding TimeZones}"
+ SelectionChanged="TimeZoneBox_OnSelectionChanged"
+ Text="{Binding Path=TimeZone, Mode=OneWay}"
+ TextChanged="TimeZoneBox_OnTextChanged"
+ ToolTip.Tip="{locale:Locale TimezoneTooltip}" />
+ </StackPanel>
+ <StackPanel Margin="0,0,0,10" Orientation="Horizontal">
+ <TextBlock VerticalAlignment="Center"
+ Text="{locale:Locale SettingsTabSystemSystemTime}"
+ ToolTip.Tip="{locale:Locale TimeTooltip}"
+ Width="250"/>
+ <DatePicker VerticalAlignment="Center" SelectedDate="{Binding DateOffset}"
+ ToolTip.Tip="{locale:Locale TimeTooltip}"
+ Width="350" />
+ </StackPanel>
+ <StackPanel Margin="250,0,0,10" Orientation="Horizontal">
+ <TimePicker
+ VerticalAlignment="Center"
+ ClockIdentifier="24HourClock"
+ SelectedTime="{Binding TimeOffset}"
+ Width="350"
+ ToolTip.Tip="{locale:Locale TimeTooltip}" />
+ </StackPanel>
+ <CheckBox IsChecked="{Binding EnableVsync}">
+ <TextBlock Text="{locale:Locale SettingsTabSystemEnableVsync}"
+ ToolTip.Tip="{locale:Locale VSyncToggleTooltip}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding EnableFsIntegrityChecks}">
+ <TextBlock Text="{locale:Locale SettingsTabSystemEnableFsIntegrityChecks}"
+ ToolTip.Tip="{locale:Locale FsIntegrityToggleTooltip}" />
+ </CheckBox>
+ </StackPanel>
+ <Separator Height="1" />
+ <StackPanel Orientation="Vertical" Spacing="2">
+ <TextBlock Classes="h1" Text="{locale:Locale SettingsTabSystemHacks}" />
+ <TextBlock Foreground="{DynamicResource SecondaryTextColor}" Text="{locale:Locale SettingsTabSystemHacksNote}" />
+ </StackPanel>
+ <StackPanel
+ Margin="10,0,0,0"
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical">
+ <CheckBox IsChecked="{Binding ExpandDramSize}"
+ ToolTip.Tip="{locale:Locale DRamTooltip}">
+ <TextBlock Text="{locale:Locale SettingsTabSystemExpandDramSize}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding IgnoreMissingServices}"
+ ToolTip.Tip="{locale:Locale IgnoreMissingServicesTooltip}">
+ <TextBlock Text="{locale:Locale SettingsTabSystemIgnoreMissingServices}" />
+ </CheckBox>
+ </StackPanel>
+ </StackPanel>
+ </Border>
+ </ScrollViewer>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsSystemView.axaml.cs b/src/Ryujinx.Ava/UI/Views/Settings/SettingsSystemView.axaml.cs
new file mode 100644
index 00000000..d2ea59de
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsSystemView.axaml.cs
@@ -0,0 +1,52 @@
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Data.Converters;
+using Ryujinx.Ava.UI.ViewModels;
+using System;
+using System.Linq;
+using TimeZone = Ryujinx.Ava.UI.Models.TimeZone;
+
+namespace Ryujinx.Ava.UI.Views.Settings
+{
+ public partial class SettingsSystemView : UserControl
+ {
+ public SettingsViewModel ViewModel;
+
+ public SettingsSystemView()
+ {
+ InitializeComponent();
+
+ FuncMultiValueConverter<string, string> converter = new(parts => string.Format("{0} {1} {2}", parts.ToArray()).Trim());
+ MultiBinding tzMultiBinding = new() { Converter = converter };
+
+ tzMultiBinding.Bindings.Add(new Binding("UtcDifference"));
+ tzMultiBinding.Bindings.Add(new Binding("Location"));
+ tzMultiBinding.Bindings.Add(new Binding("Abbreviation"));
+
+ TimeZoneBox.ValueMemberBinding = tzMultiBinding;
+ }
+
+ private void TimeZoneBox_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (e.AddedItems != null && e.AddedItems.Count > 0)
+ {
+ if (e.AddedItems[0] is TimeZone timeZone)
+ {
+ e.Handled = true;
+
+ ViewModel.ValidateAndSetTimeZone(timeZone.Location);
+ }
+ }
+ }
+
+ private void TimeZoneBox_OnTextChanged(object sender, EventArgs e)
+ {
+ if (sender is AutoCompleteBox box && box.SelectedItem is TimeZone timeZone)
+ {
+ {
+ ViewModel.ValidateAndSetTimeZone(timeZone.Location);
+ }
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsUIView.axaml b/src/Ryujinx.Ava/UI/Views/Settings/SettingsUIView.axaml
new file mode 100644
index 00000000..61b6c433
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsUIView.axaml
@@ -0,0 +1,156 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Views.Settings.SettingsUIView"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ mc:Ignorable="d"
+ x:CompileBindings="True"
+ x:DataType="viewModels:SettingsViewModel">
+ <Design.DataContext>
+ <viewModels:SettingsViewModel />
+ </Design.DataContext>
+ <ScrollViewer
+ Name="UiPage"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ HorizontalScrollBarVisibility="Disabled"
+ VerticalScrollBarVisibility="Auto">
+ <Border Classes="settings">
+ <StackPanel
+ Margin="10"
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical"
+ Spacing="10">
+ <TextBlock Classes="h1" Text="{locale:Locale SettingsTabGeneralGeneral}" />
+ <StackPanel Margin="10,0,0,0" Orientation="Vertical">
+ <CheckBox IsChecked="{Binding EnableDiscordIntegration}">
+ <TextBlock VerticalAlignment="Center"
+ ToolTip.Tip="{locale:Locale ToggleDiscordTooltip}"
+ Text="{locale:Locale SettingsTabGeneralEnableDiscordRichPresence}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding CheckUpdatesOnStart}">
+ <TextBlock Text="{locale:Locale SettingsTabGeneralCheckUpdatesOnLaunch}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding ShowConfirmExit}">
+ <TextBlock Text="{locale:Locale SettingsTabGeneralShowConfirmExitDialog}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding HideCursorOnIdle}">
+ <TextBlock Text="{locale:Locale SettingsTabGeneralHideCursorOnIdle}" />
+ </CheckBox>
+ </StackPanel>
+ <Separator Height="1" />
+ <TextBlock Classes="h1" Text="{locale:Locale SettingsTabGeneralGameDirectories}" />
+ <StackPanel
+ Margin="10,0,0,0"
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical"
+ Spacing="10">
+ <ListBox
+ Name="GameList"
+ MinHeight="230"
+ Items="{Binding GameDirectories}">
+ <ListBox.Styles>
+ <Style Selector="ListBoxItem">
+ <Setter Property="Padding" Value="10" />
+ <Setter Property="Background" Value="{DynamicResource ListBoxBackground}" />
+ </Style>
+ </ListBox.Styles>
+ </ListBox>
+ <Grid HorizontalAlignment="Stretch">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="*" />
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition Width="Auto" />
+ </Grid.ColumnDefinitions>
+ <TextBox
+ Name="PathBox"
+ Margin="0"
+ ToolTip.Tip="{locale:Locale AddGameDirBoxTooltip}"
+ VerticalAlignment="Stretch" />
+ <Button
+ Name="AddButton"
+ Grid.Column="1"
+ MinWidth="90"
+ Margin="10,0,0,0"
+ ToolTip.Tip="{locale:Locale AddGameDirTooltip}"
+ Click="AddButton_OnClick">
+ <TextBlock HorizontalAlignment="Center"
+ Text="{locale:Locale SettingsTabGeneralAdd}" />
+ </Button>
+ <Button
+ Name="RemoveButton"
+ Grid.Column="2"
+ MinWidth="90"
+ Margin="10,0,0,0"
+ ToolTip.Tip="{locale:Locale RemoveGameDirTooltip}"
+ Click="RemoveButton_OnClick">
+ <TextBlock HorizontalAlignment="Center"
+ Text="{locale:Locale SettingsTabGeneralRemove}" />
+ </Button>
+ </Grid>
+ </StackPanel>
+ <Separator Height="1" />
+ <TextBlock Classes="h1" Text="{locale:Locale SettingsTabGeneralTheme}" />
+ <Grid Margin="10,0,0,0">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition />
+ <ColumnDefinition Width="Auto" />
+ </Grid.ColumnDefinitions>
+ <Grid.RowDefinitions>
+ <RowDefinition />
+ <RowDefinition />
+ <RowDefinition />
+ </Grid.RowDefinitions>
+ <CheckBox
+ IsChecked="{Binding EnableCustomTheme}"
+ ToolTip.Tip="{locale:Locale CustomThemeCheckTooltip}">
+ <TextBlock Text="{locale:Locale SettingsTabGeneralThemeEnableCustomTheme}" />
+ </CheckBox>
+ <TextBlock
+ Grid.Column="0"
+ Grid.Row="1"
+ VerticalAlignment="Center"
+ Margin="0,10,0,0"
+ Text="{locale:Locale SettingsTabGeneralThemeCustomTheme}"
+ ToolTip.Tip="{locale:Locale CustomThemePathTooltip}" />
+ <TextBox
+ Grid.Row="1"
+ Grid.Column="1"
+ Margin="0,10,0,0"
+ Text="{Binding CustomThemePath}" />
+ <Button
+ Grid.Row="1"
+ Grid.Column="2"
+ Margin="10,10,0,0"
+ Click="BrowseTheme"
+ ToolTip.Tip="{locale:Locale CustomThemeBrowseTooltip}"
+ Content="{locale:Locale ButtonBrowse}" />
+ <TextBlock
+ Grid.Column="0"
+ Grid.Row="2"
+ VerticalAlignment="Center"
+ Margin="0,10,0,0"
+ Text="{locale:Locale SettingsTabGeneralThemeBaseStyle}" />
+ <ComboBox
+ Grid.Column="1"
+ Grid.Row="2"
+ VerticalAlignment="Center"
+ Margin="0,10,0,0"
+ MinWidth="100"
+ SelectedIndex="{Binding BaseStyleIndex}">
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabGeneralThemeBaseStyleLight}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <TextBlock Text="{locale:Locale SettingsTabGeneralThemeBaseStyleDark}" />
+ </ComboBoxItem>
+ </ComboBox>
+ </Grid>
+ </StackPanel>
+ </Border>
+ </ScrollViewer>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/Settings/SettingsUIView.axaml.cs b/src/Ryujinx.Ava/UI/Views/Settings/SettingsUIView.axaml.cs
new file mode 100644
index 00000000..8e6da7c5
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/Settings/SettingsUIView.axaml.cs
@@ -0,0 +1,82 @@
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Interactivity;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.ViewModels;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace Ryujinx.Ava.UI.Views.Settings
+{
+ public partial class SettingsUIView : UserControl
+ {
+ public SettingsViewModel ViewModel;
+
+ public SettingsUIView()
+ {
+ InitializeComponent();
+ }
+
+ private async void AddButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ string path = PathBox.Text;
+
+ if (!string.IsNullOrWhiteSpace(path) && Directory.Exists(path) && !ViewModel.GameDirectories.Contains(path))
+ {
+ ViewModel.GameDirectories.Add(path);
+ ViewModel.DirectoryChanged = true;
+ }
+ else
+ {
+ if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ path = await new OpenFolderDialog().ShowAsync(desktop.MainWindow);
+
+ if (!string.IsNullOrWhiteSpace(path))
+ {
+ ViewModel.GameDirectories.Add(path);
+ ViewModel.DirectoryChanged = true;
+ }
+ }
+ }
+ }
+
+ private void RemoveButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ int oldIndex = GameList.SelectedIndex;
+
+ foreach (string path in new List<string>(GameList.SelectedItems.Cast<string>()))
+ {
+ ViewModel.GameDirectories.Remove(path);
+ ViewModel.DirectoryChanged = true;
+ }
+
+ if (GameList.ItemCount > 0)
+ {
+ GameList.SelectedIndex = oldIndex < GameList.ItemCount ? oldIndex : 0;
+ }
+ }
+
+ public async void BrowseTheme(object sender, RoutedEventArgs e)
+ {
+ var dialog = new OpenFileDialog()
+ {
+ Title = LocaleManager.Instance[LocaleKeys.SettingsSelectThemeFileDialogTitle],
+ AllowMultiple = false
+ };
+
+ dialog.Filters.Add(new FileDialogFilter() { Extensions = { "xaml" }, Name = LocaleManager.Instance[LocaleKeys.SettingsXamlThemeFile] });
+
+ if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ var file = await dialog.ShowAsync(desktop.MainWindow);
+
+ if (file != null && file.Length > 0)
+ {
+ ViewModel.CustomThemePath = file[0];
+ }
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/User/UserEditorView.axaml b/src/Ryujinx.Ava/UI/Views/User/UserEditorView.axaml
new file mode 100644
index 00000000..7e55f25e
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/User/UserEditorView.axaml
@@ -0,0 +1,123 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Views.User.UserEditorView"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
+ xmlns:models="clr-namespace:Ryujinx.Ava.UI.Models"
+ Margin="0"
+ MinWidth="500"
+ Padding="0"
+ mc:Ignorable="d"
+ Focusable="True"
+ x:CompileBindings="True"
+ x:DataType="models:TempProfile">
+ <UserControl.Resources>
+ <helpers:BitmapArrayValueConverter x:Key="ByteImage" />
+ </UserControl.Resources>
+ <Grid Margin="0">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition />
+ </Grid.ColumnDefinitions>
+ <Grid.RowDefinitions>
+ <RowDefinition Height="*" />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <StackPanel
+ Grid.Row="0"
+ Grid.Column="0"
+ HorizontalAlignment="Stretch"
+ Orientation="Vertical"
+ Spacing="10">
+ <TextBlock Text="{locale:Locale UserProfilesName}" />
+ <TextBox
+ Name="NameBox"
+ Width="300"
+ HorizontalAlignment="Stretch"
+ MaxLength="{Binding MaxProfileNameLength}"
+ Watermark="{locale:Locale ProfileNameSelectionWatermark}"
+ Text="{Binding Name}" />
+ <TextBlock Name="IdText" Text="{locale:Locale UserProfilesUserId}" />
+ <TextBox
+ Name="IdLabel"
+ Width="300"
+ HorizontalAlignment="Stretch"
+ IsReadOnly="True"
+ Text="{Binding UserIdString}" />
+ </StackPanel>
+ <StackPanel
+ Grid.Row="0"
+ Grid.Column="1"
+ HorizontalAlignment="Right"
+ VerticalAlignment="Stretch"
+ Orientation="Vertical">
+ <Border
+ BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
+ BorderThickness="1">
+ <Panel>
+ <ui:SymbolIcon
+ FontSize="60"
+ Width="96"
+ Height="96"
+ Margin="0"
+ Foreground="{DynamicResource AppListHoverBackgroundColor}"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Top"
+ Symbol="Camera" />
+ <Image
+ Name="ProfileImage"
+ Width="96"
+ Height="96"
+ Margin="0"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Top"
+ Source="{Binding Image, Converter={StaticResource ByteImage}}" />
+ </Panel>
+ </Border>
+ </StackPanel>
+ <StackPanel
+ Grid.Row="1"
+ Grid.Column="0"
+ Grid.ColumnSpan="2"
+ HorizontalAlignment="Left"
+ Orientation="Horizontal"
+ Margin="0 24 0 0"
+ Spacing="10">
+ <Button
+ Width="50"
+ MinWidth="50"
+ Click="BackButton_Click">
+ <ui:SymbolIcon Symbol="Back" />
+ </Button>
+ </StackPanel>
+ <StackPanel
+ Grid.Row="1"
+ Grid.Column="0"
+ Grid.ColumnSpan="2"
+ HorizontalAlignment="Right"
+ Orientation="Horizontal"
+ Margin="0 24 0 0"
+ Spacing="10">
+ <Button
+ Name="DeleteButton"
+ Click="DeleteButton_Click"
+ Content="{locale:Locale UserProfilesDelete}" />
+ <Button
+ Name="ChangePictureButton"
+ Click="ChangePictureButton_Click"
+ Content="{locale:Locale UserProfilesChangeProfileImage}" />
+ <Button
+ Name="AddPictureButton"
+ Click="ChangePictureButton_Click"
+ Content="{locale:Locale UserProfilesSetProfileImage}" />
+ <Button
+ Name="SaveButton"
+ Click="SaveButton_Click"
+ Content="{locale:Locale Save}" />
+ </StackPanel>
+ </Grid>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/User/UserEditorView.axaml.cs b/src/Ryujinx.Ava/UI/Views/User/UserEditorView.axaml.cs
new file mode 100644
index 00000000..81938d23
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/User/UserEditorView.axaml.cs
@@ -0,0 +1,165 @@
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Interactivity;
+using FluentAvalonia.UI.Controls;
+using FluentAvalonia.UI.Navigation;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Controls;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.HLE.HOS.Services.Account.Acc;
+using System;
+using UserProfile = Ryujinx.Ava.UI.Models.UserProfile;
+
+namespace Ryujinx.Ava.UI.Views.User
+{
+ public partial class UserEditorView : UserControl
+ {
+ private NavigationDialogHost _parent;
+ private UserProfile _profile;
+ private bool _isNewUser;
+
+ public TempProfile TempProfile { get; set; }
+ public uint MaxProfileNameLength => 0x20;
+ public bool IsDeletable => _profile.UserId != AccountManager.DefaultUserId;
+
+ public UserEditorView()
+ {
+ InitializeComponent();
+ AddHandler(Frame.NavigatedToEvent, (s, e) =>
+ {
+ NavigatedTo(e);
+ }, RoutingStrategies.Direct);
+ }
+
+ private void NavigatedTo(NavigationEventArgs arg)
+ {
+ if (Program.PreviewerDetached)
+ {
+ switch (arg.NavigationMode)
+ {
+ case NavigationMode.New:
+ var args = ((NavigationDialogHost parent, UserProfile profile, bool isNewUser))arg.Parameter;
+ _isNewUser = args.isNewUser;
+ _profile = args.profile;
+ TempProfile = new TempProfile(_profile);
+
+ _parent = args.parent;
+ break;
+ }
+
+ ((ContentDialog)_parent.Parent).Title = $"{LocaleManager.Instance[LocaleKeys.UserProfileWindowTitle]} - " +
+ $"{ (_isNewUser ? LocaleManager.Instance[LocaleKeys.UserEditorTitleCreate] : LocaleManager.Instance[LocaleKeys.UserEditorTitle])}";
+
+ DataContext = TempProfile;
+
+ AddPictureButton.IsVisible = _isNewUser;
+ ChangePictureButton.IsVisible = !_isNewUser;
+ IdLabel.IsVisible = _profile != null;
+ IdText.IsVisible = _profile != null;
+ if (!_isNewUser && IsDeletable)
+ {
+ DeleteButton.IsVisible = true;
+ }
+ else
+ {
+ DeleteButton.IsVisible = false;
+ }
+ }
+ }
+
+ private async void BackButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (_isNewUser)
+ {
+ if (TempProfile.Name != String.Empty || TempProfile.Image != null)
+ {
+ if (await ContentDialogHelper.CreateChoiceDialog(
+ LocaleManager.Instance[LocaleKeys.DialogUserProfileUnsavedChangesTitle],
+ LocaleManager.Instance[LocaleKeys.DialogUserProfileUnsavedChangesMessage],
+ LocaleManager.Instance[LocaleKeys.DialogUserProfileUnsavedChangesSubMessage]))
+ {
+ _parent?.GoBack();
+ }
+ }
+ else
+ {
+ _parent?.GoBack();
+ }
+ }
+ else
+ {
+ if (_profile.Name != TempProfile.Name || _profile.Image != TempProfile.Image)
+ {
+ if (await ContentDialogHelper.CreateChoiceDialog(
+ LocaleManager.Instance[LocaleKeys.DialogUserProfileUnsavedChangesTitle],
+ LocaleManager.Instance[LocaleKeys.DialogUserProfileUnsavedChangesMessage],
+ LocaleManager.Instance[LocaleKeys.DialogUserProfileUnsavedChangesSubMessage]))
+ {
+ _parent?.GoBack();
+ }
+ }
+ else
+ {
+ _parent?.GoBack();
+ }
+ }
+ }
+
+ private void DeleteButton_Click(object sender, RoutedEventArgs e)
+ {
+ _parent.DeleteUser(_profile);
+ }
+
+ private void SaveButton_Click(object sender, RoutedEventArgs e)
+ {
+ DataValidationErrors.ClearErrors(NameBox);
+
+ if (string.IsNullOrWhiteSpace(TempProfile.Name))
+ {
+ DataValidationErrors.SetError(NameBox, new DataValidationException(LocaleManager.Instance[LocaleKeys.UserProfileEmptyNameError]));
+
+ return;
+ }
+
+ if (TempProfile.Image == null)
+ {
+ _parent.Navigate(typeof(UserProfileImageSelectorView), (_parent, TempProfile));
+
+ return;
+ }
+
+ if (_profile != null && !_isNewUser)
+ {
+ _profile.Name = TempProfile.Name;
+ _profile.Image = TempProfile.Image;
+ _profile.UpdateState();
+ _parent.AccountManager.SetUserName(_profile.UserId, _profile.Name);
+ _parent.AccountManager.SetUserImage(_profile.UserId, _profile.Image);
+ }
+ else if (_isNewUser)
+ {
+ _parent.AccountManager.AddUser(TempProfile.Name, TempProfile.Image, TempProfile.UserId);
+ }
+ else
+ {
+ return;
+ }
+
+ _parent?.GoBack();
+ }
+
+ public void SelectProfileImage()
+ {
+ _parent.Navigate(typeof(UserProfileImageSelectorView), (_parent, TempProfile));
+ }
+
+ private void ChangePictureButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (_profile != null || _isNewUser)
+ {
+ SelectProfileImage();
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/User/UserFirmwareAvatarSelectorView.axaml b/src/Ryujinx.Ava/UI/Views/User/UserFirmwareAvatarSelectorView.axaml
new file mode 100644
index 00000000..d46fcefc
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/User/UserFirmwareAvatarSelectorView.axaml
@@ -0,0 +1,114 @@
+<UserControl
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ mc:Ignorable="d"
+ Width="528"
+ d:DesignWidth="578"
+ d:DesignHeight="350"
+ x:Class="Ryujinx.Ava.UI.Views.User.UserFirmwareAvatarSelectorView"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
+ x:CompileBindings="True"
+ x:DataType="viewModels:UserFirmwareAvatarSelectorViewModel"
+ Focusable="True">
+ <Design.DataContext>
+ <viewModels:UserFirmwareAvatarSelectorViewModel />
+ </Design.DataContext>
+ <UserControl.Resources>
+ <helpers:BitmapArrayValueConverter x:Key="ByteImage" />
+ </UserControl.Resources>
+ <Grid
+ Margin="0"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="*" />
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <ListBox
+ Grid.Row="1"
+ BorderThickness="0"
+ SelectedIndex="{Binding SelectedIndex}"
+ Height="400"
+ Items="{Binding Images}"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Center">
+ <ListBox.ItemsPanel>
+ <ItemsPanelTemplate>
+ <WrapPanel
+ Orientation="Horizontal"
+ Margin="0"
+ HorizontalAlignment="Center" />
+ </ItemsPanelTemplate>
+ </ListBox.ItemsPanel>
+ <ListBox.Styles>
+ <Style Selector="ListBoxItem">
+ <Setter Property="CornerRadius" Value="4" />
+ <Setter Property="Width" Value="85" />
+ <Setter Property="MaxWidth" Value="85" />
+ <Setter Property="MinWidth" Value="85" />
+ </Style>
+ <Style Selector="ListBoxItem /template/ Border#SelectionIndicator">
+ <Setter Property="MinHeight" Value="70" />
+ </Style>
+ </ListBox.Styles>
+ <ListBox.ItemTemplate>
+ <DataTemplate>
+ <Panel
+ Background="{Binding BackgroundColor}"
+ Margin="5">
+ <Image Source="{Binding Data, Converter={StaticResource ByteImage}}" />
+ </Panel>
+ </DataTemplate>
+ </ListBox.ItemTemplate>
+ </ListBox>
+ <StackPanel
+ Grid.Row="3"
+ Orientation="Horizontal"
+ Spacing="10"
+ Margin="0 24 0 0"
+ HorizontalAlignment="Left">
+ <Button
+ Width="50"
+ MinWidth="50"
+ Height="35"
+ Click="GoBack">
+ <ui:SymbolIcon Symbol="Back" />
+ </Button>
+ </StackPanel>
+ <StackPanel
+ Grid.Row="3"
+ Orientation="Horizontal"
+ Spacing="10"
+ Margin="0 24 0 0"
+ HorizontalAlignment="Right">
+ <ui:ColorPickerButton
+ FlyoutPlacement="Top"
+ IsMoreButtonVisible="False"
+ UseColorPalette="False"
+ UseColorTriangle="False"
+ UseColorWheel="False"
+ ShowAcceptDismissButtons="False"
+ IsAlphaEnabled="False"
+ Color="{Binding BackgroundColor, Mode=TwoWay}"
+ Name="ColorButton">
+ <ui:ColorPickerButton.Styles>
+ <Style Selector="Grid#Root > DockPanel > Grid">
+ <Setter Property="IsVisible" Value="False" />
+ </Style>
+ </ui:ColorPickerButton.Styles>
+ </ui:ColorPickerButton>
+ <Button
+ Content="{locale:Locale AvatarChoose}"
+ Height="35"
+ Name="ChooseButton"
+ Click="ChooseButton_OnClick" />
+ </StackPanel>
+ </Grid>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/User/UserFirmwareAvatarSelectorView.axaml.cs b/src/Ryujinx.Ava/UI/Views/User/UserFirmwareAvatarSelectorView.axaml.cs
new file mode 100644
index 00000000..7c9191ab
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/User/UserFirmwareAvatarSelectorView.axaml.cs
@@ -0,0 +1,88 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using FluentAvalonia.UI.Controls;
+using FluentAvalonia.UI.Navigation;
+using Ryujinx.Ava.UI.Controls;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.HLE.FileSystem;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Formats.Png;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+using System.IO;
+
+namespace Ryujinx.Ava.UI.Views.User
+{
+ public partial class UserFirmwareAvatarSelectorView : UserControl
+ {
+ private NavigationDialogHost _parent;
+ private TempProfile _profile;
+
+ public UserFirmwareAvatarSelectorView(ContentManager contentManager)
+ {
+ ContentManager = contentManager;
+
+ DataContext = ViewModel;
+
+ InitializeComponent();
+ }
+
+ public UserFirmwareAvatarSelectorView()
+ {
+ InitializeComponent();
+
+ AddHandler(Frame.NavigatedToEvent, (s, e) =>
+ {
+ NavigatedTo(e);
+ }, RoutingStrategies.Direct);
+ }
+
+ private void NavigatedTo(NavigationEventArgs arg)
+ {
+ if (Program.PreviewerDetached)
+ {
+ if (arg.NavigationMode == NavigationMode.New)
+ {
+ (_parent, _profile) = ((NavigationDialogHost, TempProfile))arg.Parameter;
+ ContentManager = _parent.ContentManager;
+ if (Program.PreviewerDetached)
+ {
+ ViewModel = new UserFirmwareAvatarSelectorViewModel();
+ }
+
+ DataContext = ViewModel;
+ }
+ }
+ }
+
+ public ContentManager ContentManager { get; private set; }
+
+ internal UserFirmwareAvatarSelectorViewModel ViewModel { get; set; }
+
+ private void GoBack(object sender, RoutedEventArgs e)
+ {
+ _parent.GoBack();
+ }
+
+ private void ChooseButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ if (ViewModel.SelectedImage != null)
+ {
+ MemoryStream streamJpg = new();
+ SixLabors.ImageSharp.Image avatarImage = SixLabors.ImageSharp.Image.Load(ViewModel.SelectedImage, new PngDecoder());
+
+ avatarImage.Mutate(x => x.BackgroundColor(new Rgba32(
+ ViewModel.BackgroundColor.R,
+ ViewModel.BackgroundColor.G,
+ ViewModel.BackgroundColor.B,
+ ViewModel.BackgroundColor.A)));
+ avatarImage.SaveAsJpeg(streamJpg);
+
+ _profile.Image = streamJpg.ToArray();
+
+ _parent.GoBack();
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/User/UserProfileImageSelectorView.axaml b/src/Ryujinx.Ava/UI/Views/User/UserProfileImageSelectorView.axaml
new file mode 100644
index 00000000..b9f51fdc
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/User/UserProfileImageSelectorView.axaml
@@ -0,0 +1,63 @@
+<UserControl
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:viewModles="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ Focusable="True"
+ mc:Ignorable="d"
+ x:Class="Ryujinx.Ava.UI.Views.User.UserProfileImageSelectorView"
+ x:CompileBindings="True"
+ x:DataType="viewModles:UserProfileImageSelectorViewModel"
+ Width="500"
+ d:DesignWidth="500">
+ <Design.DataContext>
+ <viewModles:UserProfileImageSelectorViewModel />
+ </Design.DataContext>
+ <Grid
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Center">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="70" />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <TextBlock
+ Grid.Row="0"
+ TextWrapping="Wrap"
+ HorizontalAlignment="Left"
+ TextAlignment="Left"
+ Text="{locale:Locale ProfileImageSelectionNote}" />
+ <StackPanel
+ Grid.Row="2"
+ Spacing="10"
+ HorizontalAlignment="Left"
+ Orientation="Horizontal">
+ <Button
+ Width="50"
+ MinWidth="50"
+ Click="GoBack">
+ <ui:SymbolIcon Symbol="Back" />
+ </Button>
+ </StackPanel>
+ <StackPanel
+ Grid.Row="2"
+ Spacing="10"
+ HorizontalAlignment="Right"
+ Orientation="Horizontal">
+ <Button
+ Name="Import"
+ Click="Import_OnClick">
+ <TextBlock Text="{locale:Locale ProfileImageSelectionImportImage}" />
+ </Button>
+ <Button
+ Name="SelectFirmwareImage"
+ IsEnabled="{Binding FirmwareFound}"
+ Click="SelectFirmwareImage_OnClick">
+ <TextBlock Text="{locale:Locale ProfileImageSelectionSelectAvatar}" />
+ </Button>
+ </StackPanel>
+ </Grid>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/User/UserProfileImageSelectorView.axaml.cs b/src/Ryujinx.Ava/UI/Views/User/UserProfileImageSelectorView.axaml.cs
new file mode 100644
index 00000000..18f76f80
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/User/UserProfileImageSelectorView.axaml.cs
@@ -0,0 +1,124 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.VisualTree;
+using FluentAvalonia.UI.Controls;
+using FluentAvalonia.UI.Navigation;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Controls;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.HLE.FileSystem;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Processing;
+using System.IO;
+using Image = SixLabors.ImageSharp.Image;
+
+namespace Ryujinx.Ava.UI.Views.User
+{
+ public partial class UserProfileImageSelectorView : UserControl
+ {
+ private ContentManager _contentManager;
+ private NavigationDialogHost _parent;
+ private TempProfile _profile;
+
+ internal UserProfileImageSelectorViewModel ViewModel { get; private set; }
+
+ public UserProfileImageSelectorView()
+ {
+ InitializeComponent();
+ AddHandler(Frame.NavigatedToEvent, (s, e) =>
+ {
+ NavigatedTo(e);
+ }, RoutingStrategies.Direct);
+ }
+
+ private void NavigatedTo(NavigationEventArgs arg)
+ {
+ if (Program.PreviewerDetached)
+ {
+ switch (arg.NavigationMode)
+ {
+ case NavigationMode.New:
+ (_parent, _profile) = ((NavigationDialogHost, TempProfile))arg.Parameter;
+ _contentManager = _parent.ContentManager;
+
+ ((ContentDialog)_parent.Parent).Title = $"{LocaleManager.Instance[LocaleKeys.UserProfileWindowTitle]} - {LocaleManager.Instance[LocaleKeys.ProfileImageSelectionHeader]}";
+
+ if (Program.PreviewerDetached)
+ {
+ DataContext = ViewModel = new UserProfileImageSelectorViewModel();
+ ViewModel.FirmwareFound = _contentManager.GetCurrentFirmwareVersion() != null;
+ }
+
+ break;
+ case NavigationMode.Back:
+ if (_profile.Image != null)
+ {
+ _parent.GoBack();
+ }
+ break;
+ }
+ }
+ }
+
+ private async void Import_OnClick(object sender, RoutedEventArgs e)
+ {
+ OpenFileDialog dialog = new();
+ dialog.Filters.Add(new FileDialogFilter
+ {
+ Name = LocaleManager.Instance[LocaleKeys.AllSupportedFormats],
+ Extensions = { "jpg", "jpeg", "png", "bmp" }
+ });
+ dialog.Filters.Add(new FileDialogFilter { Name = "JPEG", Extensions = { "jpg", "jpeg" } });
+ dialog.Filters.Add(new FileDialogFilter { Name = "PNG", Extensions = { "png" } });
+ dialog.Filters.Add(new FileDialogFilter { Name = "BMP", Extensions = { "bmp" } });
+
+ dialog.AllowMultiple = false;
+
+ string[] image = await dialog.ShowAsync(((TopLevel)_parent.GetVisualRoot()) as Window);
+
+ if (image != null)
+ {
+ if (image.Length > 0)
+ {
+ string imageFile = image[0];
+
+ _profile.Image = ProcessProfileImage(File.ReadAllBytes(imageFile));
+
+ if (_profile.Image != null)
+ {
+ _parent.GoBack();
+ }
+ }
+ }
+ }
+
+ private void GoBack(object sender, RoutedEventArgs e)
+ {
+ _parent.GoBack();
+ }
+
+ private void SelectFirmwareImage_OnClick(object sender, RoutedEventArgs e)
+ {
+ if (ViewModel.FirmwareFound)
+ {
+ _parent.Navigate(typeof(UserFirmwareAvatarSelectorView), (_parent, _profile));
+ }
+ }
+
+ private static byte[] ProcessProfileImage(byte[] buffer)
+ {
+ using (Image image = Image.Load(buffer))
+ {
+ image.Mutate(x => x.Resize(256, 256));
+
+ using (MemoryStream streamJpg = new())
+ {
+ image.SaveAsJpeg(streamJpg);
+
+ return streamJpg.ToArray();
+ }
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/User/UserRecovererView.axaml b/src/Ryujinx.Ava/UI/Views/User/UserRecovererView.axaml
new file mode 100644
index 00000000..62b5e184
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/User/UserRecovererView.axaml
@@ -0,0 +1,83 @@
+<UserControl
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ mc:Ignorable="d"
+ d:DesignWidth="550"
+ d:DesignHeight="450"
+ Width="500"
+ Height="400"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ x:Class="Ryujinx.Ava.UI.Views.User.UserRecovererView"
+ x:CompileBindings="True"
+ x:DataType="viewModels:UserProfileViewModel"
+ Focusable="True">
+ <Design.DataContext>
+ <viewModels:UserProfileViewModel />
+ </Design.DataContext>
+ <Grid HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch">
+ <Grid.RowDefinitions>
+ <RowDefinition/>
+ <RowDefinition Height="Auto"/>
+ </Grid.RowDefinitions>
+ <Border
+ CornerRadius="5"
+ BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
+ BorderThickness="1"
+ Grid.Row="0">
+ <Panel>
+ <ListBox
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ Items="{Binding LostProfiles}">
+ <ListBox.ItemTemplate>
+ <DataTemplate>
+ <Border
+ Margin="2"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ ClipToBounds="True"
+ CornerRadius="5">
+ <Grid Margin="0">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition/>
+ <ColumnDefinition Width="Auto"/>
+ </Grid.ColumnDefinitions>
+ <TextBlock
+ HorizontalAlignment="Stretch"
+ Text="{Binding UserId}"
+ TextAlignment="Left"
+ TextWrapping="Wrap" />
+ <Button Grid.Column="1"
+ HorizontalAlignment="Right"
+ Click="Recover"
+ CommandParameter="{Binding}"
+ Content="{locale:Locale Recover}"/>
+ </Grid>
+ </Border>
+ </DataTemplate>
+ </ListBox.ItemTemplate>
+ </ListBox>
+ <TextBlock
+ IsVisible="{Binding IsEmpty}"
+ TextAlignment="Center"
+ Text="{locale:Locale UserProfilesRecoverEmptyList}"/>
+ </Panel>
+ </Border>
+ <StackPanel
+ Grid.Row="1"
+ Margin="0 24 0 0"
+ Orientation="Horizontal">
+ <Button
+ Width="50"
+ MinWidth="50"
+ Click="GoBack">
+ <ui:SymbolIcon Symbol="Back"/>
+ </Button>
+ </StackPanel>
+ </Grid>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/User/UserRecovererView.axaml.cs b/src/Ryujinx.Ava/UI/Views/User/UserRecovererView.axaml.cs
new file mode 100644
index 00000000..0c53e53d
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/User/UserRecovererView.axaml.cs
@@ -0,0 +1,51 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using FluentAvalonia.UI.Controls;
+using FluentAvalonia.UI.Navigation;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Controls;
+
+namespace Ryujinx.Ava.UI.Views.User
+{
+ public partial class UserRecovererView : UserControl
+ {
+ private NavigationDialogHost _parent;
+
+ public UserRecovererView()
+ {
+ InitializeComponent();
+ AddHandler(Frame.NavigatedToEvent, (s, e) =>
+ {
+ NavigatedTo(e);
+ }, RoutingStrategies.Direct);
+ }
+
+ private void NavigatedTo(NavigationEventArgs arg)
+ {
+ if (Program.PreviewerDetached)
+ {
+ switch (arg.NavigationMode)
+ {
+ case NavigationMode.New:
+ var parent = (NavigationDialogHost)arg.Parameter;
+
+ _parent = parent;
+
+ ((ContentDialog)_parent.Parent).Title = $"{LocaleManager.Instance[LocaleKeys.UserProfileWindowTitle]} - {LocaleManager.Instance[LocaleKeys.UserProfilesRecoverHeading]}";
+
+ break;
+ }
+ }
+ }
+
+ private void GoBack(object sender, RoutedEventArgs e)
+ {
+ _parent?.GoBack();
+ }
+
+ private void Recover(object sender, RoutedEventArgs e)
+ {
+ _parent?.RecoverLostAccounts();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/User/UserSaveManagerView.axaml b/src/Ryujinx.Ava/UI/Views/User/UserSaveManagerView.axaml
new file mode 100644
index 00000000..ec931dd9
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/User/UserSaveManagerView.axaml
@@ -0,0 +1,215 @@
+<UserControl
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
+ xmlns:models="clr-namespace:Ryujinx.Ava.UI.Models"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ mc:Ignorable="d"
+ d:DesignWidth="600"
+ d:DesignHeight="500"
+ Height="450"
+ Width="550"
+ x:Class="Ryujinx.Ava.UI.Views.User.UserSaveManagerView"
+ x:CompileBindings="True"
+ x:DataType="viewModels:UserSaveManagerViewModel"
+ Focusable="True">
+ <Design.DataContext>
+ <viewModels:UserSaveManagerViewModel />
+ </Design.DataContext>
+ <UserControl.Resources>
+ <helpers:BitmapArrayValueConverter x:Key="ByteImage" />
+ </UserControl.Resources>
+ <Grid>
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <Grid
+ Grid.Row="0"
+ HorizontalAlignment="Stretch">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition />
+ </Grid.ColumnDefinitions>
+ <StackPanel
+ Spacing="10"
+ Orientation="Horizontal"
+ HorizontalAlignment="Left"
+ VerticalAlignment="Center">
+ <Label Content="{locale:Locale CommonSort}" VerticalAlignment="Center" />
+ <ComboBox SelectedIndex="{Binding SortIndex}" Width="100">
+ <ComboBoxItem>
+ <Label
+ VerticalAlignment="Center"
+ HorizontalContentAlignment="Left"
+ Content="{locale:Locale Name}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <Label
+ VerticalAlignment="Center"
+ HorizontalContentAlignment="Left"
+ Content="{locale:Locale Size}" />
+ </ComboBoxItem>
+ <ComboBox.Styles>
+ <Style Selector="ContentControl#ContentPresenter">
+ <Setter Property="HorizontalAlignment" Value="Left" />
+ </Style>
+ </ComboBox.Styles>
+ </ComboBox>
+ <ComboBox SelectedIndex="{Binding OrderIndex}" Width="150">
+ <ComboBoxItem>
+ <Label
+ VerticalAlignment="Center"
+ HorizontalContentAlignment="Left"
+ Content="{locale:Locale OrderAscending}" />
+ </ComboBoxItem>
+ <ComboBoxItem>
+ <Label
+ VerticalAlignment="Center"
+ HorizontalContentAlignment="Left"
+ Content="{locale:Locale OrderDescending}" />
+ </ComboBoxItem>
+ <ComboBox.Styles>
+ <Style Selector="ContentControl#ContentPresenter">
+ <Setter Property="HorizontalAlignment" Value="Left" />
+ </Style>
+ </ComboBox.Styles>
+ </ComboBox>
+ </StackPanel>
+ <Grid
+ Grid.Column="1"
+ HorizontalAlignment="Stretch"
+ Margin="10,0, 0, 0">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto"/>
+ <ColumnDefinition/>
+ </Grid.ColumnDefinitions>
+ <Label Content="{locale:Locale Search}" VerticalAlignment="Center" />
+ <TextBox
+ Margin="5,0,0,0"
+ Grid.Column="1"
+ HorizontalAlignment="Stretch"
+ Text="{Binding Search}" />
+ </Grid>
+ </Grid>
+ <Border
+ Grid.Row="1"
+ Margin="0,5"
+ BorderThickness="1"
+ BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
+ CornerRadius="5"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch">
+ <ListBox
+ Name="SaveList"
+ VirtualizationMode="None"
+ Items="{Binding Views}"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch">
+ <ListBox.Styles>
+ <Style Selector="ListBoxItem">
+ <Setter Property="Padding" Value="10" />
+ <Setter Property="Margin" Value="5" />
+ <Setter Property="CornerRadius" Value="4" />
+ </Style>
+ <Style Selector="ListBoxItem:selected /template/ Border#SelectionIndicator">
+ <Setter Property="IsVisible" Value="False" />
+ </Style>
+ </ListBox.Styles>
+ <ListBox.ItemTemplate>
+ <DataTemplate x:DataType="models:SaveModel">
+ <Grid HorizontalAlignment="Stretch">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition />
+ <ColumnDefinition Width="Auto" />
+ </Grid.ColumnDefinitions>
+ <StackPanel
+ Grid.Column="0"
+ Orientation="Horizontal"
+ Spacing="5">
+ <Border
+ Height="42"
+ Width="42"
+ Padding="10"
+ BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
+ BorderThickness="1"
+ IsVisible="{Binding !InGameList}">
+ <ui:SymbolIcon
+ Symbol="Help"
+ FontSize="30"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center" />
+ </Border>
+ <Image
+ IsVisible="{Binding InGameList}"
+ Width="42"
+ Height="42"
+ Source="{Binding Icon, Converter={StaticResource ByteImage}}" />
+ <TextBlock
+ MaxLines="3"
+ Width="320"
+ Margin="5"
+ TextWrapping="Wrap"
+ Text="{Binding Title}"
+ VerticalAlignment="Center" />
+ </StackPanel>
+ <StackPanel
+ Grid.Column="1"
+ Spacing="10"
+ HorizontalAlignment="Right"
+ Orientation="Horizontal">
+ <Label
+ Content="{Binding SizeString}"
+ IsVisible="{Binding SizeAvailable}"
+ VerticalAlignment="Center"
+ HorizontalAlignment="Right" />
+ <Button
+ VerticalAlignment="Center"
+ HorizontalAlignment="Right"
+ Padding="10"
+ MinWidth="0"
+ MinHeight="0"
+ Name="OpenLocation"
+ Click="OpenLocation">
+ <ui:SymbolIcon
+ Symbol="OpenFolder"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center" />
+ </Button>
+ <Button
+ VerticalAlignment="Center"
+ HorizontalAlignment="Right"
+ Padding="10"
+ MinWidth="0"
+ MinHeight="0"
+ Name="Delete"
+ Click="Delete">
+ <ui:SymbolIcon
+ Symbol="Delete"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center" />
+ </Button>
+ </StackPanel>
+ </Grid>
+ </DataTemplate>
+ </ListBox.ItemTemplate>
+ </ListBox>
+ </Border>
+ <StackPanel
+ Grid.Row="2"
+ Margin="0 24 0 0"
+ Orientation="Horizontal">
+ <Button
+ Width="50"
+ MinWidth="50"
+ Click="GoBack">
+ <ui:SymbolIcon Symbol="Back" />
+ </Button>
+ </StackPanel>
+ </Grid>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/User/UserSaveManagerView.axaml.cs b/src/Ryujinx.Ava/UI/Views/User/UserSaveManagerView.axaml.cs
new file mode 100644
index 00000000..08fef27d
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/User/UserSaveManagerView.axaml.cs
@@ -0,0 +1,147 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using FluentAvalonia.UI.Controls;
+using FluentAvalonia.UI.Navigation;
+using LibHac;
+using LibHac.Common;
+using LibHac.Fs;
+using LibHac.Fs.Shim;
+using Ryujinx.Ava.Common;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Controls;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.HLE.FileSystem;
+using Ryujinx.HLE.HOS.Services.Account.Acc;
+using System;
+using System.Collections.ObjectModel;
+using System.Threading.Tasks;
+using UserId = LibHac.Fs.UserId;
+
+namespace Ryujinx.Ava.UI.Views.User
+{
+ public partial class UserSaveManagerView : UserControl
+ {
+ internal UserSaveManagerViewModel ViewModel { get; private set; }
+
+ private AccountManager _accountManager;
+ private HorizonClient _horizonClient;
+ private VirtualFileSystem _virtualFileSystem;
+ private NavigationDialogHost _parent;
+
+ public UserSaveManagerView()
+ {
+ InitializeComponent();
+ AddHandler(Frame.NavigatedToEvent, (s, e) =>
+ {
+ NavigatedTo(e);
+ }, RoutingStrategies.Direct);
+ }
+
+ private void NavigatedTo(NavigationEventArgs arg)
+ {
+ if (Program.PreviewerDetached)
+ {
+ switch (arg.NavigationMode)
+ {
+ case NavigationMode.New:
+ var args = ((NavigationDialogHost parent, AccountManager accountManager, HorizonClient client, VirtualFileSystem virtualFileSystem))arg.Parameter;
+ _accountManager = args.accountManager;
+ _horizonClient = args.client;
+ _virtualFileSystem = args.virtualFileSystem;
+
+ _parent = args.parent;
+ break;
+ }
+
+ DataContext = ViewModel = new UserSaveManagerViewModel(_accountManager);
+ ((ContentDialog)_parent.Parent).Title = $"{LocaleManager.Instance[LocaleKeys.UserProfileWindowTitle]} - {ViewModel.SaveManagerHeading}";
+
+ Task.Run(LoadSaves);
+ }
+ }
+
+ public void LoadSaves()
+ {
+ ViewModel.Saves.Clear();
+ var saves = new ObservableCollection<SaveModel>();
+ var saveDataFilter = SaveDataFilter.Make(
+ programId: default,
+ saveType: SaveDataType.Account,
+ new UserId((ulong)_accountManager.LastOpenedUser.UserId.High, (ulong)_accountManager.LastOpenedUser.UserId.Low),
+ saveDataId: default,
+ index: default);
+
+ using var saveDataIterator = new UniqueRef<SaveDataIterator>();
+
+ _horizonClient.Fs.OpenSaveDataIterator(ref saveDataIterator.Ref, SaveDataSpaceId.User, in saveDataFilter).ThrowIfFailure();
+
+ Span<SaveDataInfo> saveDataInfo = stackalloc SaveDataInfo[10];
+
+ while (true)
+ {
+ saveDataIterator.Get.ReadSaveDataInfo(out long readCount, saveDataInfo).ThrowIfFailure();
+
+ if (readCount == 0)
+ {
+ break;
+ }
+
+ for (int i = 0; i < readCount; i++)
+ {
+ var save = saveDataInfo[i];
+ if (save.ProgramId.Value != 0)
+ {
+ var saveModel = new SaveModel(save, _virtualFileSystem);
+ saves.Add(saveModel);
+ }
+ }
+ }
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ ViewModel.Saves = saves;
+ ViewModel.Sort();
+ });
+ }
+
+ private void GoBack(object sender, RoutedEventArgs e)
+ {
+ _parent?.GoBack();
+ }
+
+ private void OpenLocation(object sender, RoutedEventArgs e)
+ {
+ if (sender is Avalonia.Controls.Button button)
+ {
+ if (button.DataContext is SaveModel saveModel)
+ {
+ ApplicationHelper.OpenSaveDir(saveModel.SaveId);
+ }
+ }
+ }
+
+ private async void Delete(object sender, RoutedEventArgs e)
+ {
+ if (sender is Avalonia.Controls.Button button)
+ {
+ if (button.DataContext is SaveModel saveModel)
+ {
+ var result = await ContentDialogHelper.CreateConfirmationDialog(LocaleManager.Instance[LocaleKeys.DeleteUserSave],
+ LocaleManager.Instance[LocaleKeys.IrreversibleActionNote],
+ LocaleManager.Instance[LocaleKeys.InputDialogYes],
+ LocaleManager.Instance[LocaleKeys.InputDialogNo], "");
+
+ if (result == UserResult.Yes)
+ {
+ _horizonClient.Fs.DeleteSaveData(SaveDataSpaceId.User, saveModel.SaveId);
+ ViewModel.Saves.Remove(saveModel);
+ ViewModel.Sort();
+ }
+ }
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/User/UserSelectorView.axaml b/src/Ryujinx.Ava/UI/Views/User/UserSelectorView.axaml
new file mode 100644
index 00000000..9a6ba054
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/User/UserSelectorView.axaml
@@ -0,0 +1,165 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Views.User.UserSelectorViews"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:flex="clr-namespace:Avalonia.Flexbox;assembly=Avalonia.Flexbox"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
+ xmlns:models="clr-namespace:Ryujinx.Ava.UI.Models"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ d:DesignHeight="450"
+ MinWidth="500"
+ d:DesignWidth="800"
+ mc:Ignorable="d"
+ Focusable="True"
+ x:CompileBindings="True"
+ x:DataType="viewModels:UserProfileViewModel">
+ <UserControl.Resources>
+ <helpers:BitmapArrayValueConverter x:Key="ByteImage" />
+ </UserControl.Resources>
+ <Design.DataContext>
+ <viewModels:UserProfileViewModel />
+ </Design.DataContext>
+ <Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
+ <Grid.RowDefinitions>
+ <RowDefinition />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <Border
+ CornerRadius="5"
+ BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
+ BorderThickness="1">
+ <ListBox
+ MaxHeight="300"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Center"
+ SelectionChanged="ProfilesList_SelectionChanged"
+ Background="Transparent"
+ Items="{Binding Profiles}">
+ <ListBox.ItemsPanel>
+ <ItemsPanelTemplate>
+ <flex:FlexPanel
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ AlignContent="FlexStart"
+ JustifyContent="FlexStart" />
+ </ItemsPanelTemplate>
+ </ListBox.ItemsPanel>
+ <ListBox.Styles>
+ <Style Selector="ListBoxItem">
+ <Setter Property="Margin" Value="5 5 0 5" />
+ <Setter Property="CornerRadius" Value="5" />
+ </Style>
+ <Style Selector="Border#SelectionIndicator">
+ <Setter Property="Opacity" Value="0" />
+ </Style>
+ </ListBox.Styles>
+ <ListBox.DataTemplates>
+ <DataTemplate
+ DataType="models:UserProfile">
+ <Grid
+ PointerEnter="Grid_PointerEntered"
+ PointerLeave="Grid_OnPointerExited">
+ <Border
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ ClipToBounds="True"
+ CornerRadius="5"
+ Background="{Binding BackgroundColor}">
+ <StackPanel
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch">
+ <Image
+ Width="96"
+ Height="96"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Top"
+ Source="{Binding Image, Converter={StaticResource ByteImage}}" />
+ <TextBlock
+ HorizontalAlignment="Stretch"
+ MaxWidth="90"
+ Text="{Binding Name}"
+ TextAlignment="Center"
+ TextWrapping="Wrap"
+ TextTrimming="CharacterEllipsis"
+ MaxLines="2"
+ Margin="5" />
+ </StackPanel>
+ </Border>
+ <Border
+ Margin="2"
+ Height="24"
+ Width="24"
+ CornerRadius="12"
+ HorizontalAlignment="Right"
+ VerticalAlignment="Top"
+ Background="{DynamicResource ThemeContentBackgroundColor}"
+ IsVisible="{Binding IsPointerOver}">
+ <Button
+ MaxHeight="24"
+ MaxWidth="24"
+ MinHeight="24"
+ MinWidth="24"
+ CornerRadius="12"
+ Padding="0"
+ Click="EditUser">
+ <ui:SymbolIcon Symbol="Edit" />
+ </Button>
+ </Border>
+ </Grid>
+ </DataTemplate>
+ <DataTemplate
+ DataType="viewModels:BaseModel">
+ <Panel
+ Height="118"
+ Width="96">
+ <Button
+ MinWidth="50"
+ MinHeight="50"
+ MaxWidth="50"
+ MaxHeight="50"
+ CornerRadius="25"
+ Margin="10"
+ Padding="0"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Click="AddUser">
+ <ui:SymbolIcon Symbol="Add" />
+ </Button>
+ <Panel.Styles>
+ <Style Selector="Panel">
+ <Setter Property="Background" Value="{DynamicResource ListBoxBackground}"/>
+ </Style>
+ </Panel.Styles>
+ </Panel>
+ </DataTemplate>
+ </ListBox.DataTemplates>
+ </ListBox>
+ </Border>
+ <StackPanel
+ Grid.Row="1"
+ Margin="0 24 0 0"
+ HorizontalAlignment="Left"
+ Orientation="Horizontal"
+ Spacing="10">
+ <Button
+ Click="ManageSaves"
+ Content="{locale:Locale UserProfilesManageSaves}" />
+ <Button
+ Click="RecoverLostAccounts"
+ Content="{locale:Locale UserProfilesRecoverLostAccounts}" />
+ </StackPanel>
+ <StackPanel
+ Grid.Row="1"
+ Margin="0 24 0 0"
+ HorizontalAlignment="Right"
+ Orientation="Horizontal">
+ <Button
+ Click="Close"
+ Content="{locale:Locale UserProfilesClose}" />
+ </StackPanel>
+ </Grid>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Views/User/UserSelectorView.axaml.cs b/src/Ryujinx.Ava/UI/Views/User/UserSelectorView.axaml.cs
new file mode 100644
index 00000000..aa89fea9
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Views/User/UserSelectorView.axaml.cs
@@ -0,0 +1,128 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using FluentAvalonia.UI.Controls;
+using FluentAvalonia.UI.Navigation;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Controls;
+using Ryujinx.Ava.UI.ViewModels;
+using UserProfile = Ryujinx.Ava.UI.Models.UserProfile;
+
+namespace Ryujinx.Ava.UI.Views.User
+{
+ public partial class UserSelectorViews : UserControl
+ {
+ private NavigationDialogHost _parent;
+
+ public UserProfileViewModel ViewModel { get; set; }
+
+ public UserSelectorViews()
+ {
+ InitializeComponent();
+
+ if (Program.PreviewerDetached)
+ {
+ AddHandler(Frame.NavigatedToEvent, (s, e) =>
+ {
+ NavigatedTo(e);
+ }, RoutingStrategies.Direct);
+ }
+ }
+
+ private void NavigatedTo(NavigationEventArgs arg)
+ {
+ if (Program.PreviewerDetached)
+ {
+ if (arg.NavigationMode == NavigationMode.New)
+ {
+ _parent = (NavigationDialogHost)arg.Parameter;
+ ViewModel = _parent.ViewModel;
+ }
+
+ if (arg.NavigationMode == NavigationMode.Back)
+ {
+ ((ContentDialog)_parent.Parent).Title = LocaleManager.Instance[LocaleKeys.UserProfileWindowTitle];
+ }
+
+ DataContext = ViewModel;
+ }
+ }
+
+ private void Grid_PointerEntered(object sender, PointerEventArgs e)
+ {
+ if (sender is Grid grid)
+ {
+ if (grid.DataContext is UserProfile profile)
+ {
+ profile.IsPointerOver = true;
+ }
+ }
+ }
+
+ private void Grid_OnPointerExited(object sender, PointerEventArgs e)
+ {
+ if (sender is Grid grid)
+ {
+ if (grid.DataContext is UserProfile profile)
+ {
+ profile.IsPointerOver = false;
+ }
+ }
+ }
+
+ private void ProfilesList_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (sender is ListBox listBox)
+ {
+ int selectedIndex = listBox.SelectedIndex;
+
+ if (selectedIndex >= 0 && selectedIndex < ViewModel.Profiles.Count)
+ {
+ if (ViewModel.Profiles[selectedIndex] is UserProfile userProfile)
+ {
+ _parent?.AccountManager?.OpenUser(userProfile.UserId);
+
+ foreach (BaseModel profile in ViewModel.Profiles)
+ {
+ if (profile is UserProfile uProfile)
+ {
+ uProfile.UpdateState();
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private void AddUser(object sender, RoutedEventArgs e)
+ {
+ _parent.AddUser();
+ }
+
+ private void EditUser(object sender, RoutedEventArgs e)
+ {
+ if (sender is Avalonia.Controls.Button button)
+ {
+ if (button.DataContext is UserProfile userProfile)
+ {
+ _parent.EditUser(userProfile);
+ }
+ }
+ }
+
+ private void ManageSaves(object sender, RoutedEventArgs e)
+ {
+ _parent.ManageSaves();
+ }
+
+ private void RecoverLostAccounts(object sender, RoutedEventArgs e)
+ {
+ _parent.RecoverLostAccounts();
+ }
+
+ private void Close(object sender, RoutedEventArgs e)
+ {
+ ((ContentDialog)_parent.Parent).Hide();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/AboutWindow.axaml b/src/Ryujinx.Ava/UI/Windows/AboutWindow.axaml
new file mode 100644
index 00000000..7bd3e20d
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/AboutWindow.axaml
@@ -0,0 +1,247 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Windows.AboutWindow"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:flex="clr-namespace:Avalonia.Flexbox;assembly=Avalonia.Flexbox"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:viewModel="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ Width="550"
+ Height="260"
+ Margin="0,-12,0,0"
+ d:DesignHeight="260"
+ d:DesignWidth="550"
+ x:CompileBindings="True"
+ x:DataType="viewModel:AboutWindowViewModel"
+ Focusable="True"
+ mc:Ignorable="d">
+ <Design.DataContext>
+ <viewModel:AboutWindowViewModel />
+ </Design.DataContext>
+ <Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition Width="*" />
+ </Grid.ColumnDefinitions>
+ <Grid
+ Grid.Column="0"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="*" />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <StackPanel
+ Grid.Row="0"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ Spacing="10">
+ <Grid>
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition Width="*" />
+ <ColumnDefinition Width="Auto" />
+ </Grid.ColumnDefinitions>
+ <Image
+ Grid.Column="0"
+ Height="80"
+ Source="resm:Ryujinx.Ui.Common.Resources.Logo_Ryujinx.png?assembly=Ryujinx.Ui.Common" />
+ <flex:FlexPanel
+ Grid.Column="2"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ Direction="Column"
+ JustifyContent="SpaceAround"
+ RowSpacing="2">
+ <TextBlock
+ FontSize="28"
+ FontWeight="Bold"
+ Text="Ryujinx"
+ TextAlignment="Left" />
+ <TextBlock Text="(REE-YOU-JINX)" TextAlignment="Left" />
+ </flex:FlexPanel>
+ </Grid>
+ <TextBlock
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ FontSize="10"
+ LineHeight="12"
+ Text="{Binding Version}"
+ TextAlignment="Center" />
+ </StackPanel>
+ <StackPanel
+ Grid.Row="2"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ Spacing="10">
+ <TextBlock
+ Width="200"
+ HorizontalAlignment="Center"
+ FontSize="10"
+ LineHeight="12"
+ Text="{locale:Locale AboutDisclaimerMessage}"
+ TextAlignment="Center"
+ TextWrapping="Wrap" />
+ <TextBlock
+ Name="AmiiboLabel"
+ Width="200"
+ HorizontalAlignment="Center"
+ FontSize="10"
+ LineHeight="12"
+ PointerPressed="AmiiboLabel_OnPointerPressed"
+ Text="{locale:Locale AboutAmiiboDisclaimerMessage}"
+ TextAlignment="Center"
+ TextWrapping="Wrap" />
+ <StackPanel
+ HorizontalAlignment="Center"
+ Orientation="Horizontal"
+ Spacing="10">
+ <Button
+ MinWidth="30"
+ MinHeight="30"
+ MaxWidth="30"
+ MaxHeight="30"
+ Padding="8"
+ Background="Transparent"
+ Click="Button_OnClick"
+ CornerRadius="15"
+ Tag="https://www.patreon.com/ryujinx"
+ ToolTip.Tip="{locale:Locale AboutPatreonUrlTooltipMessage}">
+ <Image Source="{Binding PatreonLogo}" />
+ </Button>
+ <Button
+ MinWidth="30"
+ MinHeight="30"
+ MaxWidth="30"
+ MaxHeight="30"
+ Padding="8"
+ Background="Transparent"
+ Click="Button_OnClick"
+ CornerRadius="15"
+ Tag="https://github.com/Ryujinx/Ryujinx"
+ ToolTip.Tip="{locale:Locale AboutGithubUrlTooltipMessage}">
+ <Image Source="{Binding GithubLogo}" />
+ </Button>
+ <Button
+ MinWidth="30"
+ MinHeight="30"
+ MaxWidth="30"
+ MaxHeight="30"
+ Padding="8"
+ Background="Transparent"
+ Click="Button_OnClick"
+ CornerRadius="15"
+ Tag="https://discordapp.com/invite/N2FmfVc"
+ ToolTip.Tip="{locale:Locale AboutDiscordUrlTooltipMessage}">
+ <Image Source="{Binding DiscordLogo}" />
+ </Button>
+ <Button
+ MinWidth="30"
+ MinHeight="30"
+ MaxWidth="30"
+ MaxHeight="30"
+ Padding="8"
+ Background="Transparent"
+ Click="Button_OnClick"
+ CornerRadius="15"
+ Tag="https://twitter.com/RyujinxEmu"
+ ToolTip.Tip="{locale:Locale AboutTwitterUrlTooltipMessage}">
+ <Image Source="{Binding TwitterLogo}" />
+ </Button>
+ <Button
+ MinWidth="30"
+ MinHeight="30"
+ MaxWidth="30"
+ MaxHeight="30"
+ Padding="8"
+ Background="Transparent"
+ Click="Button_OnClick"
+ CornerRadius="15"
+ Tag="https://www.ryujinx.org"
+ ToolTip.Tip="{locale:Locale AboutUrlTooltipMessage}">
+ <ui:SymbolIcon Foreground="{DynamicResource ThemeForegroundColor}" Symbol="Link" />
+ </Button>
+ </StackPanel>
+ </StackPanel>
+ </Grid>
+ <Border
+ Grid.Column="1"
+ Width="1"
+ Margin="20,0"
+ VerticalAlignment="Stretch"
+ BorderBrush="{DynamicResource ThemeControlBorderColor}"
+ BorderThickness="1,0,0,0" />
+ <Grid
+ Grid.Column="2"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <StackPanel
+ Grid.Row="0"
+ Margin="0,10,0,0"
+ Spacing="2">
+ <TextBlock
+ FontSize="15"
+ FontWeight="Bold"
+ Text="{locale:Locale AboutRyujinxAboutTitle}" />
+ <TextBlock
+ FontSize="10"
+ Text="{locale:Locale AboutRyujinxAboutContent}"
+ TextWrapping="Wrap" />
+ </StackPanel>
+ <StackPanel
+ Grid.Row="1"
+ Margin="0,10,0,0"
+ Spacing="2">
+ <TextBlock
+ FontSize="15"
+ FontWeight="Bold"
+ Text="{locale:Locale AboutRyujinxMaintainersTitle}" />
+ <TextBlock
+ FontSize="10"
+ Text="{Binding Developers}"
+ TextWrapping="Wrap" />
+ <Button
+ Padding="5"
+ HorizontalAlignment="Left"
+ Background="Transparent"
+ Click="Button_OnClick"
+ Tag="https://github.com/Ryujinx/Ryujinx/graphs/contributors?type=a">
+ <TextBlock
+ FontSize="10"
+ Text="{locale:Locale AboutRyujinxContributorsButtonHeader}"
+ TextAlignment="Right"
+ ToolTip.Tip="{locale:Locale AboutRyujinxMaintainersContentTooltipMessage}" />
+ </Button>
+ </StackPanel>
+ <StackPanel
+ Grid.Row="2"
+ Margin="0,10,0,0"
+ Spacing="2">
+ <TextBlock
+ FontSize="15"
+ FontWeight="Bold"
+ Text="{locale:Locale AboutRyujinxSupprtersTitle}" />
+ <ScrollViewer
+ Height="70"
+ HorizontalScrollBarVisibility="Disabled"
+ VerticalScrollBarVisibility="Visible">
+ <TextBlock
+ Name="SupportersTextBlock"
+ VerticalAlignment="Top"
+ FontSize="10"
+ Text="{Binding Supporters}"
+ TextWrapping="Wrap" />
+ </ScrollViewer>
+ </StackPanel>
+ </Grid>
+ </Grid>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/AboutWindow.axaml.cs b/src/Ryujinx.Ava/UI/Windows/AboutWindow.axaml.cs
new file mode 100644
index 00000000..36a28605
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/AboutWindow.axaml.cs
@@ -0,0 +1,62 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Styling;
+using FluentAvalonia.UI.Controls;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.Ui.Common.Helper;
+using System.Threading.Tasks;
+using Button = Avalonia.Controls.Button;
+
+namespace Ryujinx.Ava.UI.Windows
+{
+ public partial class AboutWindow : UserControl
+ {
+ public AboutWindow()
+ {
+ DataContext = new AboutWindowViewModel();
+
+ InitializeComponent();
+ }
+
+ public static async Task Show()
+ {
+ ContentDialog contentDialog = new()
+ {
+ PrimaryButtonText = "",
+ SecondaryButtonText = "",
+ CloseButtonText = LocaleManager.Instance[LocaleKeys.UserProfilesClose],
+ Content = new AboutWindow()
+ };
+
+ Style closeButton = new(x => x.Name("CloseButton"));
+ closeButton.Setters.Add(new Setter(WidthProperty, 80d));
+
+ Style closeButtonParent = new(x => x.Name("CommandSpace"));
+ closeButtonParent.Setters.Add(new Setter(HorizontalAlignmentProperty, Avalonia.Layout.HorizontalAlignment.Right));
+
+ contentDialog.Styles.Add(closeButton);
+ contentDialog.Styles.Add(closeButtonParent);
+
+ await ContentDialogHelper.ShowAsync(contentDialog);
+ }
+
+ private void Button_OnClick(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button button)
+ {
+ OpenHelper.OpenUrl(button.Tag.ToString());
+ }
+ }
+
+ private void AmiiboLabel_OnPointerPressed(object sender, PointerPressedEventArgs e)
+ {
+ if (sender is TextBlock)
+ {
+ OpenHelper.OpenUrl("https://amiiboapi.com");
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/AmiiboWindow.axaml b/src/Ryujinx.Ava/UI/Windows/AmiiboWindow.axaml
new file mode 100644
index 00000000..90d47b8e
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/AmiiboWindow.axaml
@@ -0,0 +1,74 @@
+<window:StyleableWindow
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:window="clr-namespace:Ryujinx.Ava.UI.Windows"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ mc:Ignorable="d"
+ d:DesignWidth="400"
+ d:DesignHeight="350"
+ x:Class="Ryujinx.Ava.UI.Windows.AmiiboWindow"
+ CanResize="False"
+ WindowStartupLocation="CenterOwner"
+ Width="800"
+ MinHeight="650"
+ Height="650"
+ SizeToContent="Manual"
+ MinWidth="600"
+ Focusable="True">
+ <Design.DataContext>
+ <viewModels:AmiiboWindowViewModel />
+ </Design.DataContext>
+ <Grid Margin="15" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="*" />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <Grid Grid.Row="1" HorizontalAlignment="Stretch">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition />
+ <ColumnDefinition />
+ </Grid.ColumnDefinitions>
+ <StackPanel Spacing="10" Orientation="Horizontal" HorizontalAlignment="Left">
+ <TextBlock VerticalAlignment="Center" Text="{locale:Locale AmiiboSeriesLabel}" />
+ <ComboBox SelectedIndex="{Binding SeriesSelectedIndex}" Items="{Binding AmiiboSeries}" MinWidth="100" />
+ </StackPanel>
+ <StackPanel Spacing="10" Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right">
+ <TextBlock VerticalAlignment="Center" Text="{locale:Locale AmiiboCharacterLabel}" />
+ <ComboBox SelectedIndex="{Binding AmiiboSelectedIndex}" MinWidth="100" Items="{Binding AmiiboList}" />
+ </StackPanel>
+ </Grid>
+ <StackPanel Margin="20" Grid.Row="2">
+ <Image Source="{Binding AmiiboImage}" Height="350" Width="350" HorizontalAlignment="Center" />
+ <ScrollViewer MaxHeight="120" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"
+ Margin="20" VerticalAlignment="Top" HorizontalAlignment="Stretch">
+ <TextBlock TextWrapping="Wrap" Text="{Binding Usage}" HorizontalAlignment="Center"
+ TextAlignment="Center" />
+ </ScrollViewer>
+ </StackPanel>
+ <Grid Grid.Row="3">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition Width="*" />
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition Width="Auto" />
+ </Grid.ColumnDefinitions>
+ <CheckBox Margin="10" Grid.Column="0" VerticalContentAlignment="Center" IsChecked="{Binding ShowAllAmiibo}"
+ Content="{locale:Locale AmiiboOptionsShowAllLabel}" />
+ <CheckBox Margin="10" VerticalContentAlignment="Center" Grid.Column="1" IsChecked="{Binding UseRandomUuid}"
+ Content="{locale:Locale AmiiboOptionsUsRandomTagLabel}" />
+
+ <Button Grid.Column="3" IsEnabled="{Binding EnableScanning}" Width="80"
+ Content="{locale:Locale AmiiboScanButtonLabel}" Name="ScanButton"
+ Click="ScanButton_Click" />
+ <Button Grid.Column="4" Margin="10,0" Width="80" Content="{locale:Locale InputDialogCancel}"
+ Name="CancelButton"
+ Click="CancelButton_Click" />
+ </Grid>
+ </Grid>
+</window:StyleableWindow> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/AmiiboWindow.axaml.cs b/src/Ryujinx.Ava/UI/Windows/AmiiboWindow.axaml.cs
new file mode 100644
index 00000000..206d0a7e
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/AmiiboWindow.axaml.cs
@@ -0,0 +1,59 @@
+using Avalonia.Interactivity;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.Ui.Common.Models.Amiibo;
+
+namespace Ryujinx.Ava.UI.Windows
+{
+ public partial class AmiiboWindow : StyleableWindow
+ {
+ public AmiiboWindow(bool showAll, string lastScannedAmiiboId, string titleId)
+ {
+ ViewModel = new AmiiboWindowViewModel(this, lastScannedAmiiboId, titleId);
+
+ ViewModel.ShowAllAmiibo = showAll;
+
+ DataContext = ViewModel;
+
+ InitializeComponent();
+
+ Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance[LocaleKeys.Amiibo];
+ }
+
+ public AmiiboWindow()
+ {
+ ViewModel = new AmiiboWindowViewModel(this, string.Empty, string.Empty);
+
+ DataContext = ViewModel;
+
+ InitializeComponent();
+
+ if (Program.PreviewerDetached)
+ {
+ Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance[LocaleKeys.Amiibo];
+ }
+ }
+
+ public bool IsScanned { get; set; }
+ public AmiiboApi ScannedAmiibo { get; set; }
+ public AmiiboWindowViewModel ViewModel { get; set; }
+
+ private void ScanButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (ViewModel.AmiiboSelectedIndex > -1)
+ {
+ AmiiboApi amiibo = ViewModel.AmiiboList[ViewModel.AmiiboSelectedIndex];
+ ScannedAmiibo = amiibo;
+ IsScanned = true;
+ Close();
+ }
+ }
+
+ private void CancelButton_Click(object sender, RoutedEventArgs e)
+ {
+ IsScanned = false;
+
+ Close();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/CheatWindow.axaml b/src/Ryujinx.Ava/UI/Windows/CheatWindow.axaml
new file mode 100644
index 00000000..3557ed69
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/CheatWindow.axaml
@@ -0,0 +1,106 @@
+<window:StyleableWindow
+ x:Class="Ryujinx.Ava.UI.Windows.CheatWindow"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:model="clr-namespace:Ryujinx.Ava.UI.Models"
+ xmlns:window="clr-namespace:Ryujinx.Ava.UI.Windows"
+ Width="500"
+ Height="500"
+ MinWidth="500"
+ MinHeight="500"
+ WindowStartupLocation="CenterOwner"
+ mc:Ignorable="d"
+ Focusable="True">
+ <Window.Styles>
+ <Style Selector="TreeViewItem">
+ <Setter Property="IsExpanded" Value="True" />
+ </Style>
+ </Window.Styles>
+ <Grid Name="CheatGrid" Margin="15">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="*" />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <TextBlock
+ Grid.Row="1"
+ MaxWidth="500"
+ Margin="20,15,20,20"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ LineHeight="18"
+ Text="{Binding Heading}"
+ TextAlignment="Center"
+ TextWrapping="Wrap" />
+ <Border
+ Grid.Row="2"
+ Margin="5"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ BorderBrush="Gray"
+ BorderThickness="1">
+ <TreeView
+ Name="CheatsView"
+ MinHeight="300"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ Items="{Binding LoadedCheats}">
+ <TreeView.Styles>
+ <Styles>
+ <Style Selector="TreeViewItem:empty /template/ ItemsPresenter">
+ <Setter Property="IsVisible" Value="False" />
+ </Style>
+ </Styles>
+ </TreeView.Styles>
+ <TreeView.DataTemplates>
+ <TreeDataTemplate DataType="model:CheatsList" ItemsSource="{Binding}">
+ <StackPanel HorizontalAlignment="Left" Orientation="Horizontal">
+ <CheckBox MinWidth="20" IsChecked="{Binding IsEnabled}" />
+ <TextBlock Width="150" Text="{Binding BuildId}" />
+ <TextBlock Text="{Binding Path}" />
+ </StackPanel>
+ </TreeDataTemplate>
+ <DataTemplate x:DataType="model:CheatModel">
+ <StackPanel
+ Margin="0"
+ HorizontalAlignment="Left"
+ Orientation="Horizontal">
+ <CheckBox
+ MinWidth="20"
+ Margin="5,0"
+ Padding="0"
+ IsChecked="{Binding IsEnabled}" />
+ <TextBlock VerticalAlignment="Center" Text="{Binding CleanName}" />
+ </StackPanel>
+ </DataTemplate>
+ </TreeView.DataTemplates>
+ </TreeView>
+ </Border>
+ <DockPanel
+ Grid.Row="3"
+ Margin="0"
+ HorizontalAlignment="Stretch">
+ <DockPanel Margin="0" HorizontalAlignment="Right">
+ <Button
+ Name="SaveButton"
+ MinWidth="90"
+ Margin="5"
+ Command="{Binding Save}"
+ IsVisible="{Binding !NoCheatsFound}">
+ <TextBlock Text="{locale:Locale SettingsButtonSave}" />
+ </Button>
+ <Button
+ Name="CancelButton"
+ MinWidth="90"
+ Margin="5"
+ Command="{Binding Close}">
+ <TextBlock Text="{locale:Locale InputDialogCancel}" />
+ </Button>
+ </DockPanel>
+ </DockPanel>
+ </Grid>
+</window:StyleableWindow> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/CheatWindow.axaml.cs b/src/Ryujinx.Ava/UI/Windows/CheatWindow.axaml.cs
new file mode 100644
index 00000000..cb939763
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/CheatWindow.axaml.cs
@@ -0,0 +1,119 @@
+using Avalonia.Collections;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.HLE.FileSystem;
+using Ryujinx.HLE.HOS;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace Ryujinx.Ava.UI.Windows
+{
+ public partial class CheatWindow : StyleableWindow
+ {
+ private readonly string _enabledCheatsPath;
+ public bool NoCheatsFound { get; }
+
+ private AvaloniaList<CheatsList> LoadedCheats { get; }
+
+ public string Heading { get; }
+
+ public CheatWindow()
+ {
+ DataContext = this;
+
+ InitializeComponent();
+
+ Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance[LocaleKeys.CheatWindowTitle];
+ }
+
+ public CheatWindow(VirtualFileSystem virtualFileSystem, string titleId, string titleName)
+ {
+ LoadedCheats = new AvaloniaList<CheatsList>();
+
+ Heading = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.CheatWindowHeading, titleName, titleId.ToUpper());
+
+ InitializeComponent();
+
+ string modsBasePath = virtualFileSystem.ModLoader.GetModsBasePath();
+ string titleModsPath = virtualFileSystem.ModLoader.GetTitleDir(modsBasePath, titleId);
+ ulong titleIdValue = ulong.Parse(titleId, System.Globalization.NumberStyles.HexNumber);
+
+ _enabledCheatsPath = Path.Combine(titleModsPath, "cheats", "enabled.txt");
+
+ string[] enabled = { };
+
+ if (File.Exists(_enabledCheatsPath))
+ {
+ enabled = File.ReadAllLines(_enabledCheatsPath);
+ }
+
+ int cheatAdded = 0;
+
+ var mods = new ModLoader.ModCache();
+
+ ModLoader.QueryContentsDir(mods, new DirectoryInfo(Path.Combine(modsBasePath, "contents")), titleIdValue);
+
+ string currentCheatFile = string.Empty;
+ string buildId = string.Empty;
+ string parentPath = string.Empty;
+
+ CheatsList currentGroup = null;
+
+ foreach (var cheat in mods.Cheats)
+ {
+ if (cheat.Path.FullName != currentCheatFile)
+ {
+ currentCheatFile = cheat.Path.FullName;
+ parentPath = currentCheatFile.Replace(titleModsPath, "");
+
+ buildId = Path.GetFileNameWithoutExtension(currentCheatFile).ToUpper();
+ currentGroup = new CheatsList(buildId, parentPath);
+
+ LoadedCheats.Add(currentGroup);
+ }
+
+ var model = new CheatModel(cheat.Name, buildId, enabled.Contains($"{buildId}-{cheat.Name}"));
+ currentGroup?.Add(model);
+
+ cheatAdded++;
+ }
+
+ if (cheatAdded == 0)
+ {
+ NoCheatsFound = true;
+ }
+
+ DataContext = this;
+
+ Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance[LocaleKeys.CheatWindowTitle];
+ }
+
+ public void Save()
+ {
+ if (NoCheatsFound)
+ {
+ return;
+ }
+
+ List<string> enabledCheats = new List<string>();
+
+ foreach (var cheats in LoadedCheats)
+ {
+ foreach (var cheat in cheats)
+ {
+ if (cheat.IsEnabled)
+ {
+ enabledCheats.Add(cheat.BuildIdKey);
+ }
+ }
+ }
+
+ Directory.CreateDirectory(Path.GetDirectoryName(_enabledCheatsPath));
+
+ File.WriteAllLines(_enabledCheatsPath, enabledCheats);
+
+ Close();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/ContentDialogOverlayWindow.axaml b/src/Ryujinx.Ava/UI/Windows/ContentDialogOverlayWindow.axaml
new file mode 100644
index 00000000..8b52bade
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/ContentDialogOverlayWindow.axaml
@@ -0,0 +1,25 @@
+<window:StyleableWindow
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:window="clr-namespace:Ryujinx.Ava.UI.Windows"
+ mc:Ignorable="d"
+ d:DesignWidth="800"
+ d:DesignHeight="450"
+ x:Class="Ryujinx.Ava.UI.Windows.ContentDialogOverlayWindow"
+ Title="ContentDialogOverlayWindow"
+ Focusable="False">
+ <window:StyleableWindow.Styles>
+ <Style Selector="ui|ContentDialog /template/ Panel#LayoutRoot">
+ <Setter Property="Background"
+ Value="Transparent" />
+ </Style>
+ </window:StyleableWindow.Styles>
+ <ui:ContentDialog Name="ContentDialog"
+ IsPrimaryButtonEnabled="True"
+ IsSecondaryButtonEnabled="True"
+ IsVisible="False"
+ Focusable="True"/>
+</window:StyleableWindow>
diff --git a/src/Ryujinx.Ava/UI/Windows/ContentDialogOverlayWindow.axaml.cs b/src/Ryujinx.Ava/UI/Windows/ContentDialogOverlayWindow.axaml.cs
new file mode 100644
index 00000000..3f77124d
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/ContentDialogOverlayWindow.axaml.cs
@@ -0,0 +1,25 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+using Avalonia.Media;
+
+namespace Ryujinx.Ava.UI.Windows
+{
+ public partial class ContentDialogOverlayWindow : StyleableWindow
+ {
+ public ContentDialogOverlayWindow()
+ {
+ InitializeComponent();
+#if DEBUG
+ this.AttachDevTools();
+#endif
+ ExtendClientAreaToDecorationsHint = true;
+ TransparencyLevelHint = WindowTransparencyLevel.Transparent;
+ WindowStartupLocation = WindowStartupLocation.Manual;
+ SystemDecorations = SystemDecorations.None;
+ ExtendClientAreaTitleBarHeightHint = 0;
+ Background = Brushes.Transparent;
+ CanResize = false;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/ControllerSettingsWindow.axaml b/src/Ryujinx.Ava/UI/Windows/ControllerSettingsWindow.axaml
new file mode 100644
index 00000000..8a4d22ff
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/ControllerSettingsWindow.axaml
@@ -0,0 +1,1169 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Windows.ControllerSettingsWindow"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ d:DesignHeight="800"
+ d:DesignWidth="800"
+ x:CompileBindings="False"
+ mc:Ignorable="d"
+ Focusable="True">
+ <Design.DataContext>
+ <viewModels:ControllerSettingsViewModel />
+ </Design.DataContext>
+ <UserControl.Resources>
+ <helpers:KeyValueConverter x:Key="Key" />
+ </UserControl.Resources>
+ <StackPanel
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ Orientation="Vertical">
+ <Grid Margin="2,2,2,5" HorizontalAlignment="Stretch">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition Width="*" />
+ <ColumnDefinition Width="0.5*" />
+ <ColumnDefinition Width="Auto" />
+ </Grid.ColumnDefinitions>
+ <Border
+ Grid.Column="0"
+ Margin="0,0,2,0"
+ BorderBrush="{DynamicResource ThemeControlBorderColor}"
+ BorderThickness="1"
+ Padding="2,0">
+ <StackPanel
+ Margin="2"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Orientation="Vertical">
+ <TextBlock
+ Margin="0,0,0,4"
+ HorizontalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsPlayer}" />
+ <ComboBox
+ Name="PlayerIndexBox"
+ Width="150"
+ SelectionChanged="PlayerIndexBox_OnSelectionChanged"
+ Items="{Binding PlayerIndexes}"
+ SelectedIndex="{Binding PlayerId}">
+ <ComboBox.ItemTemplate>
+ <DataTemplate>
+ <TextBlock Text="{Binding Name}" />
+ </DataTemplate>
+ </ComboBox.ItemTemplate>
+ </ComboBox>
+ </StackPanel>
+ </Border>
+
+ <!-- Main Controller Settings -->
+ <Border
+ Grid.Column="1"
+ Margin="0,0,2,0"
+ BorderBrush="{DynamicResource ThemeControlBorderColor}"
+ BorderThickness="1"
+ Padding="2,0">
+ <StackPanel
+ Margin="2"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Center"
+ Orientation="Vertical">
+ <TextBlock
+ Margin="0,0,0,5"
+ HorizontalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsInputDevice}" />
+ <Grid HorizontalAlignment="Stretch">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition />
+ <ColumnDefinition Width="Auto" />
+ </Grid.ColumnDefinitions>
+ <ComboBox
+ Name="DeviceBox"
+ HorizontalAlignment="Stretch"
+ Items="{Binding DeviceList}"
+ SelectedIndex="{Binding Device}" />
+ <Button
+ Grid.Column="1"
+ MinWidth="0"
+ Margin="5,0,0,0"
+ VerticalAlignment="Center"
+ Command="{Binding LoadDevices}">
+ <ui:SymbolIcon
+ Symbol="Refresh"
+ FontSize="15"
+ Height="20" />
+ </Button>
+ </Grid>
+ </StackPanel>
+ </Border>
+ <Border
+ Grid.Column="2"
+ Margin="0,0,2,0"
+ BorderBrush="{DynamicResource ThemeControlBorderColor}"
+ BorderThickness="1"
+ Padding="2,0">
+ <Grid
+ Margin="2"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Center">
+ <Grid.RowDefinitions>
+ <RowDefinition />
+ <RowDefinition />
+ </Grid.RowDefinitions>
+ <TextBlock
+ Margin="0,0,0,4"
+ HorizontalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsControllerType}" />
+ <ComboBox
+ Grid.Row="1"
+ HorizontalAlignment="Stretch"
+ Items="{Binding Controllers}"
+ SelectedIndex="{Binding Controller}">
+ <ComboBox.ItemTemplate>
+ <DataTemplate>
+ <TextBlock Text="{Binding Name}" />
+ </DataTemplate>
+ </ComboBox.ItemTemplate>
+ </ComboBox>
+ </Grid>
+ </Border>
+ <Border
+ Grid.Column="3"
+ BorderBrush="{DynamicResource ThemeControlBorderColor}"
+ BorderThickness="1"
+ Padding="2,0" >
+ <StackPanel
+ Margin="2"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Orientation="Vertical">
+ <TextBlock
+ Margin="0,0,0,4"
+ HorizontalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsProfile}" />
+ <StackPanel Orientation="Horizontal">
+ <ui:ComboBox
+ IsEditable="True"
+ Name="ProfileBox"
+ Width="100"
+ SelectedIndex="0"
+ Items="{Binding ProfilesList}"
+ Text="{Binding ProfileName}" />
+ <Button
+ MinWidth="0"
+ Margin="5,0,0,0"
+ VerticalAlignment="Center"
+ ToolTip.Tip="{locale:Locale ControllerSettingsLoadProfileToolTip}"
+ Command="{Binding LoadProfile}">
+ <ui:SymbolIcon
+ Symbol="Upload"
+ FontSize="15"
+ Height="20" />
+ </Button>
+ <Button
+ MinWidth="0"
+ Margin="5,0,0,0"
+ VerticalAlignment="Center"
+ ToolTip.Tip="{locale:Locale ControllerSettingsSaveProfileToolTip}"
+ Command="{Binding SaveProfile}">
+ <ui:SymbolIcon
+ Symbol="Save"
+ FontSize="15"
+ Height="20" />
+ </Button>
+ <Button
+ MinWidth="0"
+ Margin="5,0,0,0"
+ VerticalAlignment="Center"
+ ToolTip.Tip="{locale:Locale ControllerSettingsRemoveProfileToolTip}"
+ Command="{Binding RemoveProfile}">
+ <ui:SymbolIcon
+ Symbol="Delete"
+ FontSize="15"
+ Height="20" />
+ </Button>
+ </StackPanel>
+ </StackPanel>
+ </Border>
+ </Grid>
+
+ <!-- Button / JoyStick Settings -->
+ <Grid
+ Name="SettingButtons"
+ MinHeight="450"
+ IsVisible="{Binding ShowSettings}">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition Width="*" />
+ <ColumnDefinition Width="Auto" />
+ </Grid.ColumnDefinitions>
+
+ <!-- Left -->
+ <Grid
+ Margin="0,0,10,0"
+ Grid.Column="0"
+ VerticalAlignment="Stretch"
+ DockPanel.Dock="Left">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+
+ <!-- Left Triggers -->
+ <Border
+ Grid.Row="0"
+ BorderBrush="{DynamicResource ThemeControlBorderColor}"
+ BorderThickness="1"
+ IsVisible="{Binding IsLeft}">
+ <Grid Margin="10" HorizontalAlignment="Stretch">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition />
+ <ColumnDefinition />
+ </Grid.ColumnDefinitions>
+ <Grid.RowDefinitions>
+ <RowDefinition />
+ <RowDefinition />
+ </Grid.RowDefinitions>
+ <StackPanel
+ Margin="0,0,0,4"
+ Grid.Column="0"
+ Grid.Row="0"
+ Background="{DynamicResource ThemeDarkColor}"
+ Orientation="Horizontal">
+ <TextBlock
+ Width="20"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsTriggerZL}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.ButtonZl, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <StackPanel
+ Grid.Column="0"
+ Grid.Row="1"
+ Background="{DynamicResource ThemeDarkColor}"
+ Orientation="Horizontal">
+ <TextBlock
+ Width="20"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsTriggerL}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.ButtonL, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <StackPanel
+ Margin="0,0,0,4"
+ Grid.Column="1"
+ Grid.Row="0"
+ Background="{DynamicResource ThemeDarkColor}"
+ Orientation="Horizontal">
+ <TextBlock
+ Width="20"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsButtonMinus}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.ButtonMinus, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ </Grid>
+ </Border>
+
+ <!-- Left Joystick -->
+ <Border
+ Grid.Row="1"
+ BorderBrush="{DynamicResource ThemeControlBorderColor}"
+ BorderThickness="1"
+ IsVisible="{Binding IsLeft}">
+ <StackPanel Margin="10" Orientation="Vertical">
+ <TextBlock
+ Margin="0,0,0,10"
+ HorizontalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsLStick}" />
+
+ <!-- Left Joystick Keyboard -->
+ <StackPanel IsVisible="{Binding !IsController}" Orientation="Vertical">
+
+ <!-- Left Joystick Button -->
+ <StackPanel Margin="0,0,0,4" Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsLStickButton}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.LeftKeyboardStickButton, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+
+ <!-- Left Joystick Up -->
+ <StackPanel Margin="0,0,0,4" Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsLStickUp}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.LeftStickUp, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+
+ <!-- Left Joystick Down -->
+ <StackPanel Margin="0,0,0,4" Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsLStickDown}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.LeftStickDown, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+
+ <!-- Left Joystick Left -->
+ <StackPanel Margin="0,0,0,4" Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsLStickLeft}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.LeftStickLeft, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+
+ <!-- Left Joystick Right -->
+ <StackPanel Margin="0,0,0,4" Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsLStickRight}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.LeftStickRight, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ </StackPanel>
+
+ <!-- Left Joystick Controller -->
+ <StackPanel IsVisible="{Binding IsController}" Orientation="Vertical">
+
+ <!-- Left Joystick Button -->
+ <StackPanel Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsLStickButton}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.LeftControllerStickButton, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+
+ <!-- Left Joystick Stick -->
+ <StackPanel Margin="0,4,0,4" Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsLStickStick}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch"
+ Tag="stick">
+ <TextBlock
+ Text="{Binding Configuration.LeftJoystick, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <CheckBox IsChecked="{Binding Configuration.LeftInvertStickX}">
+ <TextBlock Text="{locale:Locale ControllerSettingsLStickInvertXAxis}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding Configuration.LeftInvertStickY}">
+ <TextBlock Text="{locale:Locale ControllerSettingsLStickInvertYAxis}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding Configuration.LeftRotate90}">
+ <TextBlock Text="{locale:Locale ControllerSettingsRotate90}" />
+ </CheckBox>
+ <Separator Margin="0,4,0,4" Height="1" />
+ <StackPanel Orientation="Vertical">
+ <TextBlock Text="{locale:Locale ControllerSettingsLStickDeadzone}" />
+ <StackPanel
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Orientation="Horizontal">
+ <Slider
+ Width="130"
+ Maximum="1"
+ TickFrequency="0.01"
+ IsSnapToTickEnabled="True"
+ Minimum="0"
+ Value="{Binding Configuration.DeadzoneLeft, Mode=TwoWay}" />
+ <TextBlock
+ VerticalAlignment="Center"
+ Text="{Binding Configuration.DeadzoneLeft, StringFormat=\{0:0.00\}}" />
+ </StackPanel>
+ <TextBlock Text="{locale:Locale ControllerSettingsStickRange}" />
+ <StackPanel
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Orientation="Horizontal">
+ <Slider
+ Width="130"
+ Maximum="2"
+ TickFrequency="0.01"
+ IsSnapToTickEnabled="True"
+ Minimum="0"
+ Value="{Binding Configuration.RangeLeft, Mode=TwoWay}" />
+ <TextBlock
+ VerticalAlignment="Center"
+ Text="{Binding Configuration.RangeLeft, StringFormat=\{0:0.00\}}" />
+ </StackPanel>
+ </StackPanel>
+ </StackPanel>
+ </StackPanel>
+ </Border>
+
+ <!-- Left DPad -->
+ <Border
+ Grid.Row="2"
+ BorderBrush="{DynamicResource ThemeControlBorderColor}"
+ BorderThickness="1"
+ VerticalAlignment="Top"
+ IsVisible="{Binding IsLeft}">
+ <StackPanel Margin="10" Orientation="Vertical">
+ <TextBlock
+ Margin="0,0,0,10"
+ HorizontalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsDPad}" />
+ <StackPanel Orientation="Vertical">
+ <!-- Left DPad Up -->
+ <StackPanel Margin="0,0,0,4" Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsDPadUp}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.DpadUp, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+
+ <!-- Left DPad Down -->
+ <StackPanel Margin="0,0,0,4" Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsDPadDown}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.DpadDown, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+
+ <!-- Left DPad Left -->
+ <StackPanel Margin="0,0,0,4" Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsDPadLeft}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.DpadLeft, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+
+ <!-- Left DPad Right -->
+ <StackPanel Margin="0,0,0,4" Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsDPadRight}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.DpadRight, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ </StackPanel>
+ </StackPanel>
+ </Border>
+ </Grid>
+
+ <!-- Triggers And Side Buttons-->
+ <StackPanel Grid.Column="1" HorizontalAlignment="Stretch">
+ <Border
+ BorderBrush="{DynamicResource ThemeControlBorderColor}"
+ BorderThickness="1">
+ <StackPanel Margin="10" Orientation="Vertical">
+ <TextBlock HorizontalAlignment="Center" Text="{locale:Locale ControllerSettingsTriggerThreshold}" />
+ <StackPanel HorizontalAlignment="Center" Orientation="Horizontal">
+ <Slider
+ Width="130"
+ Maximum="1"
+ TickFrequency="0.01"
+ IsSnapToTickEnabled="True"
+ Minimum="0"
+ Value="{Binding Configuration.TriggerThreshold, Mode=TwoWay}" />
+ <TextBlock Text="{Binding Configuration.TriggerThreshold, StringFormat=\{0:0.00\}}" />
+ </StackPanel>
+ <StackPanel
+ Margin="0,4,0,0"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Background="{DynamicResource ThemeDarkColor}"
+ IsVisible="{Binding !IsRight}"
+ Orientation="Horizontal">
+ <TextBlock
+ Width="20"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsLeftSR}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.LeftButtonSr, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <StackPanel
+ Margin="0,4,0,0"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ IsVisible="{Binding !IsRight}"
+ Background="{DynamicResource ThemeDarkColor}"
+ Orientation="Horizontal">
+ <TextBlock
+ Width="20"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsLeftSL}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.LeftButtonSl, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <StackPanel
+ Margin="0,4,0,0"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ IsVisible="{Binding !IsLeft}"
+ Background="{DynamicResource ThemeDarkColor}"
+ Orientation="Horizontal">
+ <TextBlock
+ Width="20"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsRightSR}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.RightButtonSr, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <StackPanel
+ Margin="0,4,0,0"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ IsVisible="{Binding !IsLeft}"
+ Background="{DynamicResource ThemeDarkColor}"
+ Orientation="Horizontal">
+ <TextBlock
+ Width="20"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsRightSL}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.RightButtonSl, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ </StackPanel>
+ </Border>
+
+ <!-- Controller Picture -->
+ <Image
+ Margin="0,10,0,0"
+ MaxHeight="250"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Top"
+ Source="{Binding Image}" />
+
+ <!-- Motion+Rumble -->
+ <StackPanel Margin="0,10,0,0" Orientation="Vertical" >
+ <Border
+ BorderBrush="{DynamicResource ThemeControlBorderColor}"
+ BorderThickness="1"
+ HorizontalAlignment="Stretch"
+ IsVisible="{Binding IsController}">
+ <Grid>
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="*" />
+ <ColumnDefinition Width="Auto" />
+ </Grid.ColumnDefinitions>
+ <CheckBox
+ Margin="10"
+ MinWidth="0"
+ Grid.Column="0"
+ IsChecked="{Binding Configuration.EnableMotion, Mode=TwoWay}">
+ <TextBlock Text="{locale:Locale ControllerSettingsMotion}" />
+ </CheckBox>
+ <Button Margin="10" Grid.Column="1" Command="{Binding ShowMotionConfig}">
+ <TextBlock Text="{locale:Locale ControllerSettingsConfigureGeneral}" />
+ </Button>
+ </Grid>
+ </Border>
+ <Border
+ BorderBrush="{DynamicResource ThemeControlBorderColor}"
+ BorderThickness="1"
+ HorizontalAlignment="Stretch"
+ IsVisible="{Binding IsController}">
+ <Grid>
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="*" />
+ <ColumnDefinition Width="Auto" />
+ </Grid.ColumnDefinitions>
+ <CheckBox
+ Margin="10"
+ MinWidth="0"
+ Grid.Column="0"
+ IsChecked="{Binding Configuration.EnableRumble, Mode=TwoWay}">
+ <TextBlock Text="{locale:Locale ControllerSettingsRumble}" />
+ </CheckBox>
+ <Button Margin="10" Grid.Column="1" Command="{Binding ShowRumbleConfig}">
+ <TextBlock Text="{locale:Locale ControllerSettingsConfigureGeneral}" />
+ </Button>
+ </Grid>
+ </Border>
+ </StackPanel>
+ </StackPanel>
+
+ <!--Right Controls-->
+ <Grid
+ Margin="10,0,0,0"
+ Grid.Column="2"
+ VerticalAlignment="Top"
+ HorizontalAlignment="Stretch" >
+ <Grid.RowDefinitions>
+ <RowDefinition />
+ <RowDefinition />
+ <RowDefinition />
+ <RowDefinition />
+ </Grid.RowDefinitions>
+ <Border
+ Grid.Row="0"
+ BorderBrush="{DynamicResource ThemeControlBorderColor}"
+ BorderThickness="1"
+ IsVisible="{Binding IsRight}">
+ <StackPanel Margin="10" Orientation="Vertical">
+ <Grid HorizontalAlignment="Stretch">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition />
+ <ColumnDefinition />
+ </Grid.ColumnDefinitions>
+ <Grid.RowDefinitions>
+ <RowDefinition />
+ <RowDefinition />
+ </Grid.RowDefinitions>
+ <StackPanel
+ Margin="0,0,0,4"
+ Grid.Column="1"
+ Grid.Row="0"
+ Background="{DynamicResource ThemeDarkColor}"
+ Orientation="Horizontal">
+ <TextBlock
+ Width="20"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsTriggerZR}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.ButtonZr, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <StackPanel
+ Grid.Column="1"
+ Grid.Row="1"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Background="{DynamicResource ThemeDarkColor}"
+ Orientation="Horizontal">
+ <TextBlock
+ Width="20"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsTriggerR}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.ButtonR, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <StackPanel
+ Margin="0,0,8,4"
+ Grid.Column="0"
+ Grid.Row="0"
+ HorizontalAlignment="Right"
+ VerticalAlignment="Center"
+ Background="{DynamicResource ThemeDarkColor}"
+ Orientation="Horizontal">
+ <TextBlock
+ Width="20"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsButtonPlus}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.ButtonPlus, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ </Grid>
+ </StackPanel>
+ </Border>
+ <Border
+ Grid.Row="1"
+ BorderBrush="{DynamicResource ThemeControlBorderColor}"
+ BorderThickness="1"
+ IsVisible="{Binding IsRight}">
+ <StackPanel Margin="10" Orientation="Vertical">
+ <TextBlock
+ Margin="0,0,0,10"
+ HorizontalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsButtons}" />
+ <Grid HorizontalAlignment="Stretch">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition />
+ <ColumnDefinition />
+ </Grid.ColumnDefinitions>
+ <Grid.RowDefinitions>
+ <RowDefinition />
+ <RowDefinition />
+ </Grid.RowDefinitions>
+ <!-- Right Buttons X -->
+ <StackPanel
+ Margin="0,0,0,4"
+ Grid.Column="0"
+ Grid.Row="0"
+ Background="{DynamicResource ThemeDarkColor}"
+ Orientation="Horizontal">
+ <TextBlock
+ Width="20"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsButtonX}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.ButtonX, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <!-- Right Buttons Y -->
+ <StackPanel
+ Grid.Column="0"
+ Grid.Row="1"
+ Background="{DynamicResource ThemeDarkColor}"
+ Orientation="Horizontal">
+ <TextBlock
+ Width="20"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsButtonY}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.ButtonY, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <!-- Right Buttons A -->
+ <StackPanel
+ Margin="0,0,0,4"
+ Grid.Column="1"
+ Grid.Row="0"
+ Background="{DynamicResource ThemeDarkColor}"
+ Orientation="Horizontal">
+ <TextBlock
+ Width="20"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsButtonA}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.ButtonA, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <!-- Right Buttons B -->
+ <StackPanel
+ Grid.Column="1"
+ Grid.Row="1"
+ Background="{DynamicResource ThemeDarkColor}"
+ Orientation="Horizontal">
+ <TextBlock
+ Width="20"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsButtonB}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.ButtonB, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ </Grid>
+ </StackPanel>
+ </Border>
+ <Border
+ Grid.Row="2"
+ Padding="10"
+ BorderBrush="{DynamicResource ThemeControlBorderColor}"
+ BorderThickness="1"
+ IsVisible="{Binding IsRight}">
+ <StackPanel Orientation="Vertical">
+ <TextBlock
+ Margin="0,0,0,10"
+ HorizontalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsRStick}" />
+
+ <!-- Right Joystick Keyboard -->
+ <StackPanel IsVisible="{Binding !IsController}" Orientation="Vertical">
+
+ <!-- Right Joystick Button -->
+ <StackPanel Margin="0,0,0,4" Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsRStickButton}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.RightKeyboardStickButton, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+
+ <!-- Right Joystick Up -->
+ <StackPanel Margin="0,0,0,4" Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsRStickUp}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.RightStickUp, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+
+ <!-- Right Joystick Down -->
+ <StackPanel Margin="0,0,0,4" Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsRStickDown}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.RightStickDown, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+
+ <!-- Right Joystick Left -->
+ <StackPanel Margin="0,0,0,4" Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsRStickLeft}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.RightStickLeft, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+
+ <!-- Right Joystick Right -->
+ <StackPanel Margin="0,0,0,4" Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsRStickRight}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.RightStickRight, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ </StackPanel>
+
+ <!-- Right Joystick Controller -->
+ <StackPanel IsVisible="{Binding IsController}" Orientation="Vertical">
+
+ <!-- Right Joystick Button -->
+ <StackPanel Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsRStickButton}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch">
+ <TextBlock
+ Text="{Binding Configuration.RightControllerStickButton, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+
+
+ <!-- Right Joystick Stick -->
+ <StackPanel Margin="0,4,0,4" Background="{DynamicResource ThemeDarkColor}" Orientation="Horizontal">
+ <TextBlock
+ Margin="0,0,10,0"
+ Width="120"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsRStickStick}"
+ TextAlignment="Center" />
+ <ToggleButton
+ Width="90"
+ Height="27"
+ HorizontalAlignment="Stretch"
+ Tag="stick">
+ <TextBlock
+ Text="{Binding Configuration.RightJoystick, Mode=TwoWay, Converter={StaticResource Key}}"
+ TextAlignment="Center" />
+ </ToggleButton>
+ </StackPanel>
+ <CheckBox IsChecked="{Binding Configuration.RightInvertStickX}">
+ <TextBlock Text="{locale:Locale ControllerSettingsRStickInvertXAxis}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding Configuration.RightInvertStickY}">
+ <TextBlock Text="{locale:Locale ControllerSettingsRStickInvertYAxis}" />
+ </CheckBox>
+ <CheckBox IsChecked="{Binding Configuration.RightRotate90}">
+ <TextBlock Text="{locale:Locale ControllerSettingsRotate90}" />
+ </CheckBox>
+ <Separator Margin="0,4,0,4" Height="1" />
+ <StackPanel Orientation="Vertical">
+ <TextBlock Text="{locale:Locale ControllerSettingsRStickDeadzone}" />
+ <StackPanel
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Orientation="Horizontal">
+ <Slider
+ Width="130"
+ Maximum="1"
+ TickFrequency="0.01"
+ IsSnapToTickEnabled="True"
+ Padding="0"
+ VerticalAlignment="Center"
+ Minimum="0"
+ Value="{Binding Configuration.DeadzoneRight, Mode=TwoWay}" />
+ <TextBlock
+ VerticalAlignment="Center"
+ Text="{Binding Configuration.DeadzoneRight, StringFormat=\{0:0.00\}}" />
+ </StackPanel>
+ <TextBlock Text="{locale:Locale ControllerSettingsStickRange}" />
+ <StackPanel
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Orientation="Horizontal">
+ <Slider
+ Width="130"
+ Maximum="2"
+ TickFrequency="0.01"
+ IsSnapToTickEnabled="True"
+ Minimum="0"
+ Value="{Binding Configuration.RangeRight, Mode=TwoWay}" />
+ <TextBlock
+ VerticalAlignment="Center"
+ Text="{Binding Configuration.RangeRight, StringFormat=\{0:0.00\}}" />
+ </StackPanel>
+ </StackPanel>
+ </StackPanel>
+ </StackPanel>
+ </Border>
+ </Grid>
+
+ </Grid>
+
+ </StackPanel>
+
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/ControllerSettingsWindow.axaml.cs b/src/Ryujinx.Ava/UI/Windows/ControllerSettingsWindow.axaml.cs
new file mode 100644
index 00000000..2864b6da
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/ControllerSettingsWindow.axaml.cs
@@ -0,0 +1,181 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.LogicalTree;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Controls;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.Common.Configuration.Hid.Controller;
+using Ryujinx.Input;
+using Ryujinx.Input.Assigner;
+using System;
+
+namespace Ryujinx.Ava.UI.Windows
+{
+ public partial class ControllerSettingsWindow : UserControl
+ {
+ private bool _dialogOpen;
+
+ private ButtonKeyAssigner _currentAssigner;
+ internal ControllerSettingsViewModel ViewModel { get; set; }
+
+ public ControllerSettingsWindow()
+ {
+ DataContext = ViewModel = new ControllerSettingsViewModel(this);
+
+ InitializeComponent();
+
+ foreach (ILogical visual in SettingButtons.GetLogicalDescendants())
+ {
+ if (visual is ToggleButton button && !(visual is CheckBox))
+ {
+ button.Checked += Button_Checked;
+ button.Unchecked += Button_Unchecked;
+ }
+ }
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+
+ if (_currentAssigner != null && _currentAssigner.ToggledButton != null && !_currentAssigner.ToggledButton.IsPointerOver)
+ {
+ _currentAssigner.Cancel();
+ }
+ }
+
+ private void Button_Checked(object sender, RoutedEventArgs e)
+ {
+ if (sender is ToggleButton button)
+ {
+ if (_currentAssigner != null && button == _currentAssigner.ToggledButton)
+ {
+ return;
+ }
+
+ bool isStick = button.Tag != null && button.Tag.ToString() == "stick";
+
+ if (_currentAssigner == null && (bool)button.IsChecked)
+ {
+ _currentAssigner = new ButtonKeyAssigner(button);
+
+ FocusManager.Instance.Focus(this, NavigationMethod.Pointer);
+
+ PointerPressed += MouseClick;
+
+ IKeyboard keyboard = (IKeyboard)ViewModel.AvaloniaKeyboardDriver.GetGamepad("0"); // Open Avalonia keyboard for cancel operations.
+ IButtonAssigner assigner = CreateButtonAssigner(isStick);
+
+ _currentAssigner.ButtonAssigned += (sender, e) =>
+ {
+ if (e.IsAssigned)
+ {
+ ViewModel.IsModified = true;
+ }
+ };
+
+ _currentAssigner.GetInputAndAssign(assigner, keyboard);
+ }
+ else
+ {
+ if (_currentAssigner != null)
+ {
+ ToggleButton oldButton = _currentAssigner.ToggledButton;
+
+ _currentAssigner.Cancel();
+ _currentAssigner = null;
+ button.IsChecked = false;
+ }
+ }
+ }
+ }
+
+ public void SaveCurrentProfile()
+ {
+ ViewModel.Save();
+ }
+
+ private IButtonAssigner CreateButtonAssigner(bool forStick)
+ {
+ IButtonAssigner assigner;
+
+ var device = ViewModel.Devices[ViewModel.Device];
+
+ if (device.Type == DeviceType.Keyboard)
+ {
+ assigner = new KeyboardKeyAssigner((IKeyboard)ViewModel.SelectedGamepad);
+ }
+ else if (device.Type == DeviceType.Controller)
+ {
+ assigner = new GamepadButtonAssigner(ViewModel.SelectedGamepad, (ViewModel.Config as StandardControllerInputConfig).TriggerThreshold, forStick);
+ }
+ else
+ {
+ throw new Exception("Controller not supported");
+ }
+
+ return assigner;
+ }
+
+ private void Button_Unchecked(object sender, RoutedEventArgs e)
+ {
+ _currentAssigner?.Cancel();
+ _currentAssigner = null;
+ }
+
+ private void MouseClick(object sender, PointerPressedEventArgs e)
+ {
+ bool shouldUnbind = false;
+
+ if (e.GetCurrentPoint(this).Properties.IsMiddleButtonPressed)
+ {
+ shouldUnbind = true;
+ }
+
+ _currentAssigner?.Cancel(shouldUnbind);
+
+ PointerPressed -= MouseClick;
+ }
+
+ private async void PlayerIndexBox_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (ViewModel.IsModified && !_dialogOpen)
+ {
+ _dialogOpen = true;
+
+ var result = await ContentDialogHelper.CreateConfirmationDialog(
+ LocaleManager.Instance[LocaleKeys.DialogControllerSettingsModifiedConfirmMessage],
+ LocaleManager.Instance[LocaleKeys.DialogControllerSettingsModifiedConfirmSubMessage],
+ LocaleManager.Instance[LocaleKeys.InputDialogYes],
+ LocaleManager.Instance[LocaleKeys.InputDialogNo],
+ LocaleManager.Instance[LocaleKeys.RyujinxConfirm]);
+
+ if (result == UserResult.Yes)
+ {
+ ViewModel.Save();
+ }
+
+ _dialogOpen = false;
+
+ ViewModel.IsModified = false;
+
+ if (e.AddedItems.Count > 0)
+ {
+ var player = (PlayerModel)e.AddedItems[0];
+ ViewModel.PlayerId = player.Id;
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ _currentAssigner?.Cancel();
+ _currentAssigner = null;
+ ViewModel.Dispose();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/DownloadableContentManagerWindow.axaml b/src/Ryujinx.Ava/UI/Windows/DownloadableContentManagerWindow.axaml
new file mode 100644
index 00000000..fe446fb3
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/DownloadableContentManagerWindow.axaml
@@ -0,0 +1,194 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Windows.DownloadableContentManagerWindow"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ xmlns:models="clr-namespace:Ryujinx.Ava.UI.Models"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ Width="500"
+ Height="380"
+ mc:Ignorable="d"
+ x:CompileBindings="True"
+ x:DataType="viewModels:DownloadableContentManagerViewModel"
+ Focusable="True">
+ <Grid>
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="*" />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <Panel
+ Margin="0 0 0 10"
+ Grid.Row="0">
+ <Grid>
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition Width="*" />
+ </Grid.ColumnDefinitions>
+ <TextBlock
+ Grid.Column="0"
+ Text="{Binding UpdateCount}" />
+ <StackPanel
+ Margin="10 0"
+ Grid.Column="1"
+ Orientation="Horizontal">
+ <Button
+ Name="EnableAllButton"
+ MinWidth="90"
+ Margin="5"
+ Command="{ReflectionBinding EnableAll}">
+ <TextBlock Text="{locale:Locale DlcManagerEnableAllButton}" />
+ </Button>
+ <Button
+ Name="DisableAllButton"
+ MinWidth="90"
+ Margin="5"
+ Command="{ReflectionBinding DisableAll}">
+ <TextBlock Text="{locale:Locale DlcManagerDisableAllButton}" />
+ </Button>
+ </StackPanel>
+ <TextBox
+ Grid.Column="2"
+ MinHeight="27"
+ MaxHeight="27"
+ HorizontalAlignment="Stretch"
+ Watermark="{locale:Locale Search}"
+ Text="{Binding Search}" />
+ </Grid>
+ </Panel>
+ <Border
+ Grid.Row="1"
+ Margin="0 0 0 24"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
+ BorderThickness="1"
+ CornerRadius="5"
+ Padding="2.5">
+ <ListBox
+ AutoScrollToSelectedItem="False"
+ VirtualizationMode="None"
+ SelectionMode="Multiple, Toggle"
+ Background="Transparent"
+ SelectionChanged="OnSelectionChanged"
+ SelectedItems="{Binding SelectedDownloadableContents, Mode=TwoWay}"
+ Items="{Binding Views}">
+ <ListBox.DataTemplates>
+ <DataTemplate
+ DataType="models:DownloadableContentModel">
+ <Panel Margin="10">
+ <Grid>
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="*" />
+ <ColumnDefinition Width="Auto" />
+ </Grid.ColumnDefinitions>
+ <Grid
+ Grid.Column="0">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="*"></ColumnDefinition>
+ <ColumnDefinition Width="Auto"></ColumnDefinition>
+ </Grid.ColumnDefinitions>
+ <TextBlock
+ Grid.Column="0"
+ HorizontalAlignment="Left"
+ VerticalAlignment="Center"
+ MaxLines="2"
+ TextWrapping="Wrap"
+ TextTrimming="CharacterEllipsis"
+ Text="{Binding FileName}" />
+ <TextBlock
+ Grid.Column="1"
+ Margin="10 0"
+ HorizontalAlignment="Left"
+ VerticalAlignment="Center"
+ Text="{Binding TitleId}" />
+ </Grid>
+ <StackPanel
+ Grid.Column="1"
+ Spacing="10"
+ Orientation="Horizontal"
+ HorizontalAlignment="Right">
+ <Button
+ VerticalAlignment="Center"
+ HorizontalAlignment="Right"
+ Padding="10"
+ MinWidth="0"
+ MinHeight="0"
+ Click="OpenLocation">
+ <ui:SymbolIcon
+ Symbol="OpenFolder"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center" />
+ </Button>
+ <Button
+ VerticalAlignment="Center"
+ HorizontalAlignment="Right"
+ Padding="10"
+ MinWidth="0"
+ MinHeight="0"
+ Click="RemoveDLC">
+ <ui:SymbolIcon
+ Symbol="Cancel"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center" />
+ </Button>
+ </StackPanel>
+ </Grid>
+ </Panel>
+ </DataTemplate>
+ </ListBox.DataTemplates>
+ <ListBox.Styles>
+ <Style Selector="ListBoxItem">
+ <Setter Property="Background" Value="Transparent" />
+ </Style>
+ </ListBox.Styles>
+ </ListBox>
+ </Border>
+ <Panel
+ Grid.Row="2"
+ HorizontalAlignment="Stretch">
+ <StackPanel
+ Orientation="Horizontal"
+ Spacing="10"
+ HorizontalAlignment="Left">
+ <Button
+ Name="AddButton"
+ MinWidth="90"
+ Margin="5"
+ Command="{ReflectionBinding Add}">
+ <TextBlock Text="{locale:Locale SettingsTabGeneralAdd}" />
+ </Button>
+ <Button
+ Name="RemoveAllButton"
+ MinWidth="90"
+ Margin="5"
+ Command="{ReflectionBinding RemoveAll}">
+ <TextBlock Text="{locale:Locale DlcManagerRemoveAllButton}" />
+ </Button>
+ </StackPanel>
+ <StackPanel
+ Orientation="Horizontal"
+ Spacing="10"
+ HorizontalAlignment="Right">
+ <Button
+ Name="SaveButton"
+ MinWidth="90"
+ Margin="5"
+ Click="SaveAndClose">
+ <TextBlock Text="{locale:Locale SettingsButtonSave}" />
+ </Button>
+ <Button
+ Name="CancelButton"
+ MinWidth="90"
+ Margin="5"
+ Click="Close">
+ <TextBlock Text="{locale:Locale InputDialogCancel}" />
+ </Button>
+ </StackPanel>
+ </Panel>
+ </Grid>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/DownloadableContentManagerWindow.axaml.cs b/src/Ryujinx.Ava/UI/Windows/DownloadableContentManagerWindow.axaml.cs
new file mode 100644
index 00000000..6dc99fb5
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/DownloadableContentManagerWindow.axaml.cs
@@ -0,0 +1,115 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Styling;
+using FluentAvalonia.UI.Controls;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.HLE.FileSystem;
+using Ryujinx.Ui.Common.Helper;
+using System.Threading.Tasks;
+using Button = Avalonia.Controls.Button;
+
+namespace Ryujinx.Ava.UI.Windows
+{
+ public partial class DownloadableContentManagerWindow : UserControl
+ {
+ public DownloadableContentManagerViewModel ViewModel;
+
+ public DownloadableContentManagerWindow()
+ {
+ DataContext = this;
+
+ InitializeComponent();
+ }
+
+ public DownloadableContentManagerWindow(VirtualFileSystem virtualFileSystem, ulong titleId, string titleName)
+ {
+ DataContext = ViewModel = new DownloadableContentManagerViewModel(virtualFileSystem, titleId, titleName);
+
+ InitializeComponent();
+ }
+
+ public static async Task Show(VirtualFileSystem virtualFileSystem, ulong titleId, string titleName)
+ {
+ ContentDialog contentDialog = new()
+ {
+ PrimaryButtonText = "",
+ SecondaryButtonText = "",
+ CloseButtonText = "",
+ Content = new DownloadableContentManagerWindow(virtualFileSystem, titleId, titleName),
+ Title = string.Format(LocaleManager.Instance[LocaleKeys.DlcWindowTitle], titleName, titleId.ToString("X16"))
+ };
+
+ Style bottomBorder = new(x => x.OfType<Grid>().Name("DialogSpace").Child().OfType<Border>());
+ bottomBorder.Setters.Add(new Setter(IsVisibleProperty, false));
+
+ contentDialog.Styles.Add(bottomBorder);
+
+ await ContentDialogHelper.ShowAsync(contentDialog);
+ }
+
+ private void SaveAndClose(object sender, RoutedEventArgs routedEventArgs)
+ {
+ ViewModel.Save();
+ ((ContentDialog)Parent).Hide();
+ }
+
+ private void Close(object sender, RoutedEventArgs e)
+ {
+ ((ContentDialog)Parent).Hide();
+ }
+
+ private void RemoveDLC(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button button)
+ {
+ if (button.DataContext is DownloadableContentModel model)
+ {
+ ViewModel.Remove(model);
+ }
+ }
+ }
+
+ private void OpenLocation(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button button)
+ {
+ if (button.DataContext is DownloadableContentModel model)
+ {
+ OpenHelper.LocateFile(model.ContainerPath);
+ }
+ }
+ }
+
+ private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ foreach (var content in e.AddedItems)
+ {
+ if (content is DownloadableContentModel model)
+ {
+ var index = ViewModel.DownloadableContents.IndexOf(model);
+
+ if (index != -1)
+ {
+ ViewModel.DownloadableContents[index].Enabled = true;
+ }
+ }
+ }
+
+ foreach (var content in e.RemovedItems)
+ {
+ if (content is DownloadableContentModel model)
+ {
+ var index = ViewModel.DownloadableContents.IndexOf(model);
+
+ if (index != -1)
+ {
+ ViewModel.DownloadableContents[index].Enabled = false;
+ }
+ }
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/IconColorPicker.cs b/src/Ryujinx.Ava/UI/Windows/IconColorPicker.cs
new file mode 100644
index 00000000..a4c6287f
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/IconColorPicker.cs
@@ -0,0 +1,192 @@
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+using System;
+using System.Collections.Generic;
+
+namespace Ryujinx.Ava.UI.Windows
+{
+ static class IconColorPicker
+ {
+ private const int ColorsPerLine = 64;
+ private const int TotalColors = ColorsPerLine * ColorsPerLine;
+
+ private const int UvQuantBits = 3;
+ private const int UvQuantShift = BitsPerComponent - UvQuantBits;
+
+ private const int SatQuantBits = 5;
+ private const int SatQuantShift = BitsPerComponent - SatQuantBits;
+
+ private const int BitsPerComponent = 8;
+
+ private const int CutOffLuminosity = 64;
+
+ private readonly struct PaletteColor
+ {
+ public int Qck { get; }
+ public byte R { get; }
+ public byte G { get; }
+ public byte B { get; }
+
+ public PaletteColor(int qck, byte r, byte g, byte b)
+ {
+ Qck = qck;
+ R = r;
+ G = g;
+ B = b;
+ }
+ }
+
+ public static Color GetFilteredColor(Image<Bgra32> image)
+ {
+ var color = GetColor(image).ToPixel<Bgra32>();
+
+ // We don't want colors that are too dark.
+ // If the color is too dark, make it brighter by reducing the range
+ // and adding a constant color.
+ int luminosity = GetColorApproximateLuminosity(color.R, color.G, color.B);
+ if (luminosity < CutOffLuminosity)
+ {
+ color = Color.FromRgb(
+ (byte)Math.Min(CutOffLuminosity + color.R, byte.MaxValue),
+ (byte)Math.Min(CutOffLuminosity + color.G, byte.MaxValue),
+ (byte)Math.Min(CutOffLuminosity + color.B, byte.MaxValue));
+ }
+
+ return color;
+ }
+
+ public static Color GetColor(Image<Bgra32> image)
+ {
+ var colors = new PaletteColor[TotalColors];
+
+ var dominantColorBin = new Dictionary<int, int>();
+
+ var buffer = GetBuffer(image);
+
+ int w = image.Width;
+
+ int w8 = w << 8;
+ int h8 = image.Height << 8;
+
+ int xStep = w8 / ColorsPerLine;
+ int yStep = h8 / ColorsPerLine;
+
+ int i = 0;
+ int maxHitCount = 0;
+
+ for (int y = 0; y < image.Height; y++)
+ {
+ int yOffset = y * image.Width;
+
+ for (int x = 0; x < image.Width && i < TotalColors; x++)
+ {
+ int offset = x + yOffset;
+
+ byte cb = buffer[offset].B;
+ byte cg = buffer[offset].G;
+ byte cr = buffer[offset].R;
+
+ var qck = GetQuantizedColorKey(cr, cg, cb);
+
+ if (dominantColorBin.TryGetValue(qck, out int hitCount))
+ {
+ dominantColorBin[qck] = hitCount + 1;
+
+ if (maxHitCount < hitCount)
+ {
+ maxHitCount = hitCount;
+ }
+ }
+ else
+ {
+ dominantColorBin.Add(qck, 1);
+ }
+
+ colors[i++] = new PaletteColor(qck, cr, cg, cb);
+ }
+ }
+
+ int highScore = -1;
+ PaletteColor bestCandidate = default;
+
+ for (i = 0; i < TotalColors; i++)
+ {
+ var score = GetColorScore(dominantColorBin, maxHitCount, colors[i]);
+
+ if (highScore < score)
+ {
+ highScore = score;
+ bestCandidate = colors[i];
+ }
+ }
+
+ return Color.FromRgb(bestCandidate.R, bestCandidate.G, bestCandidate.B);
+ }
+
+ public static Bgra32[] GetBuffer(Image<Bgra32> image)
+ {
+ return image.TryGetSinglePixelSpan(out var data) ? data.ToArray() : Array.Empty<Bgra32>();
+ }
+
+ private static int GetColorScore(Dictionary<int, int> dominantColorBin, int maxHitCount, PaletteColor color)
+ {
+ var hitCount = dominantColorBin[color.Qck];
+ var balancedHitCount = BalanceHitCount(hitCount, maxHitCount);
+ var quantSat = (GetColorSaturation(color) >> SatQuantShift) << SatQuantShift;
+ var value = GetColorValue(color);
+
+ // If the color is rarely used on the image,
+ // then chances are that theres a better candidate, even if the saturation value
+ // is high. By multiplying the saturation value with a weight, we can lower
+ // it if the color is almost never used (hit count is low).
+ var satWeighted = quantSat;
+ var satWeight = balancedHitCount << 5;
+ if (satWeight < 0x100)
+ {
+ satWeighted = (satWeighted * satWeight) >> 8;
+ }
+
+ // Compute score from saturation and dominance of the color.
+ // We prefer more vivid colors over dominant ones, so give more weight to the saturation.
+ var score = ((satWeighted << 1) + balancedHitCount) * value;
+
+ return score;
+ }
+
+ private static int BalanceHitCount(int hitCount, int maxHitCount)
+ {
+ return (hitCount << 8) / maxHitCount;
+ }
+
+ private static int GetColorApproximateLuminosity(byte r, byte g, byte b)
+ {
+ return (r + g + b) / 3;
+ }
+
+ private static int GetColorSaturation(PaletteColor color)
+ {
+ int cMax = Math.Max(Math.Max(color.R, color.G), color.B);
+
+ if (cMax == 0)
+ {
+ return 0;
+ }
+
+ int cMin = Math.Min(Math.Min(color.R, color.G), color.B);
+ int delta = cMax - cMin;
+ return (delta << 8) / cMax;
+ }
+
+ private static int GetColorValue(PaletteColor color)
+ {
+ return Math.Max(Math.Max(color.R, color.G), color.B);
+ }
+
+ private static int GetQuantizedColorKey(byte r, byte g, byte b)
+ {
+ int u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128;
+ int v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128;
+ return (v >> UvQuantShift) | ((u >> UvQuantShift) << UvQuantBits);
+ }
+ }
+}
diff --git a/src/Ryujinx.Ava/UI/Windows/MainWindow.axaml b/src/Ryujinx.Ava/UI/Windows/MainWindow.axaml
new file mode 100644
index 00000000..08b99cf5
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/MainWindow.axaml
@@ -0,0 +1,206 @@
+<window:StyleableWindow
+ x:Class="Ryujinx.Ava.UI.Windows.MainWindow"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:window="clr-namespace:Ryujinx.Ava.UI.Windows"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
+ xmlns:controls="clr-namespace:Ryujinx.Ava.UI.Controls"
+ xmlns:main="clr-namespace:Ryujinx.Ava.UI.Views.Main"
+ Cursor="{Binding Cursor}"
+ Title="{Binding Title}"
+ WindowState="{Binding WindowState}"
+ Width="1280"
+ Height="777"
+ MinWidth="1092"
+ MinHeight="672"
+ d:DesignHeight="720"
+ d:DesignWidth="1280"
+ x:CompileBindings="True"
+ x:DataType="viewModels:MainWindowViewModel"
+ WindowStartupLocation="CenterScreen"
+ mc:Ignorable="d"
+ Focusable="True">
+ <Window.Styles>
+ <Style Selector="TitleBar:fullscreen">
+ <Setter Property="Background" Value="#000000" />
+ </Style>
+ </Window.Styles>
+ <Design.DataContext>
+ <viewModels:MainWindowViewModel />
+ </Design.DataContext>
+ <Window.Resources>
+ <helpers:BitmapArrayValueConverter x:Key="ByteImage" />
+ </Window.Resources>
+ <Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="*" />
+ </Grid.RowDefinitions>
+ <helpers:OffscreenTextBox Name="HiddenTextBox" Grid.Row="0" />
+ <StackPanel Grid.Row="0" IsVisible="False">
+ <helpers:HotKeyControl Name="FullscreenHotKey" Command="{ReflectionBinding ToggleFullscreen}" />
+ <helpers:HotKeyControl Name="FullscreenHotKey2" Command="{ReflectionBinding ToggleFullscreen}" />
+ <helpers:HotKeyControl Name="FullscreenHotKeyMacOS" Command="{ReflectionBinding ToggleFullscreen}" />
+ <helpers:HotKeyControl Name="DockToggleHotKey" Command="{ReflectionBinding ToggleDockMode}" />
+ <helpers:HotKeyControl Name="ExitHotKey" Command="{ReflectionBinding ExitCurrentState}" />
+ </StackPanel>
+ <Grid
+ Grid.Row="1"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="*" />
+ </Grid.ColumnDefinitions>
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="*" />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <StackPanel
+ Name="MenuBar"
+ MinHeight="35"
+ Grid.Row="0"
+ Margin="0"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ IsVisible="{Binding ShowMenuAndStatusBar}"
+ Orientation="Vertical">
+ <main:MainMenuBarView
+ Name="MenuBarView" />
+ </StackPanel>
+ <ContentControl
+ Name="MainContent"
+ Grid.Row="1"
+ Padding="0"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ BorderBrush="{DynamicResource ThemeControlBorderColor}"
+ BorderThickness="0,0,0,0"
+ DockPanel.Dock="Top"
+ IsVisible="{Binding ShowContent}">
+ <Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Name="GameLibrary">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="*" />
+ </Grid.RowDefinitions>
+ <main:MainViewControls
+ Name="ViewControls"
+ Grid.Row="0"/>
+ <controls:GameListView
+ x:Name="GameList"
+ Grid.Row="1"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ HorizontalContentAlignment="Stretch"
+ VerticalContentAlignment="Stretch"
+ IsVisible="{Binding IsList}" />
+ <controls:GameGridView
+ x:Name="GameGrid"
+ Grid.Row="1"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ HorizontalContentAlignment="Stretch"
+ VerticalContentAlignment="Stretch"
+ IsVisible="{Binding IsGrid}" />
+ </Grid>
+ </ContentControl>
+ <Grid
+ Grid.Row="1"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ Background="{DynamicResource ThemeContentBackgroundColor}"
+ IsVisible="{Binding ShowLoadProgress}"
+ Name="LoadingView"
+ ZIndex="1000">
+ <Grid
+ Margin="40"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ IsVisible="{Binding ShowLoadProgress}">
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition Width="Auto" />
+ <ColumnDefinition Width="*" />
+ </Grid.ColumnDefinitions>
+ <Border
+ Grid.RowSpan="2"
+ Grid.Column="0"
+ Width="256"
+ Height="256"
+ Margin="10"
+ Padding="4"
+ BorderBrush="Black"
+ BorderThickness="2"
+ BoxShadow="4 4 32 8 #40000000"
+ CornerRadius="3"
+ IsVisible="{Binding ShowLoadProgress}">
+ <Image
+ Width="256"
+ Height="256"
+ IsVisible="{Binding ShowLoadProgress}"
+ Source="{Binding SelectedIcon, Converter={StaticResource ByteImage}}" />
+ </Border>
+ <Grid
+ Grid.Column="1"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Center"
+ IsVisible="{Binding ShowLoadProgress}">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <TextBlock
+ Grid.Row="0"
+ Margin="10"
+ FontSize="30"
+ FontWeight="Bold"
+ IsVisible="{Binding ShowLoadProgress}"
+ Text="{Binding LoadHeading}"
+ TextAlignment="Left"
+ TextWrapping="Wrap"
+ MaxWidth="500" />
+ <Border
+ Grid.Row="1"
+ Margin="10"
+ Padding="0"
+ HorizontalAlignment="Stretch"
+ BorderBrush="{Binding ProgressBarBackgroundColor}"
+ BorderThickness="1"
+ ClipToBounds="True"
+ CornerRadius="5"
+ IsVisible="{Binding ShowLoadProgress}">
+ <ProgressBar
+ Height="10"
+ MinWidth="500"
+ Margin="0"
+ Padding="0"
+ HorizontalAlignment="Stretch"
+ ClipToBounds="True"
+ CornerRadius="5"
+ Foreground="{Binding ProgressBarForegroundColor}"
+ IsIndeterminate="{Binding IsLoadingIndeterminate}"
+ IsVisible="{Binding ShowLoadProgress}"
+ Maximum="{Binding ProgressMaximum}"
+ Minimum="0"
+ Value="{Binding ProgressValue}" />
+ </Border>
+ <TextBlock
+ Grid.Row="2"
+ Margin="10"
+ FontSize="18"
+ IsVisible="{Binding ShowLoadProgress}"
+ Text="{Binding CacheLoadStatus}"
+ TextAlignment="Left"
+ MaxWidth="500" />
+ </Grid>
+ </Grid>
+ </Grid>
+ <main:MainStatusBarView
+ Name="StatusBarView"
+ Grid.Row="2" />
+ </Grid>
+ </Grid>
+</window:StyleableWindow> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/MainWindow.axaml.cs b/src/Ryujinx.Ava/UI/Windows/MainWindow.axaml.cs
new file mode 100644
index 00000000..81e05506
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/MainWindow.axaml.cs
@@ -0,0 +1,441 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Threading;
+using FluentAvalonia.UI.Controls;
+using Ryujinx.Ava.Common;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.Input;
+using Ryujinx.Ava.UI.Applet;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.Common.Logging;
+using Ryujinx.Graphics.Gpu;
+using Ryujinx.HLE.FileSystem;
+using Ryujinx.HLE.HOS;
+using Ryujinx.HLE.HOS.Services.Account.Acc;
+using Ryujinx.Input.SDL2;
+using Ryujinx.Modules;
+using Ryujinx.Ui.App.Common;
+using Ryujinx.Ui.Common;
+using Ryujinx.Ui.Common.Configuration;
+using Ryujinx.Ui.Common.Helper;
+using System;
+using System.ComponentModel;
+using System.IO;
+using System.Threading.Tasks;
+using InputManager = Ryujinx.Input.HLE.InputManager;
+
+namespace Ryujinx.Ava.UI.Windows
+{
+ public partial class MainWindow : StyleableWindow
+ {
+ internal static MainWindowViewModel MainWindowViewModel { get; private set; }
+
+ private bool _isLoading;
+
+ private UserChannelPersistence _userChannelPersistence;
+ private static bool _deferLoad;
+ private static string _launchPath;
+ private static bool _startFullscreen;
+ internal readonly AvaHostUiHandler UiHandler;
+
+ public VirtualFileSystem VirtualFileSystem { get; private set; }
+ public ContentManager ContentManager { get; private set; }
+ public AccountManager AccountManager { get; private set; }
+
+ public LibHacHorizonManager LibHacHorizonManager { get; private set; }
+
+ public InputManager InputManager { get; private set; }
+
+ internal MainWindowViewModel ViewModel { get; private set; }
+ public SettingsWindow SettingsWindow { get; set; }
+
+ public static bool ShowKeyErrorOnLoad { get; set; }
+ public ApplicationLibrary ApplicationLibrary { get; set; }
+
+ public MainWindow()
+ {
+ ViewModel = new MainWindowViewModel();
+
+ MainWindowViewModel = ViewModel;
+
+ DataContext = ViewModel;
+
+ InitializeComponent();
+ Load();
+
+ UiHandler = new AvaHostUiHandler(this);
+
+ ViewModel.Title = $"Ryujinx {Program.Version}";
+
+ // NOTE: Height of MenuBar and StatusBar is not usable here, since it would still be 0 at this point.
+ double barHeight = MenuBar.MinHeight + StatusBarView.StatusBar.MinHeight;
+ Height = ((Height - barHeight) / Program.WindowScaleFactor) + barHeight;
+ Width /= Program.WindowScaleFactor;
+
+ if (Program.PreviewerDetached)
+ {
+ Initialize();
+
+ InputManager = new InputManager(new AvaloniaKeyboardDriver(this), new SDL2GamepadDriver());
+
+ ViewModel.Initialize(
+ ContentManager,
+ ApplicationLibrary,
+ VirtualFileSystem,
+ AccountManager,
+ InputManager,
+ _userChannelPersistence,
+ LibHacHorizonManager,
+ UiHandler,
+ ShowLoading,
+ SwitchToGameControl,
+ SetMainContent,
+ this);
+
+ ViewModel.RefreshFirmwareStatus();
+
+ LoadGameList();
+
+ this.GetObservable(IsActiveProperty).Subscribe(IsActiveChanged);
+ }
+
+ ApplicationLibrary.ApplicationCountUpdated += ApplicationLibrary_ApplicationCountUpdated;
+ ApplicationLibrary.ApplicationAdded += ApplicationLibrary_ApplicationAdded;
+ ViewModel.ReloadGameList += ReloadGameList;
+
+ NotificationHelper.SetNotificationManager(this);
+ }
+
+ private void IsActiveChanged(bool obj)
+ {
+ ViewModel.IsActive = obj;
+ }
+
+ public void LoadGameList()
+ {
+ if (_isLoading)
+ {
+ return;
+ }
+
+ _isLoading = true;
+
+ LoadApplications();
+
+ _isLoading = false;
+ }
+
+ protected override void HandleScalingChanged(double scale)
+ {
+ Program.DesktopScaleFactor = scale;
+ base.HandleScalingChanged(scale);
+ }
+
+ public void AddApplication(ApplicationData applicationData)
+ {
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ ViewModel.Applications.Add(applicationData);
+ });
+ }
+
+ private void ApplicationLibrary_ApplicationAdded(object sender, ApplicationAddedEventArgs e)
+ {
+ AddApplication(e.AppData);
+ }
+
+ private void ApplicationLibrary_ApplicationCountUpdated(object sender, ApplicationCountUpdatedEventArgs e)
+ {
+ LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBarGamesLoaded, e.NumAppsLoaded, e.NumAppsFound);
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ ViewModel.StatusBarProgressValue = e.NumAppsLoaded;
+ ViewModel.StatusBarProgressMaximum = e.NumAppsFound;
+
+ if (e.NumAppsFound == 0)
+ {
+ StatusBarView.LoadProgressBar.IsVisible = false;
+ }
+
+ if (e.NumAppsLoaded == e.NumAppsFound)
+ {
+ StatusBarView.LoadProgressBar.IsVisible = false;
+ }
+ });
+ }
+
+ public void Application_Opened(object sender, ApplicationOpenedEventArgs args)
+ {
+ if (args.Application != null)
+ {
+ ViewModel.SelectedIcon = args.Application.Icon;
+
+ string path = new FileInfo(args.Application.Path).FullName;
+
+ ViewModel.LoadApplication(path);
+ }
+
+ args.Handled = true;
+ }
+
+ internal static void DeferLoadApplication(string launchPathArg, bool startFullscreenArg)
+ {
+ _deferLoad = true;
+ _launchPath = launchPathArg;
+ _startFullscreen = startFullscreenArg;
+ }
+
+ public void SwitchToGameControl(bool startFullscreen = false)
+ {
+ ViewModel.ShowLoadProgress = false;
+ ViewModel.ShowContent = true;
+ ViewModel.IsLoadingIndeterminate = false;
+
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ if (startFullscreen && ViewModel.WindowState != WindowState.FullScreen)
+ {
+ ViewModel.ToggleFullscreen();
+ }
+ });
+ }
+
+ public void ShowLoading(bool startFullscreen = false)
+ {
+ ViewModel.ShowContent = false;
+ ViewModel.ShowLoadProgress = true;
+ ViewModel.IsLoadingIndeterminate = true;
+
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ if (startFullscreen && ViewModel.WindowState != WindowState.FullScreen)
+ {
+ ViewModel.ToggleFullscreen();
+ }
+ });
+ }
+
+ protected override void HandleWindowStateChanged(WindowState state)
+ {
+ ViewModel.WindowState = state;
+
+ if (state != WindowState.Minimized)
+ {
+ Renderer.Start();
+ }
+ }
+
+ private void Initialize()
+ {
+ _userChannelPersistence = new UserChannelPersistence();
+ VirtualFileSystem = VirtualFileSystem.CreateInstance();
+ LibHacHorizonManager = new LibHacHorizonManager();
+ ContentManager = new ContentManager(VirtualFileSystem);
+
+ LibHacHorizonManager.InitializeFsServer(VirtualFileSystem);
+ LibHacHorizonManager.InitializeArpServer();
+ LibHacHorizonManager.InitializeBcatServer();
+ LibHacHorizonManager.InitializeSystemClients();
+
+ ApplicationLibrary = new ApplicationLibrary(VirtualFileSystem);
+
+ // Save data created before we supported extra data in directory save data will not work properly if
+ // given empty extra data. Luckily some of that extra data can be created using the data from the
+ // save data indexer, which should be enough to check access permissions for user saves.
+ // Every single save data's extra data will be checked and fixed if needed each time the emulator is opened.
+ // Consider removing this at some point in the future when we don't need to worry about old saves.
+ VirtualFileSystem.FixExtraData(LibHacHorizonManager.RyujinxClient);
+
+ AccountManager = new AccountManager(LibHacHorizonManager.RyujinxClient, CommandLineState.Profile);
+
+ VirtualFileSystem.ReloadKeySet();
+
+ ApplicationHelper.Initialize(VirtualFileSystem, AccountManager, LibHacHorizonManager.RyujinxClient, this);
+ }
+
+ protected void CheckLaunchState()
+ {
+ if (ShowKeyErrorOnLoad)
+ {
+ ShowKeyErrorOnLoad = false;
+
+ Dispatcher.UIThread.Post(async () => await
+ UserErrorDialog.ShowUserErrorDialog(UserError.NoKeys, this));
+ }
+
+ if (_deferLoad)
+ {
+ _deferLoad = false;
+
+ ViewModel.LoadApplication(_launchPath, _startFullscreen);
+ }
+
+ if (ConfigurationState.Instance.CheckUpdatesOnStart.Value && Updater.CanUpdate(false))
+ {
+ Updater.BeginParse(this, false).ContinueWith(task =>
+ {
+ Logger.Error?.Print(LogClass.Application, $"Updater Error: {task.Exception}");
+ }, TaskContinuationOptions.OnlyOnFaulted);
+ }
+ }
+
+ private void Load()
+ {
+ StatusBarView.VolumeStatus.Click += VolumeStatus_CheckedChanged;
+
+ GameGrid.ApplicationOpened += Application_Opened;
+
+ GameGrid.DataContext = ViewModel;
+
+ GameList.ApplicationOpened += Application_Opened;
+
+ GameList.DataContext = ViewModel;
+
+ LoadHotKeys();
+ }
+
+ protected override void OnOpened(EventArgs e)
+ {
+ base.OnOpened(e);
+
+ CheckLaunchState();
+ }
+
+ private void SetMainContent(Control content = null)
+ {
+ if (content == null)
+ {
+ content = GameLibrary;
+ }
+
+ if (MainContent.Content != content)
+ {
+ MainContent.Content = content;
+ }
+ }
+
+ public static void UpdateGraphicsConfig()
+ {
+ GraphicsConfig.ResScale = ConfigurationState.Instance.Graphics.ResScale == -1 ? ConfigurationState.Instance.Graphics.ResScaleCustom : ConfigurationState.Instance.Graphics.ResScale;
+ GraphicsConfig.MaxAnisotropy = ConfigurationState.Instance.Graphics.MaxAnisotropy;
+ GraphicsConfig.ShadersDumpPath = ConfigurationState.Instance.Graphics.ShadersDumpPath;
+ GraphicsConfig.EnableShaderCache = ConfigurationState.Instance.Graphics.EnableShaderCache;
+ GraphicsConfig.EnableTextureRecompression = ConfigurationState.Instance.Graphics.EnableTextureRecompression;
+ GraphicsConfig.EnableMacroHLE = ConfigurationState.Instance.Graphics.EnableMacroHLE;
+ }
+
+ public void LoadHotKeys()
+ {
+ HotKeyManager.SetHotKey(FullscreenHotKey, new KeyGesture(Key.Enter, KeyModifiers.Alt));
+ HotKeyManager.SetHotKey(FullscreenHotKey2, new KeyGesture(Key.F11));
+ HotKeyManager.SetHotKey(FullscreenHotKeyMacOS, new KeyGesture(Key.F, KeyModifiers.Control | KeyModifiers.Meta));
+ HotKeyManager.SetHotKey(DockToggleHotKey, new KeyGesture(Key.F9));
+ HotKeyManager.SetHotKey(ExitHotKey, new KeyGesture(Key.Escape));
+ }
+
+ private void VolumeStatus_CheckedChanged(object sender, SplitButtonClickEventArgs e)
+ {
+ var volumeSplitButton = sender as ToggleSplitButton;
+ if (ViewModel.IsGameRunning)
+ {
+ if (!volumeSplitButton.IsChecked)
+ {
+ ViewModel.AppHost.Device.SetVolume(ConfigurationState.Instance.System.AudioVolume);
+ }
+ else
+ {
+ ViewModel.AppHost.Device.SetVolume(0);
+ }
+
+ ViewModel.Volume = ViewModel.AppHost.Device.GetVolume();
+ }
+ }
+
+ protected override void OnClosing(CancelEventArgs e)
+ {
+ if (!ViewModel.IsClosing && ViewModel.AppHost != null && ConfigurationState.Instance.ShowConfirmExit)
+ {
+ e.Cancel = true;
+
+ ConfirmExit();
+
+ return;
+ }
+
+ ViewModel.IsClosing = true;
+
+ if (ViewModel.AppHost != null)
+ {
+ ViewModel.AppHost.AppExit -= ViewModel.AppHost_AppExit;
+ ViewModel.AppHost.AppExit += (sender, e) =>
+ {
+ ViewModel.AppHost = null;
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ MainContent = null;
+
+ Close();
+ });
+ };
+ ViewModel.AppHost?.Stop();
+
+ e.Cancel = true;
+
+ return;
+ }
+
+ ApplicationLibrary.CancelLoading();
+ InputManager.Dispose();
+ Program.Exit();
+
+ base.OnClosing(e);
+ }
+
+ private void ConfirmExit()
+ {
+ Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ ViewModel.IsClosing = await ContentDialogHelper.CreateExitDialog();
+
+ if (ViewModel.IsClosing)
+ {
+ Close();
+ }
+ });
+ }
+
+ public async void LoadApplications()
+ {
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ ViewModel.Applications.Clear();
+
+ StatusBarView.LoadProgressBar.IsVisible = true;
+ ViewModel.StatusBarProgressMaximum = 0;
+ ViewModel.StatusBarProgressValue = 0;
+
+ LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBarGamesLoaded, 0, 0);
+ });
+
+ ReloadGameList();
+ }
+
+ private void ReloadGameList()
+ {
+ if (_isLoading)
+ {
+ return;
+ }
+
+ _isLoading = true;
+
+ ApplicationLibrary.LoadApplications(ConfigurationState.Instance.Ui.GameDirs.Value, ConfigurationState.Instance.System.Language);
+
+ _isLoading = false;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/MotionSettingsWindow.axaml b/src/Ryujinx.Ava/UI/Windows/MotionSettingsWindow.axaml
new file mode 100644
index 00000000..862998ac
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/MotionSettingsWindow.axaml
@@ -0,0 +1,141 @@
+<UserControl
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ mc:Ignorable="d"
+ x:Class="Ryujinx.Ava.UI.Windows.MotionSettingsWindow"
+ Focusable="True">
+ <Grid Margin="10">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition />
+ </Grid.RowDefinitions>
+ <StackPanel Orientation="Vertical">
+ <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
+ <TextBlock
+ Margin="0"
+ HorizontalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsMotionGyroSensitivity}" />
+ <Slider
+ Margin="0,-5,0,-5"
+ Width="150"
+ MaxWidth="150"
+ TickFrequency="0.01"
+ IsSnapToTickEnabled="True"
+ Maximum="100"
+ Minimum="0"
+ Value="{Binding Sensitivity, Mode=TwoWay}" />
+ <TextBlock HorizontalAlignment="Center"
+ Margin="5, 0"
+ Text="{Binding Sensitivity, StringFormat=\{0:0\}%}" />
+ </StackPanel>
+ <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
+ <TextBlock
+ Margin="0"
+ HorizontalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsMotionGyroDeadzone}" />
+ <Slider
+ Margin="0,-5,0,-5"
+ Width="150"
+ MaxWidth="150"
+ TickFrequency="0.01"
+ IsSnapToTickEnabled="True"
+ Maximum="100"
+ Minimum="0"
+ Value="{Binding GyroDeadzone, Mode=TwoWay}" />
+ <TextBlock
+ VerticalAlignment="Center"
+ Margin="5, 0"
+ Text="{Binding GyroDeadzone, StringFormat=\{0:0.00\}}" />
+ </StackPanel>
+ <Separator Height="1" Margin="0,5" />
+ <CheckBox Margin="5" IsChecked="{Binding EnableCemuHookMotion}">
+ <TextBlock Margin="0,3,0,0" VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsMotionUseCemuhookCompatibleMotion}" />
+ </CheckBox>
+ </StackPanel>
+ <Border Grid.Row="1"
+ Padding="20,5"
+ BorderBrush="{DynamicResource ThemeControlBorderColor}"
+ BorderThickness="1"
+ HorizontalAlignment="Stretch">
+ <Grid VerticalAlignment="Top">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition Height="*" />
+ </Grid.RowDefinitions>
+ <StackPanel
+ Grid.Row="1"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Orientation="Vertical">
+ <StackPanel
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Orientation="Horizontal">
+ <TextBlock
+ Margin="5"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsMotionServerHost}" />
+ <TextBox
+ Height="30"
+ MinWidth="100"
+ MaxWidth="100"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{Binding DsuServerHost, Mode=TwoWay}" />
+ <TextBlock
+ Margin="5"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text=":" />
+ <TextBox
+ Height="30"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center"
+ Text="{Binding DsuServerPort, Mode=TwoWay}" />
+ </StackPanel>
+ <StackPanel Orientation="Vertical">
+ <Grid>
+ <Grid.RowDefinitions>
+ <RowDefinition />
+ <RowDefinition />
+ </Grid.RowDefinitions>
+ <Grid.ColumnDefinitions>
+ <ColumnDefinition />
+ <ColumnDefinition />
+ </Grid.ColumnDefinitions>
+ <TextBlock Margin="0,10,0,0" VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsMotionControllerSlot}" />
+ <ui:NumberBox Grid.Row="0" Grid.Column="1"
+ Name="CemuHookSlotUpDown"
+ SmallChange="1"
+ LargeChange="1"
+ Maximum="4"
+ Minimum="0"
+ Value="{Binding Slot}" />
+ <TextBlock Margin="0,10,0,0" Grid.Row="1" Grid.Column="0" VerticalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsMotionRightJoyConSlot}" />
+ <ui:NumberBox Grid.Row="1" Grid.Column="1"
+ Name="CemuHookRightJoyConSlotUpDown"
+ SmallChange="1"
+ LargeChange="1"
+ Maximum="4"
+ Minimum="0"
+ Value="{Binding AltSlot}" />
+ </Grid>
+ </StackPanel>
+ <CheckBox HorizontalAlignment="Center"
+ IsChecked="{Binding MirrorInput, Mode=TwoWay}">
+ <TextBlock HorizontalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsMotionMirrorInput}" />
+ </CheckBox>
+ </StackPanel>
+ </Grid>
+ </Border>
+ </Grid>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/MotionSettingsWindow.axaml.cs b/src/Ryujinx.Ava/UI/Windows/MotionSettingsWindow.axaml.cs
new file mode 100644
index 00000000..c686e7c3
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/MotionSettingsWindow.axaml.cs
@@ -0,0 +1,71 @@
+using Avalonia.Controls;
+using FluentAvalonia.UI.Controls;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.Common.Configuration.Hid.Controller;
+using System.Threading.Tasks;
+
+namespace Ryujinx.Ava.UI.Windows
+{
+ public partial class MotionSettingsWindow : UserControl
+ {
+ private readonly InputConfiguration<GamepadInputId, StickInputId> _viewmodel;
+
+ public MotionSettingsWindow()
+ {
+ InitializeComponent();
+ DataContext = _viewmodel;
+ }
+
+ public MotionSettingsWindow(ControllerSettingsViewModel viewmodel)
+ {
+ var config = viewmodel.Configuration as InputConfiguration<GamepadInputId, StickInputId>;
+
+ _viewmodel = new InputConfiguration<GamepadInputId, StickInputId>()
+ {
+ Slot = config.Slot,
+ AltSlot = config.AltSlot,
+ DsuServerHost = config.DsuServerHost,
+ DsuServerPort = config.DsuServerPort,
+ MirrorInput = config.MirrorInput,
+ EnableMotion = config.EnableMotion,
+ Sensitivity = config.Sensitivity,
+ GyroDeadzone = config.GyroDeadzone,
+ EnableCemuHookMotion = config.EnableCemuHookMotion
+ };
+
+ InitializeComponent();
+ DataContext = _viewmodel;
+ }
+
+ public static async Task Show(ControllerSettingsViewModel viewmodel)
+ {
+ MotionSettingsWindow content = new MotionSettingsWindow(viewmodel);
+
+ ContentDialog contentDialog = new ContentDialog
+ {
+ Title = LocaleManager.Instance[LocaleKeys.ControllerMotionTitle],
+ PrimaryButtonText = LocaleManager.Instance[LocaleKeys.ControllerSettingsSave],
+ SecondaryButtonText = "",
+ CloseButtonText = LocaleManager.Instance[LocaleKeys.ControllerSettingsClose],
+ Content = content
+ };
+ contentDialog.PrimaryButtonClick += (sender, args) =>
+ {
+ var config = viewmodel.Configuration as InputConfiguration<GamepadInputId, StickInputId>;
+ config.Slot = content._viewmodel.Slot;
+ config.EnableMotion = content._viewmodel.EnableMotion;
+ config.Sensitivity = content._viewmodel.Sensitivity;
+ config.GyroDeadzone = content._viewmodel.GyroDeadzone;
+ config.AltSlot = content._viewmodel.AltSlot;
+ config.DsuServerHost = content._viewmodel.DsuServerHost;
+ config.DsuServerPort = content._viewmodel.DsuServerPort;
+ config.EnableCemuHookMotion = content._viewmodel.EnableCemuHookMotion;
+ config.MirrorInput = content._viewmodel.MirrorInput;
+ };
+
+ await contentDialog.ShowAsync();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/RumbleSettingsWindow.axaml b/src/Ryujinx.Ava/UI/Windows/RumbleSettingsWindow.axaml
new file mode 100644
index 00000000..e47cc5bd
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/RumbleSettingsWindow.axaml
@@ -0,0 +1,57 @@
+<UserControl
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ mc:Ignorable="d"
+ x:Class="Ryujinx.Ava.UI.Windows.RumbleSettingsWindow"
+ Focusable="True">
+ <Grid Margin="10">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition />
+ </Grid.RowDefinitions>
+ <StackPanel Orientation="Vertical">
+ <StackPanel Orientation="Horizontal">
+ <TextBlock
+ Width="100"
+ TextWrapping="WrapWithOverflow"
+ HorizontalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsRumbleStrongMultiplier}" />
+ <Slider
+ Margin="0,-5,0,-5"
+ Width="200"
+ TickFrequency="0.01"
+ IsSnapToTickEnabled="True"
+ Maximum="10"
+ Minimum="0"
+ Value="{Binding StrongRumble, Mode=TwoWay}" />
+ <TextBlock
+ VerticalAlignment="Center"
+ Margin="5,0"
+ Text="{Binding StrongRumble, StringFormat=\{0:0.00\}}" />
+ </StackPanel>
+ <StackPanel Orientation="Horizontal">
+ <TextBlock
+ Width="100"
+ TextWrapping="WrapWithOverflow"
+ HorizontalAlignment="Center"
+ Text="{locale:Locale ControllerSettingsRumbleWeakMultiplier}" />
+ <Slider
+ Margin="0,-5,0,-5"
+ Width="200"
+ MaxWidth="200"
+ Maximum="10"
+ TickFrequency="0.01"
+ IsSnapToTickEnabled="True"
+ Minimum="0"
+ Value="{Binding WeakRumble, Mode=TwoWay}" />
+ <TextBlock
+ VerticalAlignment="Center"
+ Margin="5,0"
+ Text="{Binding WeakRumble, StringFormat=\{0:0.00\}}" />
+ </StackPanel>
+ </StackPanel>
+ </Grid>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/RumbleSettingsWindow.axaml.cs b/src/Ryujinx.Ava/UI/Windows/RumbleSettingsWindow.axaml.cs
new file mode 100644
index 00000000..178109d6
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/RumbleSettingsWindow.axaml.cs
@@ -0,0 +1,57 @@
+using Avalonia.Controls;
+using FluentAvalonia.UI.Controls;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.Common.Configuration.Hid.Controller;
+using System.Threading.Tasks;
+
+namespace Ryujinx.Ava.UI.Windows
+{
+ public partial class RumbleSettingsWindow : UserControl
+ {
+ private readonly InputConfiguration<GamepadInputId, StickInputId> _viewmodel;
+
+ public RumbleSettingsWindow()
+ {
+ InitializeComponent();
+ DataContext = _viewmodel;
+ }
+
+ public RumbleSettingsWindow(ControllerSettingsViewModel viewmodel)
+ {
+ var config = viewmodel.Configuration as InputConfiguration<GamepadInputId, StickInputId>;
+
+ _viewmodel = new InputConfiguration<GamepadInputId, StickInputId>()
+ {
+ StrongRumble = config.StrongRumble, WeakRumble = config.WeakRumble
+ };
+
+ InitializeComponent();
+ DataContext = _viewmodel;
+ }
+
+ public static async Task Show(ControllerSettingsViewModel viewmodel)
+ {
+ RumbleSettingsWindow content = new RumbleSettingsWindow(viewmodel);
+
+ ContentDialog contentDialog = new ContentDialog
+ {
+ Title = LocaleManager.Instance[LocaleKeys.ControllerRumbleTitle],
+ PrimaryButtonText = LocaleManager.Instance[LocaleKeys.ControllerSettingsSave],
+ SecondaryButtonText = "",
+ CloseButtonText = LocaleManager.Instance[LocaleKeys.ControllerSettingsClose],
+ Content = content,
+ };
+
+ contentDialog.PrimaryButtonClick += (sender, args) =>
+ {
+ var config = viewmodel.Configuration as InputConfiguration<GamepadInputId, StickInputId>;
+ config.StrongRumble = content._viewmodel.StrongRumble;
+ config.WeakRumble = content._viewmodel.WeakRumble;
+ };
+
+ await contentDialog.ShowAsync();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/SettingsWindow.axaml b/src/Ryujinx.Ava/UI/Windows/SettingsWindow.axaml
new file mode 100644
index 00000000..a44cbfe7
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/SettingsWindow.axaml
@@ -0,0 +1,128 @@
+<window:StyleableWindow
+ x:Class="Ryujinx.Ava.UI.Windows.SettingsWindow"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:window="clr-namespace:Ryujinx.Ava.UI.Windows"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ xmlns:settings="clr-namespace:Ryujinx.Ava.UI.Views.Settings"
+ xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
+ Width="1100"
+ Height="768"
+ MinWidth="800"
+ MinHeight="480"
+ WindowStartupLocation="CenterOwner"
+ x:CompileBindings="True"
+ x:DataType="viewModels:SettingsViewModel"
+ mc:Ignorable="d"
+ Focusable="True">
+ <Design.DataContext>
+ <viewModels:SettingsViewModel />
+ </Design.DataContext>
+ <Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch" MinWidth="600">
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
+ <RowDefinition />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <ContentPresenter
+ x:Name="ContentPresenter"
+ Grid.Row="1"
+ IsVisible="False"
+ KeyboardNavigation.IsTabStop="False"/>
+ <Grid Name="Pages" IsVisible="False" Grid.Row="2">
+ <settings:SettingsUIView Name="UiPage" />
+ <settings:SettingsInputView Name="InputPage" />
+ <settings:SettingsHotkeysView Name="HotkeysPage" />
+ <settings:SettingsSystemView Name="SystemPage" />
+ <settings:SettingsCPUView Name="CpuPage" />
+ <settings:SettingsGraphicsView Name="GraphicsPage" />
+ <settings:SettingsAudioView Name="AudioPage" />
+ <settings:SettingsNetworkView Name="NetworkPage" />
+ <settings:SettingsLoggingView Name="LoggingPage" />
+ </Grid>
+ <ui:NavigationView
+ Grid.Row="1"
+ IsSettingsVisible="False"
+ Name="NavPanel"
+ IsBackEnabled="False"
+ PaneDisplayMode="Left"
+ Margin="2,10,10,0"
+ VerticalAlignment="Stretch"
+ HorizontalAlignment="Stretch"
+ OpenPaneLength="200">
+ <ui:NavigationView.MenuItems>
+ <ui:NavigationViewItem
+ IsSelected="True"
+ Content="{locale:Locale SettingsTabGeneral}"
+ Tag="UiPage"
+ Icon="New" />
+ <ui:NavigationViewItem
+ Content="{locale:Locale SettingsTabInput}"
+ Tag="InputPage"
+ Icon="Games" />
+ <ui:NavigationViewItem
+ Content="{locale:Locale SettingsTabHotkeys}"
+ Tag="HotkeysPage"
+ Icon="Keyboard" />
+ <ui:NavigationViewItem
+ Content="{locale:Locale SettingsTabSystem}"
+ Tag="SystemPage"
+ Icon="Settings" />
+ <ui:NavigationViewItem
+ Content="{locale:Locale SettingsTabCpu}"
+ Tag="CpuPage">
+ <ui:NavigationViewItem.Icon>
+ <ui:FontIcon
+ FontFamily="avares://Ryujinx.Ava/Assets/Fonts#Segoe Fluent Icons"
+ Glyph="{helpers:GlyphValueConverter Chip}" />
+ </ui:NavigationViewItem.Icon>
+ </ui:NavigationViewItem>
+ <ui:NavigationViewItem
+ Content="{locale:Locale SettingsTabGraphics}"
+ Tag="GraphicsPage"
+ Icon="Image" />
+ <ui:NavigationViewItem
+ Content="{locale:Locale SettingsTabAudio}"
+ Icon="Audio"
+ Tag="AudioPage" />
+ <ui:NavigationViewItem
+ Content="{locale:Locale SettingsTabNetwork}"
+ Tag="NetworkPage"
+ Icon="Globe" />
+ <ui:NavigationViewItem
+ Content="{locale:Locale SettingsTabLogging}"
+ Tag="LoggingPage"
+ Icon="Document" />
+ </ui:NavigationView.MenuItems>
+ <ui:NavigationView.Styles>
+ <Style Selector="Grid#PlaceholderGrid">
+ <Setter Property="Height" Value="40" />
+ </Style>
+ </ui:NavigationView.Styles>
+ </ui:NavigationView>
+ <ReversibleStackPanel
+ Grid.Row="2"
+ Margin="10"
+ Spacing="10"
+ Orientation="Horizontal"
+ HorizontalAlignment="Right"
+ ReverseOrder="{Binding IsMacOS}">
+ <Button
+ HotKey="Enter"
+ Classes="accent"
+ Content="{locale:Locale SettingsButtonOk}"
+ Command="{ReflectionBinding OkButton}" />
+ <Button
+ HotKey="Escape"
+ Content="{locale:Locale SettingsButtonCancel}"
+ Command="{ReflectionBinding CancelButton}" />
+ <Button
+ Content="{locale:Locale SettingsButtonApply}"
+ Command="{ReflectionBinding ApplyButton}" />
+ </ReversibleStackPanel>
+ </Grid>
+</window:StyleableWindow> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/SettingsWindow.axaml.cs b/src/Ryujinx.Ava/UI/Windows/SettingsWindow.axaml.cs
new file mode 100644
index 00000000..bdf7e94d
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/SettingsWindow.axaml.cs
@@ -0,0 +1,103 @@
+using FluentAvalonia.Core;
+using FluentAvalonia.UI.Controls;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.HLE.FileSystem;
+using System;
+using System.ComponentModel;
+
+namespace Ryujinx.Ava.UI.Windows
+{
+ public partial class SettingsWindow : StyleableWindow
+ {
+ internal SettingsViewModel ViewModel { get; set; }
+
+ public SettingsWindow(VirtualFileSystem virtualFileSystem, ContentManager contentManager)
+ {
+ Title = $"Ryujinx {Program.Version} - {LocaleManager.Instance[LocaleKeys.Settings]}";
+
+ ViewModel = new SettingsViewModel(virtualFileSystem, contentManager);
+ DataContext = ViewModel;
+
+ ViewModel.CloseWindow += Close;
+ ViewModel.SaveSettingsEvent += SaveSettings;
+
+ InitializeComponent();
+ Load();
+ }
+
+ public SettingsWindow()
+ {
+ ViewModel = new SettingsViewModel();
+ DataContext = ViewModel;
+
+ InitializeComponent();
+ Load();
+ }
+
+ public void SaveSettings()
+ {
+ InputPage.ControllerSettings?.SaveCurrentProfile();
+
+ if (Owner is MainWindow window && ViewModel.DirectoryChanged)
+ {
+ window.ViewModel.LoadApplications();
+ }
+ }
+
+ private void Load()
+ {
+ Pages.Children.Clear();
+ NavPanel.SelectionChanged += NavPanelOnSelectionChanged;
+ NavPanel.SelectedItem = NavPanel.MenuItems.ElementAt(0);
+ }
+
+ private void NavPanelOnSelectionChanged(object sender, NavigationViewSelectionChangedEventArgs e)
+ {
+ if (e.SelectedItem is NavigationViewItem navItem && navItem.Tag is not null)
+ {
+ switch (navItem.Tag.ToString())
+ {
+ case "UiPage":
+ UiPage.ViewModel = ViewModel;
+ NavPanel.Content = UiPage;
+ break;
+ case "InputPage":
+ NavPanel.Content = InputPage;
+ break;
+ case "HotkeysPage":
+ NavPanel.Content = HotkeysPage;
+ break;
+ case "SystemPage":
+ SystemPage.ViewModel = ViewModel;
+ NavPanel.Content = SystemPage;
+ break;
+ case "CpuPage":
+ NavPanel.Content = CpuPage;
+ break;
+ case "GraphicsPage":
+ NavPanel.Content = GraphicsPage;
+ break;
+ case "AudioPage":
+ NavPanel.Content = AudioPage;
+ break;
+ case "NetworkPage":
+ NavPanel.Content = NetworkPage;
+ break;
+ case "LoggingPage":
+ NavPanel.Content = LoggingPage;
+ break;
+ default:
+ throw new NotImplementedException();
+ }
+ }
+ }
+
+ protected override void OnClosing(CancelEventArgs e)
+ {
+ HotkeysPage.Dispose();
+ InputPage.Dispose();
+ base.OnClosing(e);
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/StyleableWindow.cs b/src/Ryujinx.Ava/UI/Windows/StyleableWindow.cs
new file mode 100644
index 00000000..a157f154
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/StyleableWindow.cs
@@ -0,0 +1,39 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using System;
+using System.IO;
+using System.Reflection;
+
+namespace Ryujinx.Ava.UI.Windows
+{
+ public class StyleableWindow : Window
+ {
+ public IBitmap IconImage { get; set; }
+
+ public StyleableWindow()
+ {
+ WindowStartupLocation = WindowStartupLocation.CenterOwner;
+ TransparencyLevelHint = WindowTransparencyLevel.None;
+
+ using Stream stream = Assembly.GetAssembly(typeof(Ryujinx.Ui.Common.Configuration.ConfigurationState)).GetManifestResourceStream("Ryujinx.Ui.Common.Resources.Logo_Ryujinx.png");
+
+ Icon = new WindowIcon(stream);
+ stream.Position = 0;
+ IconImage = new Bitmap(stream);
+ }
+
+ protected override void OnOpened(EventArgs e)
+ {
+ base.OnOpened(e);
+ }
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+
+ ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.SystemChrome | ExtendClientAreaChromeHints.OSXThickTitleBar;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/TitleUpdateWindow.axaml b/src/Ryujinx.Ava/UI/Windows/TitleUpdateWindow.axaml
new file mode 100644
index 00000000..e9858038
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/TitleUpdateWindow.axaml
@@ -0,0 +1,135 @@
+<UserControl
+ x:Class="Ryujinx.Ava.UI.Windows.TitleUpdateWindow"
+ xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
+ xmlns:models="clr-namespace:Ryujinx.Ava.UI.Models"
+ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
+ Width="500"
+ Height="300"
+ mc:Ignorable="d"
+ x:CompileBindings="True"
+ x:DataType="viewModels:TitleUpdateViewModel"
+ Focusable="True">
+ <Grid>
+ <Grid.RowDefinitions>
+ <RowDefinition Height="*" />
+ <RowDefinition Height="Auto" />
+ </Grid.RowDefinitions>
+ <Border
+ Grid.Row="0"
+ Margin="0 0 0 24"
+ HorizontalAlignment="Stretch"
+ VerticalAlignment="Stretch"
+ BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
+ BorderThickness="1"
+ CornerRadius="5"
+ Padding="2.5">
+ <ListBox
+ VirtualizationMode="None"
+ Background="Transparent"
+ SelectedItem="{Binding SelectedUpdate, Mode=TwoWay}"
+ Items="{Binding Views}">
+ <ListBox.DataTemplates>
+ <DataTemplate
+ DataType="models:TitleUpdateModel">
+ <Panel Margin="10">
+ <TextBlock
+ HorizontalAlignment="Left"
+ VerticalAlignment="Center"
+ TextWrapping="Wrap"
+ Text="{Binding Label}" />
+ <StackPanel
+ Spacing="10"
+ Orientation="Horizontal"
+ HorizontalAlignment="Right">
+ <Button
+ VerticalAlignment="Center"
+ HorizontalAlignment="Right"
+ Padding="10"
+ MinWidth="0"
+ MinHeight="0"
+ Click="OpenLocation">
+ <ui:SymbolIcon
+ Symbol="OpenFolder"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center" />
+ </Button>
+ <Button
+ VerticalAlignment="Center"
+ HorizontalAlignment="Right"
+ Padding="10"
+ MinWidth="0"
+ MinHeight="0"
+ Click="RemoveUpdate">
+ <ui:SymbolIcon
+ Symbol="Cancel"
+ HorizontalAlignment="Center"
+ VerticalAlignment="Center" />
+ </Button>
+ </StackPanel>
+ </Panel>
+ </DataTemplate>
+ <DataTemplate
+ DataType="viewModels:BaseModel">
+ <Panel
+ Height="33"
+ Margin="10">
+ <TextBlock
+ HorizontalAlignment="Left"
+ VerticalAlignment="Center"
+ TextWrapping="Wrap"
+ Text="{locale:Locale NoUpdate}" />
+ </Panel>
+ </DataTemplate>
+ </ListBox.DataTemplates>
+ <ListBox.Styles>
+ <Style Selector="ListBoxItem">
+ <Setter Property="Background" Value="Transparent" />
+ </Style>
+ </ListBox.Styles>
+ </ListBox>
+ </Border>
+ <Panel
+ Grid.Row="1"
+ HorizontalAlignment="Stretch">
+ <StackPanel
+ Orientation="Horizontal"
+ Spacing="10"
+ HorizontalAlignment="Left">
+ <Button
+ Name="AddButton"
+ MinWidth="90"
+ Command="{ReflectionBinding Add}">
+ <TextBlock Text="{locale:Locale SettingsTabGeneralAdd}" />
+ </Button>
+ <Button
+ Name="RemoveAllButton"
+ MinWidth="90"
+ Click="RemoveAll">
+ <TextBlock Text="{locale:Locale DlcManagerRemoveAllButton}" />
+ </Button>
+ </StackPanel>
+ <StackPanel
+ Orientation="Horizontal"
+ Spacing="10"
+ HorizontalAlignment="Right">
+ <Button
+ Name="SaveButton"
+ MinWidth="90"
+ Click="Save">
+ <TextBlock Text="{locale:Locale SettingsButtonSave}" />
+ </Button>
+ <Button
+ Name="CancelButton"
+ MinWidth="90"
+ Click="Close">
+ <TextBlock Text="{locale:Locale InputDialogCancel}" />
+ </Button>
+ </StackPanel>
+ </Panel>
+ </Grid>
+</UserControl> \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/Windows/TitleUpdateWindow.axaml.cs b/src/Ryujinx.Ava/UI/Windows/TitleUpdateWindow.axaml.cs
new file mode 100644
index 00000000..153ce95d
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/Windows/TitleUpdateWindow.axaml.cs
@@ -0,0 +1,96 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Styling;
+using FluentAvalonia.UI.Controls;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.Ava.UI.ViewModels;
+using Ryujinx.HLE.FileSystem;
+using Ryujinx.Ui.Common.Helper;
+using System.Threading.Tasks;
+using Button = Avalonia.Controls.Button;
+
+namespace Ryujinx.Ava.UI.Windows
+{
+ public partial class TitleUpdateWindow : UserControl
+ {
+ public TitleUpdateViewModel ViewModel;
+
+ public TitleUpdateWindow()
+ {
+ DataContext = this;
+
+ InitializeComponent();
+ }
+
+ public TitleUpdateWindow(VirtualFileSystem virtualFileSystem, ulong titleId, string titleName)
+ {
+ DataContext = ViewModel = new TitleUpdateViewModel(virtualFileSystem, titleId, titleName);
+
+ InitializeComponent();
+ }
+
+ public static async Task Show(VirtualFileSystem virtualFileSystem, ulong titleId, string titleName)
+ {
+ ContentDialog contentDialog = new()
+ {
+ PrimaryButtonText = "",
+ SecondaryButtonText = "",
+ CloseButtonText = "",
+ Content = new TitleUpdateWindow(virtualFileSystem, titleId, titleName),
+ Title = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.GameUpdateWindowHeading, titleName, titleId.ToString("X16"))
+ };
+
+ Style bottomBorder = new(x => x.OfType<Grid>().Name("DialogSpace").Child().OfType<Border>());
+ bottomBorder.Setters.Add(new Setter(IsVisibleProperty, false));
+
+ contentDialog.Styles.Add(bottomBorder);
+
+ await ContentDialogHelper.ShowAsync(contentDialog);
+ }
+
+ private void Close(object sender, RoutedEventArgs e)
+ {
+ ((ContentDialog)Parent).Hide();
+ }
+
+ public void Save(object sender, RoutedEventArgs e)
+ {
+ ViewModel.Save();
+
+ if (VisualRoot is MainWindow window)
+ {
+ window.ViewModel.LoadApplications();
+ }
+
+ ((ContentDialog)Parent).Hide();
+ }
+
+ private void OpenLocation(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button button)
+ {
+ if (button.DataContext is TitleUpdateModel model)
+ {
+ OpenHelper.LocateFile(model.Path);
+ }
+ }
+ }
+
+ private void RemoveUpdate(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button button)
+ {
+ ViewModel.RemoveUpdate((TitleUpdateModel)button.DataContext);
+ }
+ }
+
+ private void RemoveAll(object sender, RoutedEventArgs e)
+ {
+ ViewModel.TitleUpdates.Clear();
+
+ ViewModel.SortUpdates();
+ }
+ }
+} \ No newline at end of file