Compare commits

...

2 Commits

Author SHA1 Message Date
ParantezTech da36a45bf3 [GUI] few stability patches for GUI 2026-08-01 14:56:19 +03:00
Berk 7c9740fee8 [GUI] Add grid settings to GUI and implement grid snapping (#729) 2026-07-31 13:27:43 +03:00
11 changed files with 505 additions and 54 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;
}
}
+3
View File
@@ -40,6 +40,8 @@ public sealed class GuiSettings
/// <summary>Loop the selected game's sce_sys/snd0.at9 preview music.</summary> /// <summary>Loop the selected game's sce_sys/snd0.at9 preview music.</summary>
public bool PlayTitleMusic { get; set; } = true; public bool PlayTitleMusic { get; set; } = true;
public string LibraryLayout { get; set; } = "Carousel";
public string? EmulatorPath { get; set; } public string? EmulatorPath { get; set; }
/// <summary>UI language, matching a file code under Languages/ (e.g. "en", "tr").</summary> /// <summary>UI language, matching a file code under Languages/ (e.g. "en", "tr").</summary>
@@ -132,6 +134,7 @@ public sealed class GuiSettings
{ {
settings.RenderResolutionScale = 1.0; settings.RenderResolutionScale = 1.0;
} }
settings.LibraryLayout = NormalizeChoice(settings.LibraryLayout, "Carousel", "Grid");
settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive"); settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive");
settings.Resolution = NormalizeResolution(settings.Resolution); settings.Resolution = NormalizeResolution(settings.Resolution);
settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer"); settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
+2
View File
@@ -9,6 +9,8 @@
"Library.SearchWatermark": "Search library…", "Library.SearchWatermark": "Search library…",
"Library.AddFolder": "Add folder", "Library.AddFolder": "Add folder",
"Library.OpenFile": "Open file…", "Library.OpenFile": "Open file…",
"Library.View.Grid": "Stacked view",
"Library.View.Carousel": "Row view",
"Library.Context.Launch": "Launch", "Library.Context.Launch": "Launch",
"Library.Context.OpenFolder": "Open game folder", "Library.Context.OpenFolder": "Open game folder",
+2
View File
@@ -9,6 +9,8 @@
"Library.SearchWatermark": "Kütüphanede ara…", "Library.SearchWatermark": "Kütüphanede ara…",
"Library.AddFolder": "Klasör ekle", "Library.AddFolder": "Klasör ekle",
"Library.OpenFile": "Dosya aç…", "Library.OpenFile": "Dosya aç…",
"Library.View.Grid": "Alt alta görünüm",
"Library.View.Carousel": "Tek sıra görünüm",
"Library.Context.Launch": "Başlat", "Library.Context.Launch": "Başlat",
"Library.Context.OpenFolder": "Oyun klasörünü aç", "Library.Context.OpenFolder": "Oyun klasörünü aç",
+10 -14
View File
@@ -86,6 +86,7 @@ public partial class MainWindow
CloseGameSettings(); CloseGameSettings();
LaunchSelected(); LaunchSelected();
}; };
GameOptionsCloseButton.Click += (_, _) => CloseGameSettings();
GameOptionsOpenFolderButton.Click += (_, _) => OpenSelectedGameFolder(); GameOptionsOpenFolderButton.Click += (_, _) => OpenSelectedGameFolder();
GameOptionsCopyPathButton.Click += async (_, _) => GameOptionsCopyPathButton.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path); await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path);
@@ -140,6 +141,7 @@ public partial class MainWindow
!string.IsNullOrWhiteSpace(game.TitleId); !string.IsNullOrWhiteSpace(game.TitleId);
_isGameSettingsOpen = true; _isGameSettingsOpen = true;
SetGameOptionsPagesSpan(coversConsoleRow: true);
SetGameOptionsOpenClass(BackdropLayer, active: true); SetGameOptionsOpenClass(BackdropLayer, active: true);
SetGameOptionsOpenClass(CarouselHost, active: true); SetGameOptionsOpenClass(CarouselHost, active: true);
SetGameOptionsOpenClass(LibrarySelectedDetails, active: true); SetGameOptionsOpenClass(LibrarySelectedDetails, active: true);
@@ -158,6 +160,7 @@ public partial class MainWindow
} }
_isGameSettingsOpen = false; _isGameSettingsOpen = false;
SetGameOptionsPagesSpan(coversConsoleRow: false);
SetGameOptionsNavigationIndicator(_gameOptionsIndicatorIndex, animate: false); SetGameOptionsNavigationIndicator(_gameOptionsIndicatorIndex, animate: false);
_gameSettingsTitleId = null; _gameSettingsTitleId = null;
_gameEnvironmentPassthrough.Clear(); _gameEnvironmentPassthrough.Clear();
@@ -438,6 +441,11 @@ public partial class MainWindow
animate); animate);
} }
private void SetGameOptionsPagesSpan(bool coversConsoleRow)
{
Grid.SetRowSpan(PagesHost, coversConsoleRow ? 2 : 1);
}
private Button[] GameOptionsNavigationButtons() => private Button[] GameOptionsNavigationButtons() =>
[ [
GameOptionsGeneralNav, GameOptionsGeneralNav,
@@ -476,18 +484,6 @@ public partial class MainWindow
("SHARPEMU_GUEST_IMAGE_CPU_SYNC", GameEnvGuestImageCpuSyncToggle), ("SHARPEMU_GUEST_IMAGE_CPU_SYNC", GameEnvGuestImageCpuSyncToggle),
]; ];
private static void SetGameOptionsOpenClass(Control control, bool active) private static void SetGameOptionsOpenClass(Control control, bool active) =>
{ SetClass(control, "gameOptionsOpen", active);
if (active)
{
if (!control.Classes.Contains("gameOptionsOpen"))
{
control.Classes.Add("gameOptionsOpen");
}
}
else
{
control.Classes.Remove("gameOptionsOpen");
}
}
} }
+66 -34
View File
@@ -136,6 +136,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
<StackPanel Grid.Column="2" x:Name="LibraryToolbar" Orientation="Horizontal" Spacing="8" <StackPanel Grid.Column="2" x:Name="LibraryToolbar" Orientation="Horizontal" Spacing="8"
VerticalAlignment="Center"> VerticalAlignment="Center">
<Button x:Name="LibraryLayoutButton"
Classes="iconGhost"
VerticalAlignment="Center">
<TextBlock x:Name="LibraryLayoutGlyph"
Classes="materialSymbol compact"
Text="grid_view" />
</Button>
<TextBox x:Name="SearchBox" <TextBox x:Name="SearchBox"
PlaceholderText="{Binding [Library.SearchWatermark], Source={x:Static local:Localization.Instance}}" PlaceholderText="{Binding [Library.SearchWatermark], Source={x:Static local:Localization.Instance}}"
Width="240" Width="240"
@@ -147,7 +154,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</StackPanel> </StackPanel>
</Grid> </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 <!-- Library page. Covers use a horizontal virtualized rail so the
number of realized images stays bounded for large libraries. --> number of realized images stays bounded for large libraries. -->
@@ -167,7 +174,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Transitions> </Transitions>
</Panel.Transitions> </Panel.Transitions>
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent" <ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
SelectionMode="Single" Padding="4,0,28,0"> SelectionMode="Single">
<ListBox.ContextMenu> <ListBox.ContextMenu>
<ContextMenu x:Name="GameContextMenu" Placement="Pointer"> <ContextMenu x:Name="GameContextMenu" Placement="Pointer">
<MenuItem x:Name="CtxLaunch" <MenuItem x:Name="CtxLaunch"
@@ -218,32 +225,35 @@ SPDX-License-Identifier: GPL-2.0-or-later
</MenuItem> </MenuItem>
</ContextMenu> </ContextMenu>
</ListBox.ContextMenu> </ListBox.ContextMenu>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.DataTemplates> <ListBox.DataTemplates>
<DataTemplate x:DataType="local:GameEntry" x:CompileBindings="True"> <DataTemplate x:DataType="local:GameEntry" x:CompileBindings="True">
<Border Classes="coverShadow libraryGameCard" <StackPanel Spacing="7">
Width="148" <Border Classes="coverShadow libraryGameCard"
Height="148" Width="148"
ToolTip.Tip="{Binding Name}" Height="148"
AutomationProperties.Name="{Binding Name}"> ToolTip.Tip="{Binding Name}"
<Border Classes="coverClip"> AutomationProperties.Name="{Binding Name}">
<Panel> <Border Classes="coverClip">
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}"> <Panel>
<TextBlock Text="{Binding Initials}" FontSize="38" FontWeight="Light" <Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
Foreground="#C4E8ECF4" <TextBlock Text="{Binding Initials}" FontSize="38" FontWeight="Light"
HorizontalAlignment="Center" VerticalAlignment="Center" /> Foreground="#C4E8ECF4"
</Border> HorizontalAlignment="Center" VerticalAlignment="Center" />
<Image Source="{Binding Cover}" </Border>
Stretch="UniformToFill" <Image Source="{Binding Cover}"
RenderOptions.BitmapInterpolationMode="LowQuality" Stretch="UniformToFill"
IsVisible="{Binding HasCover}" /> RenderOptions.BitmapInterpolationMode="LowQuality"
</Panel> IsVisible="{Binding HasCover}" />
</Panel>
</Border>
</Border> </Border>
</Border> <TextBlock Classes="libraryTileName"
Text="{Binding Name}"
TextWrapping="Wrap"
MaxLines="2"
TextTrimming="CharacterEllipsis"
TextAlignment="Center" />
</StackPanel>
</DataTemplate> </DataTemplate>
<DataTemplate x:DataType="local:AddFolderTile"> <DataTemplate x:DataType="local:AddFolderTile">
<Border Classes="addFolderTile" <Border Classes="addFolderTile"
@@ -292,8 +302,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
MaxWidth="980" MaxWidth="980"
HorizontalAlignment="Left" HorizontalAlignment="Left"
VerticalAlignment="Top" VerticalAlignment="Top"
Margin="4,8,0,0" Margin="4,8,0,0">
Spacing="18">
<StackPanel.Transitions> <StackPanel.Transitions>
<Transitions> <Transitions>
<DoubleTransition Property="Opacity" <DoubleTransition Property="Opacity"
@@ -302,8 +311,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
Duration="0:0:0.18" /> Duration="0:0:0.18" />
</Transitions> </Transitions>
</StackPanel.Transitions> </StackPanel.Transitions>
<TextBlock Text="{Binding Name}" <Border Classes="selectedDetailsDivider" />
FontSize="42" <TextBlock Classes="selectedGameTitle"
Text="{Binding Name}"
FontWeight="SemiBold" FontWeight="SemiBold"
TextWrapping="Wrap" TextWrapping="Wrap"
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
@@ -440,7 +450,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Border> </Border>
<Border Padding="260,18,32,24"> <Border Padding="260,18,32,24">
<Grid ColumnDefinitions="*,Auto" <Grid ColumnDefinitions="*,Auto,Auto"
ColumnSpacing="34"> ColumnSpacing="34">
<StackPanel Spacing="3" <StackPanel Spacing="3"
VerticalAlignment="Center"> VerticalAlignment="Center">
@@ -491,6 +501,20 @@ SPDX-License-Identifier: GPL-2.0-or-later
Text="{Binding TitleId}" /> Text="{Binding TitleId}" />
</StackPanel> </StackPanel>
</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> </Grid>
</Border> </Border>
@@ -502,10 +526,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
RowDefinitions="Auto,*"> RowDefinitions="Auto,*">
<Button x:Name="GameOptionsLaunchButton" <Button x:Name="GameOptionsLaunchButton"
Classes="gameOptionsLaunch"> Classes="gameOptionsLaunch">
<TextBlock VerticalAlignment="Center" <StackPanel Orientation="Horizontal"
Text="{Binding [Library.Context.Launch], Spacing="8"
Source={x:Static local:Localization.Instance}, HorizontalAlignment="Center"
x:CompileBindings=False}" /> 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> </Button>
<ScrollViewer x:Name="GameOptionsNavScroll" <ScrollViewer x:Name="GameOptionsNavScroll"
+137 -6
View File
@@ -36,7 +36,7 @@ public partial class MainWindow : Window
private const int MaxConsoleLines = 4000; private const int MaxConsoleLines = 4000;
private const int MaxConsoleLinesPerFlush = 500; private const int MaxConsoleLinesPerFlush = 500;
private static readonly TimeSpan NavigationIndicatorAnimationDuration = 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 DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE"));
private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488")); private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488"));
@@ -123,6 +123,7 @@ public partial class MainWindow : Window
private bool _isClosing; private bool _isClosing;
private bool _restoringGameSelection; private bool _restoringGameSelection;
private bool _addFolderInProgress; private bool _addFolderInProgress;
private bool _isLibraryGridLayout;
private GameEntry? _lastSelectedGame; private GameEntry? _lastSelectedGame;
// Bundled key art shown whenever no game-specific backdrop applies; the // Bundled key art shown whenever no game-specific backdrop applies; the
@@ -134,6 +135,8 @@ public partial class MainWindow : Window
private HostGamepadButtons _previousPadButtons; private HostGamepadButtons _previousPadButtons;
private long _navLeftNextAt; private long _navLeftNextAt;
private long _navRightNextAt; private long _navRightNextAt;
private long _navUpNextAt;
private long _navDownNextAt;
//Github http client for latest commit //Github http client for latest commit
private static readonly HttpClient GithubHttpClient = CreateGithubHttpClient(); private static readonly HttpClient GithubHttpClient = CreateGithubHttpClient();
@@ -219,6 +222,9 @@ public partial class MainWindow : Window
CloseConsoleButton.Click += (_, _) => ConsoleToggle.IsChecked = false; CloseConsoleButton.Click += (_, _) => ConsoleToggle.IsChecked = false;
LibraryTabButton.Click += (_, _) => SetActivePage(0); LibraryTabButton.Click += (_, _) => SetActivePage(0);
OptionsTabButton.Click += (_, _) => SetActivePage(1); OptionsTabButton.Click += (_, _) => SetActivePage(1);
LibraryLayoutButton.Click += (_, _) => ToggleLibraryLayout();
LibraryPage.SizeChanged += (_, _) => UpdateLibraryGridHeight();
LibrarySelectedDetails.SizeChanged += (_, _) => UpdateLibraryGridHeight();
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null; ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
WireOptionsNavigation(); WireOptionsNavigation();
WireGameOptions(); WireGameOptions();
@@ -379,18 +385,73 @@ public partial class MainWindow : Window
} }
} }
private static void SetActiveClass(Button button, bool active) private void SetLibraryLayout(bool grid)
{
_isLibraryGridLayout = grid;
SetClass(GameList, "gridLayout", grid);
SetClass(LibrarySelectedDetails, "gridLayout", grid);
LibraryPage.RowDefinitions[0].Height = grid
? GridLength.Auto
: new GridLength(188);
LibraryPage.Margin = grid
? new Thickness(0, 6, 0, 0)
: new Thickness(0, 46, 0, 0);
UpdateLibraryGridHeight();
UpdateLibraryLayoutButton();
if (GameList.SelectedItem is { } selected)
{
Dispatcher.UIThread.Post(
() => GameList.ScrollIntoView(selected),
DispatcherPriority.Loaded);
}
}
private void UpdateLibraryGridHeight()
{
var pageHeight = LibraryPage.Bounds.Height;
if (!_isLibraryGridLayout || pageHeight <= 0)
{
GameList.MaxHeight = double.PositiveInfinity;
return;
}
GameList.MaxHeight = Math.Max(
0,
pageHeight - LibrarySelectedDetails.DesiredSize.Height);
}
private void ToggleLibraryLayout()
{
SetLibraryLayout(!_isLibraryGridLayout);
_settings.LibraryLayout = _isLibraryGridLayout ? "Grid" : "Carousel";
_settings.Save();
}
private void UpdateLibraryLayoutButton()
{
LibraryLayoutGlyph.Text = _isLibraryGridLayout ? "view_carousel" : "grid_view";
var label = Localization.Instance.Get(
_isLibraryGridLayout ? "Library.View.Carousel" : "Library.View.Grid");
ToolTip.SetTip(LibraryLayoutButton, label);
AutomationProperties.SetName(LibraryLayoutButton, label);
}
private static void SetActiveClass(Button button, bool active) =>
SetClass(button, "active", active);
private static void SetClass(Control control, string className, bool active)
{ {
if (active) if (active)
{ {
if (!button.Classes.Contains("active")) if (!control.Classes.Contains(className))
{ {
button.Classes.Add("active"); control.Classes.Add(className);
} }
} }
else else
{ {
button.Classes.Remove("active"); control.Classes.Remove(className);
} }
} }
@@ -494,7 +555,7 @@ public partial class MainWindow : Window
translationAnimation.InsertExpressionKeyFrame( translationAnimation.InsertExpressionKeyFrame(
1f, 1f,
"this.FinalValue", "this.FinalValue",
new SineEaseInOut()); new CubicEaseOut());
var animations = visual.Compositor.CreateImplicitAnimationCollection(); var animations = visual.Compositor.CreateImplicitAnimationCollection();
animations[nameof(CompositionVisual.Translation)] = translationAnimation; animations[nameof(CompositionVisual.Translation)] = translationAnimation;
@@ -676,6 +737,23 @@ public partial class MainWindow : Window
MoveSelection(1); MoveSelection(1);
} }
if (_isLibraryGridLayout)
{
var up = (pad.Buttons & HostGamepadButtons.Up) != 0 || pad.LeftY < 64;
var down = (pad.Buttons & HostGamepadButtons.Down) != 0 || pad.LeftY > 192;
var rowStep = LibraryRowStep();
if (ShouldNavigate(up, ref _navUpNextAt, now))
{
MoveSelection(-rowStep);
}
if (ShouldNavigate(down, ref _navDownNextAt, now))
{
MoveSelection(rowStep);
}
}
var pressed = pad.Buttons & ~_previousPadButtons; var pressed = pad.Buttons & ~_previousPadButtons;
if ((pressed & HostGamepadButtons.Cross) != 0) if ((pressed & HostGamepadButtons.Cross) != 0)
{ {
@@ -712,6 +790,29 @@ public partial class MainWindow : Window
return false; return false;
} }
private int LibraryRowStep()
{
if (GameList.ContainerFromIndex(0) is not { } first)
{
return 1;
}
var top = first.Bounds.Top;
var columns = 1;
for (var index = 1; index < _libraryTiles.Count; index++)
{
if (GameList.ContainerFromIndex(index) is not { } container ||
Math.Abs(container.Bounds.Top - top) > 0.5)
{
break;
}
columns++;
}
return columns;
}
private void MoveSelection(int delta) private void MoveSelection(int delta)
{ {
var index = GameList.SelectedIndex < 0 var index = GameList.SelectedIndex < 0
@@ -747,6 +848,8 @@ public partial class MainWindow : Window
{ {
_ = CheckForUpdatesAsync(); _ = CheckForUpdatesAsync();
} }
SeedLibraryFromCache();
await RescanLibraryAsync(); await RescanLibraryAsync();
} }
@@ -783,6 +886,7 @@ public partial class MainWindow : Window
RefreshHostRefreshRates(_settings.RefreshRate); RefreshHostRefreshRates(_settings.RefreshRate);
RefreshUpdateText(); RefreshUpdateText();
UpdateEmptyStateTexts(); UpdateEmptyStateTexts();
UpdateLibraryLayoutButton();
UpdateRunButtons(); UpdateRunButtons();
} }
@@ -1067,6 +1171,7 @@ public partial class MainWindow : Window
LogToFileToggle.IsChecked = _settings.LogToFile; LogToFileToggle.IsChecked = _settings.LogToFile;
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile; OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
TitleMusicToggle.IsChecked = _settings.PlayTitleMusic; TitleMusicToggle.IsChecked = _settings.PlayTitleMusic;
SetLibraryLayout(string.Equals(_settings.LibraryLayout, "Grid", StringComparison.OrdinalIgnoreCase));
DiscordToggle.IsChecked = _settings.DiscordRichPresence; DiscordToggle.IsChecked = _settings.DiscordRichPresence;
AutoUpdateToggle.IsChecked = _settings.CheckForUpdatesOnStartup; AutoUpdateToggle.IsChecked = _settings.CheckForUpdatesOnStartup;
EnvBthidToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_BTHID_UNAVAILABLE"); EnvBthidToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_BTHID_UNAVAILABLE");
@@ -1444,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) private async Task RescanLibraryAsync(bool showProgress = true)
{ {
Dispatcher.UIThread.VerifyAccess(); Dispatcher.UIThread.VerifyAccess();
@@ -1473,6 +1603,7 @@ public partial class MainWindow : Window
LoadingState.IsVisible = false; LoadingState.IsVisible = false;
LoadGameDetailsInBackground(reconciliation.CoversToLoad, reconciliation.Games); LoadGameDetailsInBackground(reconciliation.CoversToLoad, reconciliation.Games);
UpdateDiscordPresence(); UpdateDiscordPresence();
GameLibraryCache.Save(folders, reconciliation.Games);
} }
/// <summary> /// <summary>
@@ -43,6 +43,23 @@ Shared launcher button variants and page switcher styles.
<Setter Property="Foreground" Value="{StaticResource TextBrush}" /> <Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style> </Style>
<Style Selector="Button.iconGhost">
<Setter Property="Width" Value="32" />
<Setter Property="Height" Value="32" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.iconGhost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="ToggleButton.ghost"> <Style Selector="ToggleButton.ghost">
<Setter Property="Background" Value="Transparent" /> <Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" /> <Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
@@ -103,6 +103,57 @@ Contextual per-game options reveal, actions, and selected-game motion.
<Setter Property="Foreground" Value="White" /> <Setter Property="Foreground" Value="White" />
</Style> </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>
<Style Selector="TextBlock.selectedGameTitle">
<Setter Property="FontSize" Value="42" />
</Style>
<Style Selector="StackPanel.selectedDetailsHost.gridLayout">
<Setter Property="Spacing" Value="12" />
</Style>
<Style Selector="StackPanel.selectedDetailsHost.gridLayout TextBlock.selectedGameTitle">
<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" />
<Setter Property="Padding" Value="28,10" />
<Setter Property="FontSize" Value="15" />
</Style>
<Style Selector="StackPanel.selectedDetailsHost.gridLayout Button.optionsCircle,
StackPanel.selectedDetailsHost.gridLayout ToggleButton.optionsCircle">
<Setter Property="Width" Value="44" />
<Setter Property="Height" Value="44" />
<Setter Property="CornerRadius" Value="22" />
</Style>
<Style Selector="Button.gameOptionsLaunch"> <Style Selector="Button.gameOptionsLaunch">
<Setter Property="Width" Value="220" /> <Setter Property="Width" Value="220" />
<Setter Property="Height" Value="46" /> <Setter Property="Height" Value="46" />
@@ -9,8 +9,25 @@ Cover-art library item states and motion.
<Style Selector="ListBox.tileGrid"> <Style Selector="ListBox.tileGrid">
<Setter Property="Background" Value="Transparent" /> <Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" /> <Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="4,0,28,0" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Hidden" /> <Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Hidden" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled" /> <Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled" />
<Setter Property="ItemsPanel">
<ItemsPanelTemplate>
<VirtualizingStackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</Setter>
</Style>
<Style Selector="ListBox.tileGrid.gridLayout">
<Setter Property="Padding" Value="4,0,14,0" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto" />
<Setter Property="ItemsPanel">
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</Setter>
</Style> </Style>
<Style Selector="ListBox.tileGrid ListBoxItem"> <Style Selector="ListBox.tileGrid ListBoxItem">
<Setter Property="Width" Value="160" /> <Setter Property="Width" Value="160" />
@@ -35,6 +52,25 @@ Cover-art library item states and motion.
</Transitions> </Transitions>
</Setter> </Setter>
</Style> </Style>
<!-- The title rides along in the item template so both layouts share one
template. The rail hides it because the selected game already names
itself in the details below the covers. -->
<Style Selector="TextBlock.libraryTileName">
<Setter Property="IsVisible" Value="False" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="LineHeight" Value="16" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="ListBox.tileGrid.gridLayout TextBlock.libraryTileName">
<Setter Property="IsVisible" Value="True" />
</Style>
<Style Selector="ListBox.tileGrid.gridLayout ListBoxItem">
<Setter Property="Height" Value="200" />
<Setter Property="Margin" Value="0,6,12,10" />
<Setter Property="VerticalContentAlignment" Value="Top" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover, <Style Selector="ListBox.tileGrid ListBoxItem:pointerover,
ListBox.tileGrid ListBoxItem:selected"> ListBox.tileGrid ListBoxItem:selected">
<Setter Property="Opacity" Value="1" /> <Setter Property="Opacity" Value="1" />
@@ -61,6 +61,19 @@ public sealed class GuiSettingsTests
Assert.Equal(1000, settings.RefreshRate); Assert.Equal(1000, settings.RefreshRate);
} }
[Theory]
[InlineData("""{ }""", "Carousel")]
[InlineData("""{ "LibraryLayout": null }""", "Carousel")]
[InlineData("""{ "LibraryLayout": "sideways" }""", "Carousel")]
[InlineData("""{ "LibraryLayout": "grid" }""", "Grid")]
[InlineData("""{ "LibraryLayout": "Grid" }""", "Grid")]
public void NormalizeFromJson_LibraryLayout_FallsBackToCarousel(string json, string expected)
{
var settings = GuiSettings.NormalizeFromJson(json);
Assert.Equal(expected, settings.LibraryLayout);
}
[Fact] [Fact]
public void NormalizeFromJson_CustomResolution_IsPreserved() public void NormalizeFromJson_CustomResolution_IsPreserved()
{ {