diff --git a/assets/locales.json b/assets/locales.json index 8899bf692..a311a52a2 100644 --- a/assets/locales.json +++ b/assets/locales.json @@ -12067,6 +12067,56 @@ "zh_TW": "淺色" } }, + { + "ID": "SettingsTabGeneralIcon", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Application icon:", + "es_ES": "Icono de aplicación:", + "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": "SettingsTabGeneralIconTooltip", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "An app restart may be required for the app icon to display properly across Ryujinx.", + "es_ES": "Podría ser necesario reiniciar la aplicación para que el icono se muestre correctamente en todo Ryujinx.", + "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": "ControllerSettingsConfigureGeneral", "Translations": { diff --git a/src/Ryujinx/Assets/Icons/AppIcons/Bordered Ryugay.svg b/src/Ryujinx/Assets/Icons/AppIcons/Bordered Ryugay.svg new file mode 100644 index 000000000..847d617fa --- /dev/null +++ b/src/Ryujinx/Assets/Icons/AppIcons/Bordered Ryugay.svg @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/src/Ryujinx/Assets/Icons/AppIcons/Bordered Ryupride.svg b/src/Ryujinx/Assets/Icons/AppIcons/Bordered Ryupride.svg new file mode 100644 index 000000000..00bba42f0 --- /dev/null +++ b/src/Ryujinx/Assets/Icons/AppIcons/Bordered Ryupride.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Ryujinx/Assets/Icons/AppIcons/Ryubi.png b/src/Ryujinx/Assets/Icons/AppIcons/Ryubi.png new file mode 100644 index 000000000..5744a057f Binary files /dev/null and b/src/Ryujinx/Assets/Icons/AppIcons/Ryubi.png differ diff --git a/src/Ryujinx/Assets/Icons/AppIcons/Ryufluid.webp b/src/Ryujinx/Assets/Icons/AppIcons/Ryufluid.webp new file mode 100644 index 000000000..cd34ac32f Binary files /dev/null and b/src/Ryujinx/Assets/Icons/AppIcons/Ryufluid.webp differ diff --git a/src/Ryujinx/Assets/Icons/AppIcons/Ryulesbian.webp b/src/Ryujinx/Assets/Icons/AppIcons/Ryulesbian.webp new file mode 100644 index 000000000..253773ab2 Binary files /dev/null and b/src/Ryujinx/Assets/Icons/AppIcons/Ryulesbian.webp differ diff --git a/src/Ryujinx/Assets/Icons/AppIcons/Ryupan.webp b/src/Ryujinx/Assets/Icons/AppIcons/Ryupan.webp new file mode 100644 index 000000000..5fb55aae5 Binary files /dev/null and b/src/Ryujinx/Assets/Icons/AppIcons/Ryupan.webp differ diff --git a/src/Ryujinx/Assets/Icons/AppIcons/Ryupride.webp b/src/Ryujinx/Assets/Icons/AppIcons/Ryupride.webp new file mode 100644 index 000000000..64a6748e6 Binary files /dev/null and b/src/Ryujinx/Assets/Icons/AppIcons/Ryupride.webp differ diff --git a/src/Ryujinx/Assets/Icons/AppIcons/Ryutrans.webp b/src/Ryujinx/Assets/Icons/AppIcons/Ryutrans.webp new file mode 100644 index 000000000..e6800d3ec Binary files /dev/null and b/src/Ryujinx/Assets/Icons/AppIcons/Ryutrans.webp differ diff --git a/src/Ryujinx/Common/Models/ApplicationIcon.cs b/src/Ryujinx/Common/Models/ApplicationIcon.cs new file mode 100644 index 000000000..95e0fb07f --- /dev/null +++ b/src/Ryujinx/Common/Models/ApplicationIcon.cs @@ -0,0 +1,23 @@ +using Avalonia.Media.Imaging; +using Ryujinx.Ava.UI.Controls; + +namespace Ryujinx.Ava.Common.Models +{ + public class ApplicationIcon + { + public string Name { get; set; } + public string Filename { get; set; } + public string FullPath + { + get => $"Ryujinx/Assets/Icons/AppIcons/{Filename}"; + } + + public Bitmap Icon + { + get + { + return RyujinxLogo.GetBitmapForLogo(this); + } + } + } +} \ No newline at end of file diff --git a/src/Ryujinx/Ryujinx.csproj b/src/Ryujinx/Ryujinx.csproj index 31dc20aac..f0c1b8377 100644 --- a/src/Ryujinx/Ryujinx.csproj +++ b/src/Ryujinx/Ryujinx.csproj @@ -164,6 +164,7 @@ + diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs b/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs index f0fafb4e0..0ccc5e5d1 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationFileFormat.cs @@ -356,6 +356,11 @@ namespace Ryujinx.Ava.Systems.Configuration /// public string BaseStyle { get; set; } + /// + /// The name of the currently selected window icon + /// + public string SelectedWindowIcon { get; set; } + /// /// Chooses the view mode of the game list // Not Used /// diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs index 163b7e98f..9f68820c0 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.Migration.cs @@ -134,6 +134,7 @@ namespace Ryujinx.Ava.Systems.Configuration UI.ShownFileTypes.NSO.Value = shouldLoadFromFile ? cff.ShownFileTypes.NSO : UI.ShownFileTypes.NSO.Value; UI.LanguageCode.Value = shouldLoadFromFile ? cff.LanguageCode : UI.LanguageCode.Value; UI.BaseStyle.Value = shouldLoadFromFile ? cff.BaseStyle : UI.BaseStyle.Value; + UI.SelectedWindowIcon.Value = shouldLoadFromFile ? cff.SelectedWindowIcon : UI.SelectedWindowIcon.Value; UI.GameListViewMode.Value = shouldLoadFromFile ? cff.GameListViewMode : UI.GameListViewMode.Value; UI.ShowNames.Value = shouldLoadFromFile ? cff.ShowNames : UI.ShowNames.Value; UI.IsAscendingOrder.Value = shouldLoadFromFile ? cff.IsAscendingOrder : UI.IsAscendingOrder.Value; diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs index 2b4c8f991..de5846718 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs @@ -151,6 +151,11 @@ namespace Ryujinx.Ava.Systems.Configuration /// public ReactiveObject BaseStyle { get; private set; } + /// + /// The currently selected window icon. + /// + public ReactiveObject SelectedWindowIcon { get; private set; } + /// /// Start games in fullscreen mode /// @@ -200,6 +205,7 @@ namespace Ryujinx.Ava.Systems.Configuration ShownFileTypes = new ShownFileTypeSettings(); WindowStartup = new WindowStartupSettings(); BaseStyle = new ReactiveObject(); + SelectedWindowIcon = new ReactiveObject(); StartFullscreen = new ReactiveObject(); StartNoUI = new ReactiveObject(); GameListViewMode = new ReactiveObject(); diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.cs index 0e2f6aaec..8b4ded14c 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationState.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.cs @@ -126,6 +126,7 @@ namespace Ryujinx.Ava.Systems.Configuration }, LanguageCode = UI.LanguageCode, BaseStyle = UI.BaseStyle, + SelectedWindowIcon = UI.SelectedWindowIcon, GameListViewMode = UI.GameListViewMode, ShowNames = UI.ShowNames, GridSize = UI.GridSize, diff --git a/src/Ryujinx/UI/Controls/RyujinxLogo.cs b/src/Ryujinx/UI/Controls/RyujinxLogo.cs index e1908fc2b..37d7bed04 100644 --- a/src/Ryujinx/UI/Controls/RyujinxLogo.cs +++ b/src/Ryujinx/UI/Controls/RyujinxLogo.cs @@ -1,28 +1,146 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Media.Imaging; +using Ryujinx.Ava.Common.Models; using Ryujinx.Ava.Systems.Configuration; -using Ryujinx.Ava.UI.ViewModels; -using System.Reflection; +using Ryujinx.Common; +using SkiaSharp; +using Svg.Skia; +using System; +using System.IO; +using System.Linq; namespace Ryujinx.Ava.UI.Controls { public class RyujinxLogo : Image { - // The UI specifically uses a thicker bordered variant of the icon to avoid crunching out the border at lower resolutions. - // For an example of this, download canary 1.2.95, then open the settings menu, and look at the icon in the top-left. - // The border gets reduced to colored pixels in the 4 corners. - public static readonly Bitmap Bitmap = - new(Assembly.GetAssembly(typeof(MainWindowViewModel))! - .GetManifestResourceStream("Ryujinx.Assets.UIImages.Logo_Ryujinx_AntiAlias.png")!); + public static ReactiveObject CurrentLogoBitmap { get; private set; } = new(); public RyujinxLogo() { Margin = new Thickness(7, 7, 7, 0); Height = 25; Width = 25; - Source = Bitmap; + Source = CurrentLogoBitmap.Value; IsVisible = !ConfigurationState.Instance.ShowOldUI; + ConfigurationState.Instance.UI.SelectedWindowIcon.Event += WindowIconChanged_Event; + CurrentLogoBitmap.Event += CurrentLogoBitmapChanged_Event; + } + + private void CurrentLogoBitmapChanged_Event(object _, ReactiveEventArgs e) + { + Source = e.NewValue; + } + + public static void RefreshAppIconFromSettings() + { + SetNewAppIcon(ConfigurationState.Instance.UI.SelectedWindowIcon.Value); + } + + private static void SetNewAppIcon(string newIconName) + { + string defaultIconName = "Bordered Ryupride"; + if (string.IsNullOrEmpty(newIconName)) + { + SetDefaultAppIcon(defaultIconName); + } + + ApplicationIcon selectedIcon = RyujinxApp.AvailableApplicationIcons.FirstOrDefault(x => x.Name == newIconName); + if (selectedIcon == null) + { + // Always try to fallback to "Bordered Ryupride" as a default + // If not found, fallback to first found icon + if (newIconName != defaultIconName) + { + SetDefaultAppIcon(defaultIconName); + return; + } + + if (RyujinxApp.AvailableApplicationIcons.Count > 0) + { + SetDefaultAppIcon(RyujinxApp.AvailableApplicationIcons.First().Name); + return; + } + } + + Stream activeIconStream = EmbeddedResources.GetStream(selectedIcon.FullPath); + if (activeIconStream != null) + { + Bitmap logoBitmap = GetBitmapForLogo(selectedIcon); + if (logoBitmap != null) + { + CurrentLogoBitmap.Value = logoBitmap; + } + } + } + + private static void SetDefaultAppIcon(string defaultIconName) + { + // Doing this triggers the WindowIconChanged_Event, which will then + // call SetNewAppIcon again + ConfigurationState.Instance.UI.SelectedWindowIcon.Value = defaultIconName; + } + + private void WindowIconChanged_Event(object _, ReactiveEventArgs rArgs) => SetNewAppIcon(rArgs.NewValue); + + public static Bitmap GetBitmapForLogo(ApplicationIcon icon) + { + Stream activeIconStream = EmbeddedResources.GetStream(icon.FullPath); + if (activeIconStream == null) + return null; + + // SVG files need to be converted to an image first + if (icon.FullPath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)) + { + Stream pngStream = ConvertSvgToPng(activeIconStream); + return new Bitmap(pngStream); + } + else + { + return new Bitmap(activeIconStream); + } + } + + private static Stream ConvertSvgToPng(Stream svgStream) + { + int width = 256; + int height = 256; + + // Load SVG + var svg = new SKSvg(); + svg.Load(svgStream); + + // Determine size + var picture = svg.Picture; + if (picture == null) + throw new InvalidOperationException("Invalid SVG data"); + + var picWidth = width > 0 ? width : (int)svg.Picture.CullRect.Width; + var picHeight = height > 0 ? height : (int)svg.Picture.CullRect.Height; + + // Create bitmap + using var bitmap = new SKBitmap(picWidth, picHeight); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Transparent); + + // Scale to fit + float scaleX = (float)picWidth / svg.Picture.CullRect.Width; + float scaleY = (float)picHeight / svg.Picture.CullRect.Height; + canvas.Scale(scaleX, scaleY); + + canvas.DrawPicture(svg.Picture); + canvas.Flush(); + + // Encode PNG into memory stream + var outputStream = new MemoryStream(); + using (var image = SKImage.FromBitmap(bitmap)) + using (var data = image.Encode(SKEncodedImageFormat.Png, 100)) + { + data.SaveTo(outputStream); + } + + outputStream.Position = 0; + return outputStream; } } } diff --git a/src/Ryujinx/UI/RyujinxApp.axaml.cs b/src/Ryujinx/UI/RyujinxApp.axaml.cs index 34c2d96ca..714f7703a 100644 --- a/src/Ryujinx/UI/RyujinxApp.axaml.cs +++ b/src/Ryujinx/UI/RyujinxApp.axaml.cs @@ -8,7 +8,9 @@ using Avalonia.Threading; using FluentAvalonia.UI.Windowing; using Gommon; using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.Common.Models; using Ryujinx.Ava.Systems.Configuration; +using Ryujinx.Ava.UI.Controls; using Ryujinx.Ava.UI.Helpers; using Ryujinx.Ava.UI.Views.Dialog; using Ryujinx.Ava.UI.Windows; @@ -16,7 +18,10 @@ using Ryujinx.Ava.Utilities; using Ryujinx.Common; using Ryujinx.Common.Logging; using System; +using System.Collections.Generic; using System.Diagnostics; +using System.IO; +using System.Linq; namespace Ryujinx.Ava { @@ -52,6 +57,9 @@ namespace Ryujinx.Ava { Name = FormatTitle(); + RetrieveAvailableAppIcons(); + RyujinxLogo.RefreshAppIconFromSettings(); + AvaloniaXamlLoader.Load(this); if (OperatingSystem.IsMacOS()) @@ -72,7 +80,6 @@ namespace Ryujinx.Ava if (Program.PreviewerDetached) { ApplyConfiguredTheme(ConfigurationState.Instance.UI.BaseStyle); - ConfigurationState.Instance.UI.BaseStyle.Event += ThemeChanged_Event; } } @@ -151,5 +158,27 @@ namespace Ryujinx.Ava { await AboutView.Show(); } + + public static List AvailableApplicationIcons { get; set; } = []; + private static void RetrieveAvailableAppIcons() + { + AvailableApplicationIcons.Clear(); + string resourceAssemblyPrefix = "Ryujinx.Assets.Icons.AppIcons."; + + IEnumerable availableAppIconResources = EmbeddedResources + .GetAllAvailableResources("Ryujinx/Assets") + .Where(x => x.StartsWith(resourceAssemblyPrefix)); + + foreach (string resource in availableAppIconResources) + { + string filename = resource.Remove(0, resourceAssemblyPrefix.Length); + string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(filename); + AvailableApplicationIcons.Add(new ApplicationIcon() + { + Name = fileNameWithoutExtension, + Filename = filename + }); + } + } } } diff --git a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs index d5d9b8218..3f275b368 100644 --- a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs @@ -9,12 +9,14 @@ using Ryujinx.Audio.Backends.OpenAL; using Ryujinx.Audio.Backends.SDL3; using Ryujinx.Audio.Backends.SoundIo; using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.Common.Models; using Ryujinx.Ava.Systems.Configuration; using Ryujinx.Ava.Systems.Configuration.System; using Ryujinx.Ava.Systems.Configuration.UI; using Ryujinx.Ava.UI.Helpers; using Ryujinx.Ava.UI.Models.Input; using Ryujinx.Ava.UI.Windows; +using Ryujinx.Common; using Ryujinx.Common.Configuration; using Ryujinx.Common.Configuration.Multiplayer; using Ryujinx.Common.GraphicsDriver; @@ -507,6 +509,7 @@ namespace Ryujinx.Ava.UI.ViewModels Task.Run(CheckSoundBackends); Task.Run(PopulateNetworkInterfaces); + ApplicationIcons = new(RyujinxApp.AvailableApplicationIcons); if (Program.PreviewerDetached) { @@ -632,6 +635,7 @@ namespace Ryujinx.Ava.UI.ViewModels HideCursor = (int)config.HideCursor.Value; UpdateCheckerType = (int)config.UpdateCheckerType.Value; FocusLostActionType = (int)config.FocusLostActionType.Value; + AppIconSelectedIndex = _appIcons.ToList().FindIndex(x => x.Name == config.UI.SelectedWindowIcon.Value); GameDirectories.Clear(); GameDirectories.AddRange(config.UI.GameDirs.Value); @@ -750,6 +754,7 @@ namespace Ryujinx.Ava.UI.ViewModels config.FocusLostActionType.Value = (FocusLostType)FocusLostActionType; config.UI.GameDirs.Value = [.. GameDirectories]; config.UI.AutoloadDirs.Value = [.. AutoloadDirectories]; + config.UI.SelectedWindowIcon.Value = _appIcons[_appIconSelectedIndex].Name; config.UI.BaseStyle.Value = BaseStyleIndex switch { @@ -937,5 +942,27 @@ namespace Ryujinx.Ava.UI.ViewModels RevertIfNotSaved(IsCustomConfig, IsGameRunning); CloseWindow?.Invoke(); } + + private AvaloniaList _appIcons = []; + public AvaloniaList ApplicationIcons + { + get => _appIcons; + set + { + _appIcons = value; + OnPropertyChanged(); + } + } + + private int _appIconSelectedIndex; + public int AppIconSelectedIndex + { + get => _appIconSelectedIndex; + set + { + _appIconSelectedIndex = value; + OnPropertyChanged(); + } + } } } diff --git a/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml index e55c20b88..2cd2819b5 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml @@ -140,6 +140,32 @@ Content="{ext:Locale SettingsTabGeneralThemeDark}" /> + + + + + + + + + + + + + + diff --git a/src/Ryujinx/UI/Windows/StyleableWindow.cs b/src/Ryujinx/UI/Windows/StyleableWindow.cs index 066440382..a91b7593c 100644 --- a/src/Ryujinx/UI/Windows/StyleableWindow.cs +++ b/src/Ryujinx/UI/Windows/StyleableWindow.cs @@ -3,11 +3,13 @@ using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Media; +using Avalonia.Media.Imaging; using Avalonia.Platform; using FluentAvalonia.UI.Windowing; using Ryujinx.Ava.Common.Locale; using Ryujinx.Ava.Systems.Configuration; using Ryujinx.Ava.UI.Controls; +using Ryujinx.Common; using System.Threading.Tasks; namespace Ryujinx.Ava.UI.Windows @@ -39,7 +41,8 @@ namespace Ryujinx.Ava.UI.Windows TitleBar.Height = titleBarHeight.Value; } - Icon = RyujinxLogo.Bitmap; + Icon = RyujinxLogo.CurrentLogoBitmap.Value; + RyujinxLogo.CurrentLogoBitmap.Event += WindowIconChanged_Event; } private void LocaleChanged() @@ -53,6 +56,12 @@ namespace Ryujinx.Ava.UI.Windows ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.SystemChrome | ExtendClientAreaChromeHints.OSXThickTitleBar; } + + private void WindowIconChanged_Event(object _, ReactiveEventArgs rArgs) => UpdateIcon(rArgs.NewValue); + private void UpdateIcon(Bitmap newIcon) + { + Icon = newIcon; + } } public abstract class StyleableWindow : Window @@ -73,7 +82,8 @@ namespace Ryujinx.Ava.UI.Windows LocaleManager.Instance.LocaleChanged += LocaleChanged; LocaleChanged(); - Icon = new WindowIcon(RyujinxLogo.Bitmap); + Icon = new WindowIcon(RyujinxLogo.CurrentLogoBitmap.Value); + RyujinxLogo.CurrentLogoBitmap.Event += WindowIconChanged_Event; } private void LocaleChanged() @@ -87,5 +97,11 @@ namespace Ryujinx.Ava.UI.Windows ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.SystemChrome | ExtendClientAreaChromeHints.OSXThickTitleBar; } + + private void WindowIconChanged_Event(object _, ReactiveEventArgs rArgs) => UpdateIcon(rArgs.NewValue); + private void UpdateIcon(Bitmap newIcon) + { + Icon = new WindowIcon(newIcon); + } } }