فهرست منبع

UI: Compatibility List Viewer

Evan Husted 1 سال پیش
والد
کامیت
c4cc657b89

+ 1 - 0
Directory.Packages.props

@@ -44,6 +44,7 @@
     <PackageVersion Include="Ryujinx.SDL2-CS" Version="2.30.0-build32" />
     <PackageVersion Include="Gommon" Version="2.7.0.1" />
     <PackageVersion Include="securifybv.ShellLink" Version="0.1.0" />
+    <PackageVersion Include="Sep" Version="0.6.0" />
     <PackageVersion Include="shaderc.net" Version="0.1.0" />
     <PackageVersion Include="SharpMetal" Version="1.0.0-preview21" />
     <PackageVersion Include="SharpZipLib" Version="1.4.2" />

+ 1 - 1
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)
                 {

+ 200 - 0
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": ""
+      }
     }
   ]
 }

+ 14 - 0
src/Ryujinx/Ryujinx.csproj

@@ -61,6 +61,7 @@
     <PackageReference Include="Ryujinx.Graphics.Nvdec.Dependencies" />
     <PackageReference Include="Ryujinx.Graphics.Vulkan.Dependencies.MoltenVK" Condition="'$(RuntimeIdentifier)' != 'linux-x64' AND '$(RuntimeIdentifier)' != 'linux-arm64' AND '$(RuntimeIdentifier)' != 'win-x64'" />
     <PackageReference Include="securifybv.ShellLink" />
+    <PackageReference Include="Sep" />
     <PackageReference Include="Silk.NET.Vulkan" />
     <PackageReference Include="Silk.NET.Vulkan.Extensions.EXT" />
     <PackageReference Include="Silk.NET.Vulkan.Extensions.KHR" />
@@ -113,6 +114,10 @@
     <AvaloniaResource Include="UI\**\*.xaml">
       <SubType>Designer</SubType>
     </AvaloniaResource>
+    <AvaloniaResource Include="Assets\Fonts\Mono\JetBrainsMonoNL-Bold.ttf" />
+    <AvaloniaResource Include="Assets\Fonts\Mono\JetBrainsMonoNL-BoldItalic.ttf" />
+    <AvaloniaResource Include="Assets\Fonts\Mono\JetBrainsMonoNL-Italic.ttf" />
+    <AvaloniaResource Include="Assets\Fonts\Mono\JetBrainsMonoNL-Regular.ttf" />
     <AvaloniaResource Include="Assets\Fonts\SegoeFluentIcons.ttf" />
     <AvaloniaResource Include="Assets\Styles\Themes.xaml">
       <Generator>MSBuild:Compile</Generator>
@@ -163,4 +168,13 @@
   <ItemGroup>
     <AdditionalFiles Include="Assets\locales.json" />
   </ItemGroup>
+  <ItemGroup>
+    <Compile Update="Utilities\Compat\CompatibilityList.axaml.cs">
+      <DependentUpon>CompatibilityList.axaml</DependentUpon>
+      <SubType>Code</SubType>
+    </Compile>
+  </ItemGroup>
+  <ItemGroup>
+    <Folder Include="Assets\Fonts\Mono\" />
+  </ItemGroup>
 </Project>

+ 3 - 0
src/Ryujinx/RyujinxApp.axaml

@@ -6,6 +6,9 @@
     <Application.Resources>
         <ResourceDictionary>
             <ResourceDictionary.MergedDictionaries>
+                <ResourceDictionary>
+                    <FontFamily x:Key="JetBrainsMono">avares://Ryujinx/Assets/Fonts/Mono/#JetBrains Mono</FontFamily>
+                </ResourceDictionary>
                 <MergeResourceInclude Source="/Assets/Styles/Themes.xaml"/>
             </ResourceDictionary.MergedDictionaries>
         </ResourceDictionary>

+ 28 - 0
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<PlayabilityStatusConverter> _shared = new(() => new());
+        public static PlayabilityStatusConverter Shared => _shared.Value;
+
+        public object Convert(object? value, Type _, object? __, CultureInfo ___) =>
+            value.Cast<LocaleKeys>() 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();
+    }
+}

+ 4 - 0
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}" />
+                <MenuItem
+                    Click="OpenCompatibilityList"
+                    Header="{ext:Locale CompatibilityListOpen}"
+                    Icon="{ext:Icon mdi-gamepad}"/>
                 <Separator />
                 <MenuItem VerticalAlignment="Center" Header="{ext:Locale MenuBarHelpFaqAndGuides}" Icon="{ext:Icon fa-solid fa-question}" >
                     <MenuItem

+ 3 - 0
src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs

@@ -8,6 +8,7 @@ using Ryujinx.Ava.UI.Helpers;
 using Ryujinx.Ava.UI.ViewModels;
 using Ryujinx.Ava.UI.Windows;
 using Ryujinx.Ava.Utilities;
