[GUI] few stability patches for GUI (#732)

This commit is contained in:
Berk
2026-08-01 15:05:13 +03:00
committed by GitHub
parent 7c9740fee8
commit c387b969e1
5 changed files with 256 additions and 8 deletions
+168
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<string> Folders { get; set; } = [];
public List<CachedGame> Games { get; set; } = [];
}
/// <summary>
/// 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.
/// </summary>
internal static List<GameEntry> Load(IReadOnlyList<string> folders)
{
var games = new List<GameEntry>();
try
{
if (!File.Exists(CachePath))
{
return games;
}
var document = JsonSerializer.Deserialize<CacheDocument>(
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<string> folders, IReadOnlyList<GameEntry> 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<string> cached,
IReadOnlyList<string> configured)
{
if (cached.Count != configured.Count)
{
return false;
}
var known = new HashSet<string>(cached, GameLibraryPath.Comparer);
foreach (var folder in configured)
{
if (!known.Contains(folder))
{
return false;
}
}
return true;
}
}
@@ -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,
+29 -6
View File
@@ -154,7 +154,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</StackPanel>
</Grid>
<Panel Grid.Row="1" x:Name="PagesHost">
<Panel Grid.Row="1" x:Name="PagesHost" ZIndex="1">
<!-- Library page. Covers use a horizontal virtualized rail so the
number of realized images stays bounded for large libraries. -->
@@ -311,6 +311,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
Duration="0:0:0.18" />
</Transitions>
</StackPanel.Transitions>
<Border Classes="selectedDetailsDivider" />
<TextBlock Classes="selectedGameTitle"
Text="{Binding Name}"
FontWeight="SemiBold"
@@ -449,7 +450,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Border>
<Border Padding="260,18,32,24">
<Grid ColumnDefinitions="*,Auto"
<Grid ColumnDefinitions="*,Auto,Auto"
ColumnSpacing="34">
<StackPanel Spacing="3"
VerticalAlignment="Center">
@@ -500,6 +501,20 @@ SPDX-License-Identifier: GPL-2.0-or-later
Text="{Binding TitleId}" />
</StackPanel>
</StackPanel>
<Button x:Name="GameOptionsCloseButton"
Grid.Column="2"
Classes="optionsCircle compact"
VerticalAlignment="Center"
ToolTip.Tip="{Binding [Common.Back],
Source={x:Static local:Localization.Instance},
x:CompileBindings=False}"
AutomationProperties.Name="{Binding [Common.Back],
Source={x:Static local:Localization.Instance},
x:CompileBindings=False}">
<TextBlock Classes="materialSymbol"
Text="close" />
</Button>
</Grid>
</Border>
@@ -511,10 +526,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
RowDefinitions="Auto,*">
<Button x:Name="GameOptionsLaunchButton"
Classes="gameOptionsLaunch">
<TextBlock VerticalAlignment="Center"
Text="{Binding [Library.Context.Launch],
Source={x:Static local:Localization.Instance},
x:CompileBindings=False}" />
<StackPanel Orientation="Horizontal"
Spacing="8"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<TextBlock Classes="materialSymbol"
VerticalAlignment="Center"
Text="play_arrow" />
<TextBlock VerticalAlignment="Center"
Text="{Binding [Library.Context.Launch],
Source={x:Static local:Localization.Instance},
x:CompileBindings=False}" />
</StackPanel>
</Button>
<ScrollViewer x:Name="GameOptionsNavScroll"
+30 -2
View File
@@ -36,7 +36,7 @@ public partial class MainWindow : Window
private const int MaxConsoleLines = 4000;
private const int MaxConsoleLinesPerFlush = 500;
private static readonly TimeSpan NavigationIndicatorAnimationDuration =
TimeSpan.FromMilliseconds(240);
TimeSpan.FromMilliseconds(180);
private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE"));
private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488"));
@@ -555,7 +555,7 @@ public partial class MainWindow : Window
translationAnimation.InsertExpressionKeyFrame(
1f,
"this.FinalValue",
new SineEaseInOut());
new CubicEaseOut());
var animations = visual.Compositor.CreateImplicitAnimationCollection();
animations[nameof(CompositionVisual.Translation)] = translationAnimation;
@@ -848,6 +848,8 @@ public partial class MainWindow : Window
{
_ = CheckForUpdatesAsync();
}
SeedLibraryFromCache();
await RescanLibraryAsync();
}
@@ -1547,6 +1549,31 @@ public partial class MainWindow : Window
}
}
/// <summary>
/// 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.
/// </summary>
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<GameEntry>(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);
}
/// <summary>
@@ -103,6 +103,12 @@ Contextual per-game options reveal, actions, and selected-game motion.
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="Button.optionsCircle.compact, ToggleButton.optionsCircle.compact">
<Setter Property="Width" Value="40" />
<Setter Property="Height" Value="40" />
<Setter Property="CornerRadius" Value="20" />
</Style>
<Style Selector="StackPanel.selectedDetailsHost">
<Setter Property="Spacing" Value="18" />
</Style>
@@ -119,6 +125,21 @@ Contextual per-game options reveal, actions, and selected-game motion.
<Setter Property="FontSize" Value="26" />
</Style>
<Style Selector="Border.selectedDetailsDivider">
<Setter Property="Height" Value="1" />
<Setter Property="MinWidth" Value="440" />
<Setter Property="Margin" Value="0,0,0,18" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="Background">
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,0%">
<GradientStop Offset="0" Color="#00FFFFFF" />
<GradientStop Offset="0.06" Color="#2EFFFFFF" />
<GradientStop Offset="0.55" Color="#14FFFFFF" />
<GradientStop Offset="1" Color="#00FFFFFF" />
</LinearGradientBrush>
</Setter>
</Style>
<Style Selector="StackPanel.selectedDetailsHost.gridLayout Button.playButton">
<Setter Property="MinHeight" Value="44" />
<Setter Property="MinWidth" Value="168" />