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. + +