diff --git a/Directory.Packages.props b/Directory.Packages.props index ab3bc39b8..203f40588 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -44,6 +44,7 @@ + diff --git a/src/Ryujinx.UI.LocaleGenerator/LocaleGenerator.cs b/src/Ryujinx.UI.LocaleGenerator/LocaleGenerator.cs index c69eca7ee..2ba0b60a3 100644 --- a/src/Ryujinx.UI.LocaleGenerator/LocaleGenerator.cs +++ b/src/Ryujinx.UI.LocaleGenerator/LocaleGenerator.cs @@ -19,7 +19,7 @@ namespace Ryujinx.UI.LocaleGenerator StringBuilder enumSourceBuilder = new(); enumSourceBuilder.AppendLine("namespace Ryujinx.Ava.Common.Locale;"); - enumSourceBuilder.AppendLine("internal enum LocaleKeys"); + enumSourceBuilder.AppendLine("public enum LocaleKeys"); enumSourceBuilder.AppendLine("{"); foreach (var line in lines) { diff --git a/src/Ryujinx/Assets/locales.json b/src/Ryujinx/Assets/locales.json index a04bd0538..e7a55bf9d 100644 --- a/src/Ryujinx/Assets/locales.json +++ b/src/Ryujinx/Assets/locales.json @@ -22596,6 +22596,206 @@ "zh_CN": "降低自定义刷新率:", "zh_TW": "" } + }, + { + "ID": "CompatibilityListSearchBoxWatermark", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Search compatibility entries...", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, + { + "ID": "CompatibilityListOpen", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Open Compatibility List", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, + { + "ID": "CompatibilityListOnlyShowOwnedGames", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Only show owned games", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, + { + "ID": "CompatibilityListPlayable", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Playable", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, + { + "ID": "CompatibilityListIngame", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Ingame", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, + { + "ID": "CompatibilityListMenus", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Menus", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, + { + "ID": "CompatibilityListBoots", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Boots", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } + }, + { + "ID": "CompatibilityListNothing", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Nothing", + "es_ES": "", + "fr_FR": "", + "he_IL": "", + "it_IT": "", + "ja_JP": "", + "ko_KR": "", + "no_NO": "", + "pl_PL": "", + "pt_BR": "", + "ru_RU": "", + "sv_SE": "", + "th_TH": "", + "tr_TR": "", + "uk_UA": "", + "zh_CN": "", + "zh_TW": "" + } } ] } \ No newline at end of file diff --git a/src/Ryujinx/Ryujinx.csproj b/src/Ryujinx/Ryujinx.csproj index 0991cf9ce..55f683af9 100644 --- a/src/Ryujinx/Ryujinx.csproj +++ b/src/Ryujinx/Ryujinx.csproj @@ -61,6 +61,7 @@ + @@ -113,6 +114,10 @@ Designer + + + + MSBuild:Compile @@ -163,4 +168,13 @@ + + + CompatibilityList.axaml + Code + + + + + diff --git a/src/Ryujinx/RyujinxApp.axaml b/src/Ryujinx/RyujinxApp.axaml index e07d7ff26..aca69645a 100644 --- a/src/Ryujinx/RyujinxApp.axaml +++ b/src/Ryujinx/RyujinxApp.axaml @@ -6,6 +6,9 @@ + + avares://Ryujinx/Assets/Fonts/Mono/#JetBrains Mono + diff --git a/src/Ryujinx/UI/Helpers/PlayabilityStatusConverter.cs b/src/Ryujinx/UI/Helpers/PlayabilityStatusConverter.cs new file mode 100644 index 000000000..a894f0246 --- /dev/null +++ b/src/Ryujinx/UI/Helpers/PlayabilityStatusConverter.cs @@ -0,0 +1,28 @@ +using Avalonia.Data.Converters; +using Avalonia.Media; +using Gommon; +using Ryujinx.Ava.Common.Locale; +using System; +using System.Globalization; + +namespace Ryujinx.Ava.UI.Helpers +{ + public class PlayabilityStatusConverter : IValueConverter + { + private static readonly Lazy _shared = new(() => new()); + public static PlayabilityStatusConverter Shared => _shared.Value; + + public object Convert(object? value, Type _, object? __, CultureInfo ___) => + value.Cast() switch + { + LocaleKeys.CompatibilityListNothing or + LocaleKeys.CompatibilityListBoots or + LocaleKeys.CompatibilityListMenus => Brushes.Red, + LocaleKeys.CompatibilityListIngame => Brushes.Yellow, + _ => Brushes.ForestGreen + }; + + public object ConvertBack(object? value, Type _, object? __, CultureInfo ___) + => throw new NotSupportedException(); + } +} diff --git a/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml b/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml index 78848e89b..d2f050c5a 100644 --- a/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml +++ b/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml @@ -304,6 +304,10 @@ Header="{ext:Locale MenuBarHelpCheckForUpdates}" Icon="{ext:Icon mdi-update}" ToolTip.Tip="{ext:Locale CheckUpdatesTooltip}" /> + await AboutWindow.Show(); public void CloseWindow(object sender, RoutedEventArgs e) => Window.Close(); + + private async void OpenCompatibilityList(object sender, RoutedEventArgs e) => await CompatibilityList.Show(); } } diff --git a/src/Ryujinx/Utilities/Compat/CompatibilityCsv.cs b/src/Ryujinx/Utilities/Compat/CompatibilityCsv.cs new file mode 100644 index 000000000..4abe7465e --- /dev/null +++ b/src/Ryujinx/Utilities/Compat/CompatibilityCsv.cs @@ -0,0 +1,152 @@ +using Gommon; +using nietras.SeparatedValues; +using Ryujinx.Ava.Common.Locale; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Ryujinx.Ava.Utilities.Compat +{ + public class CompatibilityCsv + { + public static CompatibilityCsv Shared { get; set; } + + public CompatibilityCsv(SepReader reader) + { + var entries = new List(); + + foreach (var row in reader) + { + entries.Add(new CompatibilityEntry(reader.Header, row)); + } + + Entries = entries.Where(x => x.Status != null) + .OrderBy(it => it.GameName).ToArray(); + } + + public CompatibilityEntry[] Entries { get; } + } + + public class CompatibilityEntry + { + public CompatibilityEntry(SepReaderHeader header, SepReader.Row row) + { + IssueNumber = row[header.IndexOf("issue_number")].Parse(); + + var titleIdRow = row[header.IndexOf("extracted_game_id")].ToString(); + if (!string.IsNullOrEmpty(titleIdRow)) + TitleId = titleIdRow; + + var issueTitleRow = row[header.IndexOf("issue_title")].ToString(); + if (TitleId.HasValue) + issueTitleRow = issueTitleRow.ReplaceIgnoreCase($" - {TitleId}", string.Empty); + + GameName = issueTitleRow.Trim().Trim('"'); + + IssueLabels = row[header.IndexOf("issue_labels")].ToString().Split(';'); + Status = row[header.IndexOf("extracted_status")].ToString().ToLower() switch + { + "playable" => LocaleKeys.CompatibilityListPlayable, + "ingame" => LocaleKeys.CompatibilityListIngame, + "menus" => LocaleKeys.CompatibilityListMenus, + "boots" => LocaleKeys.CompatibilityListBoots, + "nothing" => LocaleKeys.CompatibilityListNothing, + _ => null + }; + + if (row[header.IndexOf("last_event_date")].TryParse(out var dt)) + LastEvent = dt; + + if (row[header.IndexOf("events_count")].TryParse(out var eventsCount)) + EventCount = eventsCount; + } + + public int IssueNumber { get; } + public string GameName { get; } + public Optional TitleId { get; } + public string[] IssueLabels { get; } + public LocaleKeys? Status { get; } + public DateTime LastEvent { get; } + public int EventCount { get; } + + public string LocalizedStatus => LocaleManager.Instance[Status!.Value]; + public string FormattedTitleId => TitleId.OrElse(new string(' ', 16)); + + public string FormattedIssueLabels => IssueLabels + .Where(it => !it.StartsWithIgnoreCase("status")) + .Select(FormatLabelName) + .JoinToString(", "); + + public override string ToString() + { + var sb = new StringBuilder("CompatibilityEntry: {"); + sb.Append($"{nameof(IssueNumber)}={IssueNumber}, "); + sb.Append($"{nameof(GameName)}=\"{GameName}\", "); + sb.Append($"{nameof(TitleId)}={TitleId}, "); + sb.Append($"{nameof(IssueLabels)}=\"{IssueLabels}\", "); + sb.Append($"{nameof(Status)}=\"{Status}\", "); + sb.Append($"{nameof(LastEvent)}=\"{LastEvent}\", "); + sb.Append($"{nameof(EventCount)}={EventCount}"); + sb.Append('}'); + + return sb.ToString(); + } + + public static string FormatLabelName(string labelName) => labelName.ToLower() switch + { + "audio" => "Audio", + "bug" => "Bug", + "cpu" => "CPU", + "gpu" => "GPU", + "gui" => "GUI", + "help wanted" => "Help Wanted", + "horizon" => "Horizon", + "infra" => "Project Infra", + "invalid" => "Invalid", + "kernel" => "Kernel", + "ldn" => "LDN", + "linux" => "Linux", + "macos" => "macOS", + "question" => "Question", + "windows" => "Windows", + "graphics-backend:opengl" => "Graphics: OpenGL", + "graphics-backend:vulkan" => "Graphics: Vulkan", + "ldn-works" => "LDN Works", + "ldn-untested" => "LDN Untested", + "ldn-broken" => "LDN Broken", + "ldn-partial" => "Partial LDN", + "nvdec" => "NVDEC", + "services" => "NX Services", + "services-horizon" => "Horizon OS Services", + "slow" => "Runs Slow", + "crash" => "Crashes", + "deadlock" => "Deadlock", + "regression" => "Regression", + "opengl" => "OpenGL", + "opengl-backend-bug" => "OpenGL Backend Bug", + "vulkan-backend-bug" => "Vulkan Backend Bug", + "mac-bug" => "Mac-specific Bug(s)", + "amd-vendor-bug" => "AMD GPU Bug", + "intel-vendor-bug" => "Intel GPU Bug", + "loader-allocator" => "Loader Allocator", + "audout" => "AudOut", + "32-bit" => "32-bit Game", + "UE4" => "Unreal Engine 4", + "homebrew" => "Homebrew Content", + "online-broken" => "Online Broken", + _ => Capitalize(labelName) + }; + + public static string Capitalize(string value) + { + if (value == string.Empty) + return string.Empty; + + var firstChar = value[0]; + var rest = value[1..]; + + return $"{char.ToUpper(firstChar)}{rest}"; + } + } +} diff --git a/src/Ryujinx/Utilities/Compat/CompatibilityHelper.cs b/src/Ryujinx/Utilities/Compat/CompatibilityHelper.cs new file mode 100644 index 000000000..6fee23883 --- /dev/null +++ b/src/Ryujinx/Utilities/Compat/CompatibilityHelper.cs @@ -0,0 +1,32 @@ +using Gommon; +using nietras.SeparatedValues; +using Ryujinx.Common.Configuration; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Ryujinx.Ava.Utilities.Compat +{ + public static class CompatibilityHelper + { + private static readonly string _downloadUrl = + "https://gist.githubusercontent.com/ezhevita/b41ed3bf64d0cc01269cab036e884f3d/raw/002b1a1c1a5f7a83276625e8c479c987a5f5b722/Ryujinx%2520Games%2520List%2520Compatibility.csv"; + + private static readonly FilePath _compatCsvPath = new FilePath(AppDataManager.BaseDirPath) / "system" / "compatibility.csv"; + + public static async Task DownloadAsync() + { + if (_compatCsvPath.ExistsAsFile) + return Sep.Reader().FromFile(_compatCsvPath.Path); + + using var httpClient = new HttpClient(); + var compatCsv = await httpClient.GetStringAsync(_downloadUrl); + _compatCsvPath.WriteAllText(compatCsv); + return Sep.Reader().FromText(compatCsv); + } + + public static async Task InitAsync() + { + CompatibilityCsv.Shared = new CompatibilityCsv(await DownloadAsync()); + } + } +} diff --git a/src/Ryujinx/Utilities/Compat/CompatibilityList.axaml b/src/Ryujinx/Utilities/Compat/CompatibilityList.axaml new file mode 100644 index 000000000..fd912ad05 --- /dev/null +++ b/src/Ryujinx/Utilities/Compat/CompatibilityList.axaml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ryujinx/Utilities/Compat/CompatibilityList.axaml.cs b/src/Ryujinx/Utilities/Compat/CompatibilityList.axaml.cs new file mode 100644 index 000000000..68b645efd --- /dev/null +++ b/src/Ryujinx/Utilities/Compat/CompatibilityList.axaml.cs @@ -0,0 +1,56 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.Styling; +using FluentAvalonia.UI.Controls; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.UI.Helpers; +using Ryujinx.Ava.UI.Windows; +using System.Threading.Tasks; + +namespace Ryujinx.Ava.Utilities.Compat +{ + public partial class CompatibilityList : UserControl + { + public static async Task Show() + { + await CompatibilityHelper.InitAsync(); + + ContentDialog contentDialog = new() + { + PrimaryButtonText = string.Empty, + SecondaryButtonText = string.Empty, + CloseButtonText = LocaleManager.Instance[LocaleKeys.SettingsButtonClose], + Content = new CompatibilityList { DataContext = new CompatibilityViewModel(RyujinxApp.MainWindow.ViewModel.ApplicationLibrary) } + }; + + 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); + } + + public CompatibilityList() + { + InitializeComponent(); + } + + private void TextBox_OnTextChanged(object? sender, TextChangedEventArgs e) + { + if (DataContext is not CompatibilityViewModel cvm) + return; + + if (sender is not TextBox searchBox) + return; + + cvm.Search(searchBox.Text); + } + } +} + diff --git a/src/Ryujinx/Utilities/Compat/CompatibilityViewModel.cs b/src/Ryujinx/Utilities/Compat/CompatibilityViewModel.cs new file mode 100644 index 000000000..2490f222a --- /dev/null +++ b/src/Ryujinx/Utilities/Compat/CompatibilityViewModel.cs @@ -0,0 +1,59 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using ExCSS; +using Gommon; +using Ryujinx.Ava.Utilities.AppLibrary; +using System.Collections.Generic; +using System.Linq; + +namespace Ryujinx.Ava.Utilities.Compat +{ + public partial class CompatibilityViewModel : ObservableObject + { + [ObservableProperty] private bool _onlyShowOwnedGames; + + private IEnumerable _currentEntries = CompatibilityCsv.Shared.Entries; + private readonly string[] _ownedGameTitleIds = []; + private readonly ApplicationLibrary _appLibrary; + + public IEnumerable CurrentEntries => OnlyShowOwnedGames + ? _currentEntries.Where(x => + x.TitleId.Check(tid => _ownedGameTitleIds.ContainsIgnoreCase(tid)) + || _appLibrary.Applications.Items.Any(a => a.Name.EqualsIgnoreCase(x.GameName))) + : _currentEntries; + + public CompatibilityViewModel() {} + + public CompatibilityViewModel(ApplicationLibrary appLibrary) + { + _appLibrary = appLibrary; + _ownedGameTitleIds = appLibrary.Applications.Keys.Select(x => x.ToString("X16")).ToArray(); + + PropertyChanged += (_, args) => + { + if (args.PropertyName is nameof(OnlyShowOwnedGames)) + OnPropertyChanged(nameof(CurrentEntries)); + }; + } + + public void Search(string searchTerm) + { + if (string.IsNullOrEmpty(searchTerm)) + { + SetEntries(CompatibilityCsv.Shared.Entries); + return; + } + + SetEntries(CompatibilityCsv.Shared.Entries.Where(x => + x.GameName.ContainsIgnoreCase(searchTerm) + || x.TitleId.Check(tid => tid.ContainsIgnoreCase(searchTerm)))); + } + + private void SetEntries(IEnumerable entries) + { +#pragma warning disable MVVMTK0034 + _currentEntries = entries.ToList(); +#pragma warning restore MVVMTK0034 + OnPropertyChanged(nameof(CurrentEntries)); + } + } +}