From c387b969e161d8215dabf74cf8df46d49baf0ad9 Mon Sep 17 00:00:00 2001
From: Berk <12572227+par274@users.noreply.github.com>
Date: Sat, 1 Aug 2026 15:05:13 +0300
Subject: [PATCH] [GUI] few stability patches for GUI (#732)
---
src/SharpEmu.GUI/GameLibraryCache.cs | 168 ++++++++++++++++++
src/SharpEmu.GUI/MainWindow.GameOptions.cs | 8 +
src/SharpEmu.GUI/MainWindow.axaml | 35 +++-
src/SharpEmu.GUI/MainWindow.axaml.cs | 32 +++-
.../Themes/Styles/GameOptions.axaml | 21 +++
5 files changed, 256 insertions(+), 8 deletions(-)
create mode 100644 src/SharpEmu.GUI/GameLibraryCache.cs
diff --git a/src/SharpEmu.GUI/GameLibraryCache.cs b/src/SharpEmu.GUI/GameLibraryCache.cs
new file mode 100644
index 00000000..97094134
--- /dev/null
+++ b/src/SharpEmu.GUI/GameLibraryCache.cs
@@ -0,0 +1,168 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Collections.Generic;
+using System.IO;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace SharpEmu.GUI;
+
+///
+/// Remembers the last completed library scan so a cold start can paint the
+/// grid immediately instead of waiting on a recursive walk of every game
+/// folder. The cache is a display seed, never an authority: startup still
+/// runs the normal scan and reconciles over it, so a stale file can only
+/// ever cost one frame of wrong content, not a wrong library.
+///
+internal static class GameLibraryCache
+{
+ private const int CurrentVersion = 1;
+
+ private static readonly JsonSerializerOptions SerializerOptions = new()
+ {
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ internal static string CachePath =>
+ Path.Combine(AppContext.BaseDirectory, "user", "library_cache.json");
+
+ internal sealed class CachedGame
+ {
+ public string Name { get; set; } = string.Empty;
+ public string? TitleId { get; set; }
+ public string? Version { get; set; }
+ public string Path { get; set; } = string.Empty;
+ public long SizeBytes { get; set; }
+ public string? CoverPath { get; set; }
+ public string? BackgroundPath { get; set; }
+ }
+
+ internal sealed class CacheDocument
+ {
+ public int Version { get; set; }
+ public List Folders { get; set; } = [];
+ public List Games { get; set; } = [];
+ }
+
+ ///
+ /// Returns the cached games when the file matches the configured folder
+ /// set and the executables still exist. Entries whose executable is gone
+ /// are dropped so a removed game never flashes on screen.
+ ///
+ internal static List Load(IReadOnlyList folders)
+ {
+ var games = new List();
+ try
+ {
+ if (!File.Exists(CachePath))
+ {
+ return games;
+ }
+
+ var document = JsonSerializer.Deserialize(
+ File.ReadAllText(CachePath),
+ SerializerOptions);
+ if (document is null || document.Version != CurrentVersion)
+ {
+ return games;
+ }
+
+ if (!SameFolders(document.Folders, folders))
+ {
+ return games;
+ }
+
+ foreach (var cached in document.Games)
+ {
+ if (string.IsNullOrWhiteSpace(cached.Path) || !File.Exists(cached.Path))
+ {
+ continue;
+ }
+
+ games.Add(new GameEntry(
+ cached.Name,
+ cached.TitleId,
+ cached.Version,
+ cached.Path,
+ cached.SizeBytes,
+ Existing(cached.CoverPath),
+ Existing(cached.BackgroundPath)));
+ }
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine(
+ $"[GUI][WARN] Could not read the library cache: {exception.Message}");
+ games.Clear();
+ }
+
+ return games;
+ }
+
+ internal static void Save(IReadOnlyList folders, IReadOnlyList games)
+ {
+ try
+ {
+ var directory = Path.GetDirectoryName(CachePath);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ var document = new CacheDocument
+ {
+ Version = CurrentVersion,
+ Folders = [.. folders],
+ };
+
+ foreach (var game in games)
+ {
+ document.Games.Add(new CachedGame
+ {
+ Name = game.Name,
+ TitleId = game.TitleId,
+ Version = game.Version,
+ Path = game.Path,
+ SizeBytes = game.SizeBytes,
+ CoverPath = game.CoverPath,
+ BackgroundPath = game.BackgroundPath,
+ });
+ }
+
+ File.WriteAllText(
+ CachePath,
+ JsonSerializer.Serialize(document, SerializerOptions));
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine(
+ $"[GUI][WARN] Could not write the library cache: {exception.Message}");
+ }
+ }
+
+ private static string? Existing(string? path) =>
+ !string.IsNullOrWhiteSpace(path) && File.Exists(path) ? path : null;
+
+ private static bool SameFolders(
+ IReadOnlyList cached,
+ IReadOnlyList configured)
+ {
+ if (cached.Count != configured.Count)
+ {
+ return false;
+ }
+
+ var known = new HashSet(cached, GameLibraryPath.Comparer);
+ foreach (var folder in configured)
+ {
+ if (!known.Contains(folder))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/SharpEmu.GUI/MainWindow.GameOptions.cs b/src/SharpEmu.GUI/MainWindow.GameOptions.cs
index a2bb69d2..00589720 100644
--- a/src/SharpEmu.GUI/MainWindow.GameOptions.cs
+++ b/src/SharpEmu.GUI/MainWindow.GameOptions.cs
@@ -86,6 +86,7 @@ public partial class MainWindow
CloseGameSettings();
LaunchSelected();
};
+ GameOptionsCloseButton.Click += (_, _) => CloseGameSettings();
GameOptionsOpenFolderButton.Click += (_, _) => OpenSelectedGameFolder();
GameOptionsCopyPathButton.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path);
@@ -140,6 +141,7 @@ public partial class MainWindow
!string.IsNullOrWhiteSpace(game.TitleId);
_isGameSettingsOpen = true;
+ SetGameOptionsPagesSpan(coversConsoleRow: true);
SetGameOptionsOpenClass(BackdropLayer, active: true);
SetGameOptionsOpenClass(CarouselHost, active: true);
SetGameOptionsOpenClass(LibrarySelectedDetails, active: true);
@@ -158,6 +160,7 @@ public partial class MainWindow
}
_isGameSettingsOpen = false;
+ SetGameOptionsPagesSpan(coversConsoleRow: false);
SetGameOptionsNavigationIndicator(_gameOptionsIndicatorIndex, animate: false);
_gameSettingsTitleId = null;
_gameEnvironmentPassthrough.Clear();
@@ -438,6 +441,11 @@ public partial class MainWindow
animate);
}
+ private void SetGameOptionsPagesSpan(bool coversConsoleRow)
+ {
+ Grid.SetRowSpan(PagesHost, coversConsoleRow ? 2 : 1);
+ }
+
private Button[] GameOptionsNavigationButtons() =>
[
GameOptionsGeneralNav,
diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml
index c56abd09..0cad04cb 100644
--- a/src/SharpEmu.GUI/MainWindow.axaml
+++ b/src/SharpEmu.GUI/MainWindow.axaml
@@ -154,7 +154,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
-
+
@@ -311,6 +311,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
Duration="0:0:0.18" />
+
-
@@ -500,6 +501,20 @@ SPDX-License-Identifier: GPL-2.0-or-later
Text="{Binding TitleId}" />
+
+
@@ -511,10 +526,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
RowDefinitions="Auto,*">
+ /// Paints the previous scan's result before the real scan starts. The
+ /// scan that follows reconciles over this, so the cache only ever
+ /// shortens the blank period; it never decides what the library holds.
+ ///
+ private void SeedLibraryFromCache()
+ {
+ Dispatcher.UIThread.VerifyAccess();
+
+ if (_allGames.Count != 0)
+ {
+ return;
+ }
+
+ var cached = GameLibraryCache.Load(_settings.GameFolders.ToArray());
+ if (cached.Count == 0)
+ {
+ return;
+ }
+
+ _allGames.AddRange(cached);
+ RefreshVisibleGames(new HashSet(cached));
+ LoadGameDetailsInBackground(cached, cached);
+ }
+
private async Task RescanLibraryAsync(bool showProgress = true)
{
Dispatcher.UIThread.VerifyAccess();
@@ -1576,6 +1603,7 @@ public partial class MainWindow : Window
LoadingState.IsVisible = false;
LoadGameDetailsInBackground(reconciliation.CoversToLoad, reconciliation.Games);
UpdateDiscordPresence();
+ GameLibraryCache.Save(folders, reconciliation.Games);
}
///
diff --git a/src/SharpEmu.GUI/Themes/Styles/GameOptions.axaml b/src/SharpEmu.GUI/Themes/Styles/GameOptions.axaml
index 41ac9ae8..b3afcb97 100644
--- a/src/SharpEmu.GUI/Themes/Styles/GameOptions.axaml
+++ b/src/SharpEmu.GUI/Themes/Styles/GameOptions.axaml
@@ -103,6 +103,12 @@ Contextual per-game options reveal, actions, and selected-game motion.
+
+
@@ -119,6 +125,21 @@ Contextual per-game options reveal, actions, and selected-game motion.
+
+