+using Ryujinx.Ava.Utilities.Compat;
 using Ryujinx.Ava.Utilities.Configuration;
 using Ryujinx.Common;
 using Ryujinx.Common.Helper;
@@ -225,5 +226,7 @@ namespace Ryujinx.Ava.UI.Views.Main
         public async void OpenAboutWindow(object sender, RoutedEventArgs e) => await AboutWindow.Show();
 
         public void CloseWindow(object sender, RoutedEventArgs e) => Window.Close();
+
+        private async void OpenCompatibilityList(object sender, RoutedEventArgs e) => await CompatibilityList.Show();
     }
 }

+ 152 - 0
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<CompatibilityEntry>();
+
+            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<int>();
+
+            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<DateTime>(out var dt))
+                LastEvent = dt;
+
+            if (row[header.IndexOf("events_count")].TryParse<int>(out var eventsCount))
+                EventCount = eventsCount;
+        }
+
+        public int IssueNumber { get; }
+        public string GameName { get; }
+        public Optional<string> 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}";
+        }
+    }
+}

+ 32 - 0
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<SepReader> 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());
+        }
+    }
+}

+ 59 - 0
src/Ryujinx/Utilities/Compat/CompatibilityList.axaml

@@ -0,0 +1,59 @@
+<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:local="using:Ryujinx.Ava.Utilities.Compat"
+             xmlns:helpers="using:Ryujinx.Ava.UI.Helpers"
+             xmlns:ext="using:Ryujinx.Ava.Common.Markup"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             x:Class="Ryujinx.Ava.Utilities.Compat.CompatibilityList"
+             x:DataType="local:CompatibilityViewModel">
+    <UserControl.DataContext>
+        <local:CompatibilityViewModel />
+    </UserControl.DataContext>
+    <StackPanel Orientation="Vertical">
+        <StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
+            <TextBox Watermark="{ext:Locale CompatibilityListSearchBoxWatermark}" TextChanged="TextBox_OnTextChanged" />
+            <CheckBox Margin="7, 0, 0, 0" IsChecked="{Binding OnlyShowOwnedGames}" />
+            <TextBlock Margin="-3, 0, 0, 0" Text="{ext:Locale CompatibilityListOnlyShowOwnedGames}" />
+        </StackPanel>
+        <ScrollViewer>
+                <ListBox Margin="5"
+                         Padding="10"
+                         Background="Transparent"
+                         ItemsSource="{Binding CurrentEntries}">
+                    <ListBox.ItemTemplate>
+                        <DataTemplate DataType="{x:Type local:CompatibilityEntry}">
+                            <Grid HorizontalAlignment="Center" Width="500" ColumnDefinitions="Auto,Auto,Auto,*"
+                                  Margin="5">
+                                <TextBlock Grid.Column="0"
+                                           FontFamily="{StaticResource JetBrainsMono}"
+                                           Text="{Binding GameName}"
+                                           Width="140"
+                                           TextWrapping="Wrap" />
+                                <TextBlock Grid.Column="1"
+                                           Width="135"
+                                           Padding="7, 0, 0, 0"
+                                           FontFamily="{StaticResource JetBrainsMono}"
+                                           Text="{Binding FormattedTitleId}"
+                                           TextWrapping="Wrap" />
+                                <TextBlock Grid.Column="2"
+                                           Padding="7, 0"
+                                           VerticalAlignment="Center"
+                                           FontFamily="{StaticResource JetBrainsMono}"
+                                           Text="{Binding LocalizedStatus}"
+                                           Width="85"
+                                           Foreground="{Binding Status, Converter={x:Static helpers:PlayabilityStatusConverter.Shared}}"
+                                           TextWrapping="NoWrap" />
+                                <TextBlock Grid.Column="3"
+                                           VerticalAlignment="Center"
+                                           FontFamily="{StaticResource JetBrainsMono}"
+                                           Text="{Binding FormattedIssueLabels}"
+                                           TextWrapping="WrapWithOverflow" />
+                            </Grid>
+                        </DataTemplate>
+                    </ListBox.ItemTemplate>
+                </ListBox>
+        </ScrollViewer>
+    </StackPanel>
+</UserControl>

+ 56 - 0
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);
+        }
+    }
+}
+

+ 59 - 0
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<CompatibilityEntry> _currentEntries = CompatibilityCsv.Shared.Entries;
+        private readonly string[] _ownedGameTitleIds = [];
+        private readonly ApplicationLibrary _appLibrary;
+
+        public IEnumerable<CompatibilityEntry> 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<CompatibilityEntry> entries)
+        {
+#pragma warning disable MVVMTK0034
+            _currentEntries = entries.ToList();
+#pragma warning restore MVVMTK0034
+            OnPropertyChanged(nameof(CurrentEntries));
+        }
+    }
+}