[GUI] Add inline per-game settings and unify options styling (#728)

* [GUI] Add inline per-game settings and unify options styling

* [GUI] Update options styling & add scrollable area

* [GUI] Remove focus and pointover styles for options
This commit is contained in:
Daniel Freak
2026-07-31 12:14:21 +03:00
committed by GitHub
parent 97bd8c422e
commit a7ec3d5a77
28 changed files with 1782 additions and 772 deletions
+97 -53
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using Avalonia;
using Avalonia.Animation.Easings;
using Avalonia.Automation;
using Avalonia.Collections;
using Avalonia.Controls;
@@ -12,6 +13,7 @@ using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Rendering.Composition;
using Avalonia.Threading;
using Avalonia.VisualTree;
using SharpEmu.Core.Cpu;
@@ -33,6 +35,8 @@ public partial class MainWindow : Window
{
private const int MaxConsoleLines = 4000;
private const int MaxConsoleLinesPerFlush = 500;
private static readonly TimeSpan NavigationIndicatorAnimationDuration =
TimeSpan.FromMilliseconds(240);
private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE"));
private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488"));
@@ -217,6 +221,7 @@ public partial class MainWindow : Window
OptionsTabButton.Click += (_, _) => SetActivePage(1);
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
WireOptionsNavigation();
WireGameOptions();
// The settings page edits _settings live, so a launch started while
// it is open already uses the new values.
@@ -288,9 +293,9 @@ public partial class MainWindow : Window
CtxLaunch.Click += (_, _) => LaunchSelected();
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
CtxCopyPath.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Clipboard.Path");
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path);
CtxCopyTitleId.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Clipboard.TitleId");
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId);
CtxGameSettings.Click += (_, _) => OpenSelectedGameSettings();
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
@@ -340,6 +345,11 @@ public partial class MainWindow : Window
{
if (index == _activePageIndex)
{
if (index == 0 && _isGameSettingsOpen)
{
CloseGameSettings();
}
return;
}
@@ -348,12 +358,25 @@ public partial class MainWindow : Window
_settings.Save(); // leaving the Options page
}
if (_isGameSettingsOpen)
{
CloseGameSettings(restoreLibrary: false);
}
_activePageIndex = index;
SetActiveClass(LibraryTabButton, index == 0);
SetActiveClass(OptionsTabButton, index == 1);
LibraryPage.IsVisible = index == 0;
LibraryToolbar.IsVisible = index == 0;
OptionsPageSurface.IsVisible = index == 1;
OptionsPage.IsVisible = index == 1;
if (index == 1)
{
Dispatcher.UIThread.Post(
() => SetOptionsNavigationIndicator(_optionsSectionIndex, animate: false),
DispatcherPriority.Loaded);
}
}
private static void SetActiveClass(Button button, bool active)
@@ -446,17 +469,69 @@ public partial class MainWindow : Window
active ? KeyboardNavigationMode.Continue : KeyboardNavigationMode.None);
}
private void SetOptionsNavigationIndicator(int section)
private void SetOptionsNavigationIndicator(int section, bool animate = true)
{
if (OptionsNavIndicator.RenderTransform is not TranslateTransform transform)
var buttons = OptionsNavigationButtons();
var button = buttons[Math.Clamp(section, 0, buttons.Length - 1)];
MoveNavigationIndicator(
OptionsNavIndicator,
OptionsNavHost,
button,
section,
animate);
}
private static void ConfigureNavigationIndicatorAnimation(Border indicator)
{
if (ElementComposition.GetElementVisual(indicator) is not { } visual)
{
return;
}
var buttons = OptionsNavigationButtons();
var button = buttons[Math.Clamp(section, 0, buttons.Length - 1)];
transform.Y = button.TranslatePoint(default, OptionsNavHost)?.Y
var translationAnimation = visual.Compositor.CreateVector3KeyFrameAnimation();
translationAnimation.Duration = NavigationIndicatorAnimationDuration;
translationAnimation.Target = nameof(CompositionVisual.Translation);
translationAnimation.InsertExpressionKeyFrame(
1f,
"this.FinalValue",
new SineEaseInOut());
var animations = visual.Compositor.CreateImplicitAnimationCollection();
animations[nameof(CompositionVisual.Translation)] = translationAnimation;
visual.ImplicitAnimations = animations;
}
private static void MoveNavigationIndicator(
Border indicator,
Control host,
Button button,
int section,
bool animate = true)
{
if (ElementComposition.GetElementVisual(indicator) is not { } visual)
{
return;
}
var targetY = button.TranslatePoint(default, host)?.Y
?? section * button.Bounds.Height;
if (!animate)
{
visual.ImplicitAnimations = null;
visual.StopAnimation(nameof(CompositionVisual.Translation));
}
else if (visual.ImplicitAnimations is null)
{
ConfigureNavigationIndicatorAnimation(indicator);
}
visual.Translation = new Vector3D(0, targetY, 0);
if (!animate)
{
ConfigureNavigationIndicatorAnimation(indicator);
}
}
// ---- Github http client config ----
@@ -581,7 +656,7 @@ public partial class MainWindow : Window
SetActivePage(1);
}
if (_activePageIndex != 0)
if (_activePageIndex != 0 || _isGameSettingsOpen)
{
_previousPadButtons = pad.Buttons;
return;
@@ -798,6 +873,13 @@ public partial class MainWindow : Window
private void OnKeyDown(object sender, KeyEventArgs args)
{
if (args.Key == Key.Escape && _isGameSettingsOpen)
{
CloseGameSettings();
args.Handled = true;
return;
}
if (args.Key == Key.F11 && !_isRunning)
{
WindowState = WindowState == WindowState.FullScreen
@@ -910,11 +992,6 @@ public partial class MainWindow : Window
titleBar.IsVisible = !isFullscreen;
}
if (StatusBar is { } statusBar)
{
statusBar.IsVisible = !isFullscreen;
}
if (ResizeHandles is { } handles)
{
handles.IsVisible = CanResize && WindowState == WindowState.Normal;
@@ -1302,9 +1379,6 @@ public partial class MainWindow : Window
? Path.GetFullPath(found)
: null;
EmulatorPathText.Text = _emulatorExePath is not null
? Localization.Instance.Format("Status.EmulatorPath", _emulatorExePath)
: Localization.Instance.Get("Status.EmulatorNotFound");
}
// ---- Game library ----
@@ -1379,11 +1453,6 @@ public partial class MainWindow : Window
var excluded = new HashSet<string>(_settings.ExcludedGames, GameLibraryPath.Comparer);
_libraryWatcher.Watch(folders);
var showLoadingState = showProgress && _allGames.Count == 0;
if (showProgress)
{
StatusBarRight.Text = Localization.Instance.Get("Status.ScanningLibrary");
}
if (showLoadingState)
{
EmptyState.IsVisible = false;
@@ -1404,12 +1473,6 @@ public partial class MainWindow : Window
LoadingState.IsVisible = false;
LoadGameDetailsInBackground(reconciliation.CoversToLoad, reconciliation.Games);
UpdateDiscordPresence();
if (showProgress)
{
StatusBarRight.Text = folders.Length == 0
? Localization.Instance.Get("Status.AddFolderPrompt")
: Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length);
}
}
/// <summary>
@@ -1806,24 +1869,6 @@ public partial class MainWindow : Window
CtxGameSettings.IsEnabled = !string.IsNullOrWhiteSpace(game.TitleId);
}
private void OpenSelectedGameSettings()
{
if (GameList.SelectedItem is not GameEntry game)
{
return;
}
if (string.IsNullOrWhiteSpace(game.TitleId))
{
AppendConsoleLine(
"[GUI][WARN] Per-game settings require a title ID, which this game does not have.",
WarningLineBrush);
return;
}
_ = new PerGameSettingsDialog(game.TitleId, game.Name, _settings).ShowDialog(this);
}
private void OpenSelectedGameFolder()
{
if (GameList.SelectedItem is not GameEntry game)
@@ -1854,12 +1899,13 @@ public partial class MainWindow : Window
}
catch (Exception ex)
{
StatusBarRight.Text = Localization.Instance.Format("Status.CouldNotOpenFolder", ex.Message);
AppendConsoleLine(
Localization.Instance.Format("Status.CouldNotOpenFolder", ex.Message),
WarningLineBrush);
}
}
/// <summary>Copies <paramref name="text"/> and reports it via <paramref name="whatKey"/>, e.g. "Clipboard.Path".</summary>
private async Task CopyToClipboardAsync(string? text, string whatKey)
private async Task CopyToClipboardAsync(string? text)
{
if (string.IsNullOrEmpty(text) || Clipboard is null)
{
@@ -1867,7 +1913,6 @@ public partial class MainWindow : Window
}
await Clipboard.SetTextAsync(text);
StatusBarRight.Text = Localization.Instance.Format("Status.CopiedToClipboard", Localization.Instance.Get(whatKey));
}
private void RemoveSelectedFromLibrary()
@@ -1887,7 +1932,6 @@ public partial class MainWindow : Window
string.Equals(g.Path, game.Path, GameLibraryPath.Comparison));
GameList.SelectedItem = null;
RefreshVisibleGames();
StatusBarRight.Text = Localization.Instance.Format("Status.RemovedFromLibrary", game.Name);
}
private void RefreshVisibleGames(IReadOnlySet<GameEntry>? backgroundsChanged = null)
@@ -2197,7 +2241,6 @@ public partial class MainWindow : Window
_runningGameName = displayName;
_runningGameTitleId = resolvedTitleId;
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName);
UpdateRunButtons();
UpdateDiscordPresence();
@@ -2250,7 +2293,6 @@ public partial class MainWindow : Window
_emulator.Stop();
_runningGameName = null;
_runningGameTitleId = null;
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
UpdateDiscordPresence();
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
Console.Error.WriteLine("[GUI][INFO] Waiting for the SDL game process to exit.");
@@ -2317,7 +2359,6 @@ public partial class MainWindow : Window
brush);
CloseFileLogSoon();
StatusBarRight.Text = Localization.Instance.Get("Status.Idle");
_runningGameName = null;
_runningGameTitleId = null;
UpdateRunButtons();
@@ -2470,6 +2511,9 @@ public partial class MainWindow : Window
LaunchButton.IsEnabled = GameList.SelectedItem is GameEntry;
}
GameSettingsButton.IsEnabled =
GameList.SelectedItem is GameEntry game &&
!string.IsNullOrWhiteSpace(game.TitleId);
OpenFileButton.IsEnabled = !_isRunning;
}