Merge branch 'allow-change-icon' into 'master'

feat: add ability to change app icon

See merge request [ryubing/ryujinx!128](https://git.ryujinx.app/ryubing/ryujinx/-/merge_requests/128)
This commit is contained in:
VewDev 2025-12-13 22:47:01 -06:00
commit 42d8b9bf84
20 changed files with 343 additions and 12 deletions

View File

@ -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": {

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 890 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -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);
}
}
}
}

View File

@ -164,6 +164,7 @@
<EmbeddedResource Include="Assets\Icons\Controller_JoyConPair.svg" />
<EmbeddedResource Include="Assets\Icons\Controller_JoyConRight.svg" />
<EmbeddedResource Include="Assets\Icons\Controller_ProCon.svg" />
<EmbeddedResource Include="Assets\Icons\AppIcons\*" />
<EmbeddedResource Include="Assets\UIImages\Icon_NCA.png" />
<EmbeddedResource Include="Assets\UIImages\Icon_NRO.png" />
<EmbeddedResource Include="Assets\UIImages\Icon_NSO.png" />

View File

@ -356,6 +356,11 @@ namespace Ryujinx.Ava.Systems.Configuration
/// </summary>
public string BaseStyle { get; set; }
/// <summary>
/// The name of the currently selected window icon
/// </summary>
public string SelectedWindowIcon { get; set; }
/// <summary>
/// Chooses the view mode of the game list // Not Used
/// </summary>

View File

@ -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;

View File

@ -151,6 +151,11 @@ namespace Ryujinx.Ava.Systems.Configuration
/// </summary>
public ReactiveObject<string> BaseStyle { get; private set; }
/// <summary>
/// The currently selected window icon.
/// </summary>
public ReactiveObject<string> SelectedWindowIcon { get; private set; }
/// <summary>
/// Start games in fullscreen mode
/// </summary>
@ -200,6 +205,7 @@ namespace Ryujinx.Ava.Systems.Configuration
ShownFileTypes = new ShownFileTypeSettings();
WindowStartup = new WindowStartupSettings();
BaseStyle = new ReactiveObject<string>();
SelectedWindowIcon = new ReactiveObject<string>();
StartFullscreen = new ReactiveObject<bool>();
StartNoUI = new ReactiveObject<bool>();
GameListViewMode = new ReactiveObject<int>();

View File

@ -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,

View File

@ -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<Bitmap> 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<Bitmap> 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<string> 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;
}
}
}

View File

@ -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<ApplicationIcon> AvailableApplicationIcons { get; set; } = [];
private static void RetrieveAvailableAppIcons()
{
AvailableApplicationIcons.Clear();
string resourceAssemblyPrefix = "Ryujinx.Assets.Icons.AppIcons.";
IEnumerable<string> 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
});
}
}
}
}

View File

@ -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<ApplicationIcon> _appIcons = [];
public AvaloniaList<ApplicationIcon> ApplicationIcons
{
get => _appIcons;
set
{
_appIcons = value;
OnPropertyChanged();
}
}
private int _appIconSelectedIndex;
public int AppIconSelectedIndex
{
get => _appIconSelectedIndex;
set
{
_appIconSelectedIndex = value;
OnPropertyChanged();
}
}
}
}

View File

@ -141,6 +141,32 @@
</ComboBox>
<TextBlock Classes="globalConfigMarker" IsVisible="{Binding IsGameTitleNotNull}" />
</StackPanel>
<StackPanel
IsEnabled="{Binding !IsGameTitleNotNull}"
Opacity="{Binding PanelOpacity}"
Margin="0, 15, 0, 10"
ToolTip.Tip="{ext:Locale SettingsTabGeneralIconTooltip}"
Orientation="Horizontal">
<TextBlock
VerticalAlignment="Center"
Text="{ext:Locale SettingsTabGeneralIcon}"
Width="150" />
<ComboBox
MinWidth="100"
HorizontalAlignment="Left"
ItemsSource="{Binding ApplicationIcons}"
SelectedIndex="{Binding AppIconSelectedIndex}">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" >
<Image Source="{Binding Icon}" Width="24" Height="24" Margin="0,0,8,0" />
<TextBlock Text="{Binding Name}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="globalConfigMarker" IsVisible="{Binding IsGameTitleNotNull}" />
</StackPanel>
</StackPanel>
</StackPanel>
<Border Grid.Column="1"

View File

@ -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<Bitmap> 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<Bitmap> rArgs) => UpdateIcon(rArgs.NewValue);
private void UpdateIcon(Bitmap newIcon)
{
Icon = new WindowIcon(newIcon);
}
}
}