diff options
| author | TSR Berry <20988865+TSRBerry@users.noreply.github.com> | 2023-04-08 01:22:00 +0200 |
|---|---|---|
| committer | Mary <thog@protonmail.com> | 2023-04-27 23:51:14 +0200 |
| commit | cee712105850ac3385cd0091a923438167433f9f (patch) | |
| tree | 4a5274b21d8b7f938c0d0ce18736d3f2993b11b1 /src/Ryujinx.Ava/UI | |
| parent | cd124bda587ef09668a971fa1cac1c3f0cfc9f21 (diff) | |
Move solution and projects to src
Diffstat (limited to 'src/Ryujinx.Ava/UI')
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 |
