diff --git a/src/SharpEmu.GUI/AddFolderTile.cs b/src/SharpEmu.GUI/AddFolderTile.cs
new file mode 100644
index 00000000..44facad9
--- /dev/null
+++ b/src/SharpEmu.GUI/AddFolderTile.cs
@@ -0,0 +1,16 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.GUI;
+
+///
+/// Stateless trailing action that opens the library folder picker.
+///
+public sealed class AddFolderTile : LibraryTile
+{
+ public static AddFolderTile Instance { get; } = new();
+
+ private AddFolderTile()
+ {
+ }
+}
diff --git a/src/SharpEmu.GUI/GameEntry.cs b/src/SharpEmu.GUI/GameEntry.cs
index e0d2cc37..0d84be29 100644
--- a/src/SharpEmu.GUI/GameEntry.cs
+++ b/src/SharpEmu.GUI/GameEntry.cs
@@ -17,7 +17,7 @@ internal enum GameEntryChanges
Background = 4,
}
-public sealed class GameEntry : INotifyPropertyChanged
+public sealed class GameEntry : LibraryTile, INotifyPropertyChanged
{
// Placeholder gradients for games without cover art, picked
// deterministically from the game name so a game keeps its color.
diff --git a/src/SharpEmu.GUI/Languages/ar.json b/src/SharpEmu.GUI/Languages/ar.json
index 115c3155..519e7ef6 100644
--- a/src/SharpEmu.GUI/Languages/ar.json
+++ b/src/SharpEmu.GUI/Languages/ar.json
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} لعبة",
"Library.SearchWatermark": "ابحث في المكتبة...",
- "Library.AddFolder": "+ إضافة مجلد",
+ "Library.AddFolder": "إضافة مجلد",
"Library.OpenFile": "فتح ملف...",
"Library.Context.Launch": "تشغيل",
diff --git a/src/SharpEmu.GUI/Languages/br.json b/src/SharpEmu.GUI/Languages/br.json
index dcc0e219..9938ab67 100644
--- a/src/SharpEmu.GUI/Languages/br.json
+++ b/src/SharpEmu.GUI/Languages/br.json
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} jogos",
"Library.SearchWatermark": "Pesquisar na biblioteca…",
- "Library.AddFolder": "+ Adicionar pasta",
+ "Library.AddFolder": "Adicionar pasta",
"Library.OpenFile": "Abrir arquivo…",
"Library.Context.Launch": "Jogar",
diff --git a/src/SharpEmu.GUI/Languages/de.json b/src/SharpEmu.GUI/Languages/de.json
index cc936913..481ab6a1 100644
--- a/src/SharpEmu.GUI/Languages/de.json
+++ b/src/SharpEmu.GUI/Languages/de.json
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} Spiele",
"Library.SearchWatermark": "Bibliothek durchsuchen…",
- "Library.AddFolder": "+ Spielordner hinzufügen",
+ "Library.AddFolder": "Spielordner hinzufügen",
"Library.OpenFile": "Datei öffnen…",
"Library.Context.Launch": "Starten",
diff --git a/src/SharpEmu.GUI/Languages/dk.json b/src/SharpEmu.GUI/Languages/dk.json
index 479f8d59..a5ad45d7 100644
--- a/src/SharpEmu.GUI/Languages/dk.json
+++ b/src/SharpEmu.GUI/Languages/dk.json
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} spil",
"Library.SearchWatermark": "Søg i biblioteket…",
- "Library.AddFolder": "+ Tilføj mappe",
+ "Library.AddFolder": "Tilføj mappe",
"Library.OpenFile": "Åbn fil…",
"Library.Context.Launch": "Start",
diff --git a/src/SharpEmu.GUI/Languages/en.json b/src/SharpEmu.GUI/Languages/en.json
index 8a59c75e..f734c547 100644
--- a/src/SharpEmu.GUI/Languages/en.json
+++ b/src/SharpEmu.GUI/Languages/en.json
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} games",
"Library.SearchWatermark": "Search library…",
- "Library.AddFolder": "+ Add folder",
+ "Library.AddFolder": "Add folder",
"Library.OpenFile": "Open file…",
"Library.Context.Launch": "Launch",
diff --git a/src/SharpEmu.GUI/Languages/es.json b/src/SharpEmu.GUI/Languages/es.json
index 53d6d473..8838260e 100644
--- a/src/SharpEmu.GUI/Languages/es.json
+++ b/src/SharpEmu.GUI/Languages/es.json
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} juegos",
"Library.SearchWatermark": "Buscar en la biblioteca…",
- "Library.AddFolder": "+ Añadir carpeta",
+ "Library.AddFolder": "Añadir carpeta",
"Library.OpenFile": "Abrir archivo…",
"Library.Context.Launch": "Iniciar",
diff --git a/src/SharpEmu.GUI/Languages/fr.json b/src/SharpEmu.GUI/Languages/fr.json
index 5686ed9c..8d753f24 100644
--- a/src/SharpEmu.GUI/Languages/fr.json
+++ b/src/SharpEmu.GUI/Languages/fr.json
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} jeux",
"Library.SearchWatermark": "Rechercher dans la bibliothèque…",
- "Library.AddFolder": "+ Ajouter un dossier",
+ "Library.AddFolder": "Ajouter un dossier",
"Library.OpenFile": "Ouvrir un fichier…",
"Library.Context.Launch": "Lancer",
diff --git a/src/SharpEmu.GUI/Languages/hu.json b/src/SharpEmu.GUI/Languages/hu.json
index b2594ee0..59e807af 100644
--- a/src/SharpEmu.GUI/Languages/hu.json
+++ b/src/SharpEmu.GUI/Languages/hu.json
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} játékok",
"Library.SearchWatermark": "Keresés a könyvtárban",
- "Library.AddFolder": "+ Mappa hozzáadása",
+ "Library.AddFolder": "Mappa hozzáadása",
"Library.OpenFile": "Fájl megnyitása…",
"Library.Context.Launch": "Inditás",
diff --git a/src/SharpEmu.GUI/Languages/it.json b/src/SharpEmu.GUI/Languages/it.json
index 216dda7f..54b8852d 100644
--- a/src/SharpEmu.GUI/Languages/it.json
+++ b/src/SharpEmu.GUI/Languages/it.json
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} giochi",
"Library.SearchWatermark": "Cerca nella libreria…",
- "Library.AddFolder": "+ Aggiungi cartella",
+ "Library.AddFolder": "Aggiungi cartella",
"Library.OpenFile": "Apri file…",
"Library.Context.Launch": "Avvia",
diff --git a/src/SharpEmu.GUI/Languages/ja.json b/src/SharpEmu.GUI/Languages/ja.json
index 80a5afab..c8e7c377 100644
--- a/src/SharpEmu.GUI/Languages/ja.json
+++ b/src/SharpEmu.GUI/Languages/ja.json
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "ゲーム {0}本",
"Library.SearchWatermark": "ライブラリを検索…",
- "Library.AddFolder": "+ フォルダーを追加",
+ "Library.AddFolder": "フォルダーを追加",
"Library.OpenFile": "ファイルを開く…",
"Library.Context.Launch": "起動",
diff --git a/src/SharpEmu.GUI/Languages/ko.json b/src/SharpEmu.GUI/Languages/ko.json
index b66deee8..05f086bc 100644
--- a/src/SharpEmu.GUI/Languages/ko.json
+++ b/src/SharpEmu.GUI/Languages/ko.json
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "게임 {0}개",
"Library.SearchWatermark": "라이브러리 검색…",
- "Library.AddFolder": "+ 폴더 추가",
+ "Library.AddFolder": "폴더 추가",
"Library.OpenFile": "파일 열기…",
"Library.Context.Launch": "실행",
diff --git a/src/SharpEmu.GUI/Languages/nl.json b/src/SharpEmu.GUI/Languages/nl.json
index 424ec6c3..32653d3b 100644
--- a/src/SharpEmu.GUI/Languages/nl.json
+++ b/src/SharpEmu.GUI/Languages/nl.json
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} games",
"Library.SearchWatermark": "Zoeken in bibliotheek…",
- "Library.AddFolder": "+ Map toevoegen",
+ "Library.AddFolder": "Map toevoegen",
"Library.OpenFile": "Bestand openen…",
"Library.Context.Launch": "Starten",
diff --git a/src/SharpEmu.GUI/Languages/pt.json b/src/SharpEmu.GUI/Languages/pt.json
index f8e93d9c..838bd72f 100644
--- a/src/SharpEmu.GUI/Languages/pt.json
+++ b/src/SharpEmu.GUI/Languages/pt.json
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} jogos",
"Library.SearchWatermark": "Pesquisar biblioteca…",
- "Library.AddFolder": "+ Adicionar pasta",
+ "Library.AddFolder": "Adicionar pasta",
"Library.OpenFile": "Abrir ficheiro…",
"Library.Context.Launch": "Iniciar",
diff --git a/src/SharpEmu.GUI/Languages/ru.json b/src/SharpEmu.GUI/Languages/ru.json
index 423cb206..79d1858c 100644
--- a/src/SharpEmu.GUI/Languages/ru.json
+++ b/src/SharpEmu.GUI/Languages/ru.json
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "Игр: {0}",
"Library.SearchWatermark": "Поиск…",
- "Library.AddFolder": "+ Добавить папку",
+ "Library.AddFolder": "Добавить папку",
"Library.OpenFile": "Открыть файл…",
"Library.Context.Launch": "Запустить",
diff --git a/src/SharpEmu.GUI/Languages/tr.json b/src/SharpEmu.GUI/Languages/tr.json
index 3a26330a..c7c96ef9 100644
--- a/src/SharpEmu.GUI/Languages/tr.json
+++ b/src/SharpEmu.GUI/Languages/tr.json
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} oyun",
"Library.SearchWatermark": "Kütüphanede ara…",
- "Library.AddFolder": "+ Klasör ekle",
+ "Library.AddFolder": "Klasör ekle",
"Library.OpenFile": "Dosya aç…",
"Library.Context.Launch": "Başlat",
diff --git a/src/SharpEmu.GUI/LibraryTile.cs b/src/SharpEmu.GUI/LibraryTile.cs
new file mode 100644
index 00000000..9106bcbf
--- /dev/null
+++ b/src/SharpEmu.GUI/LibraryTile.cs
@@ -0,0 +1,9 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.GUI;
+
+///
+/// Base type for game cards and actions shown in the library rail.
+///
+public abstract class LibraryTile;
diff --git a/src/SharpEmu.GUI/LibraryTileCollection.cs b/src/SharpEmu.GUI/LibraryTileCollection.cs
new file mode 100644
index 00000000..6571bd86
--- /dev/null
+++ b/src/SharpEmu.GUI/LibraryTileCollection.cs
@@ -0,0 +1,103 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Collections;
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+
+namespace SharpEmu.GUI;
+
+///
+/// Read-only view of the visible games with one persistent action tile at
+/// the end. Game collection changes keep their original indices, so Avalonia
+/// can update and virtualize the rail without rebuilding every item.
+///
+public sealed class LibraryTileCollection : IReadOnlyList, IList, INotifyCollectionChanged
+{
+ private readonly ObservableCollection _games;
+
+ public LibraryTileCollection(ObservableCollection games)
+ {
+ _games = games;
+ _games.CollectionChanged += OnGamesCollectionChanged;
+ }
+
+ public event NotifyCollectionChangedEventHandler? CollectionChanged;
+
+ public int Count => _games.Count + 1;
+
+ bool IList.IsFixedSize => false;
+
+ bool IList.IsReadOnly => true;
+
+ bool ICollection.IsSynchronized => false;
+
+ object ICollection.SyncRoot => this;
+
+ public LibraryTile this[int index]
+ {
+ get
+ {
+ if ((uint)index < (uint)_games.Count)
+ {
+ return _games[index];
+ }
+
+ if (index == _games.Count)
+ {
+ return AddFolderTile.Instance;
+ }
+
+ throw new ArgumentOutOfRangeException(nameof(index));
+ }
+ }
+
+ object? IList.this[int index]
+ {
+ get => this[index];
+ set => throw new NotSupportedException();
+ }
+
+ public IEnumerator GetEnumerator()
+ {
+ foreach (var game in _games)
+ {
+ yield return game;
+ }
+
+ yield return AddFolderTile.Instance;
+ }
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ int IList.Add(object? value) => throw new NotSupportedException();
+
+ void IList.Clear() => throw new NotSupportedException();
+
+ bool IList.Contains(object? value) => ((IList)this).IndexOf(value) >= 0;
+
+ int IList.IndexOf(object? value) => value switch
+ {
+ GameEntry game => _games.IndexOf(game),
+ AddFolderTile => _games.Count,
+ _ => -1,
+ };
+
+ void IList.Insert(int index, object? value) => throw new NotSupportedException();
+
+ void IList.Remove(object? value) => throw new NotSupportedException();
+
+ void IList.RemoveAt(int index) => throw new NotSupportedException();
+
+ void ICollection.CopyTo(Array array, int index)
+ {
+ ArgumentNullException.ThrowIfNull(array);
+ foreach (var tile in this)
+ {
+ array.SetValue(tile, index++);
+ }
+ }
+
+ private void OnGamesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs args) =>
+ CollectionChanged?.Invoke(this, args);
+}
diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml
index 74ead40a..18d3ebaa 100644
--- a/src/SharpEmu.GUI/MainWindow.axaml
+++ b/src/SharpEmu.GUI/MainWindow.axaml
@@ -6,7 +6,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
-
+
-
+
+
+ Grid.Row="0"
+ SelectionMode="Single" Padding="4,0,28,0">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+ language used by the console panel below. -->
-
+
@@ -692,7 +773,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
+ Padding="10,4" Margin="0,0,8,0" />
+
@@ -705,102 +794,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/SharpEmu.GUI/MainWindow.axaml.cs b/src/SharpEmu.GUI/MainWindow.axaml.cs
index 8188e75a..17ea4abf 100644
--- a/src/SharpEmu.GUI/MainWindow.axaml.cs
+++ b/src/SharpEmu.GUI/MainWindow.axaml.cs
@@ -33,8 +33,6 @@ public partial class MainWindow : Window
{
private const int MaxConsoleLines = 4000;
private const int MaxConsoleLinesPerFlush = 500;
- private const double LaunchBlurRadius = 12;
- private const double BlurTransitionSeconds = 0.24;
private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE"));
private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488"));
@@ -83,17 +81,12 @@ public partial class MainWindow : Window
];
private readonly List _allGames = new();
private readonly ObservableCollection _visibleGames = new();
+ private readonly LibraryTileCollection _libraryTiles;
private readonly GameLibraryWatcher _libraryWatcher = new();
private readonly AvaloniaList _consoleLines = new();
private readonly List _allConsoleLines = new();
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
private readonly DispatcherTimer _consoleFlushTimer;
- private readonly DispatcherTimer _libraryBlurTimer;
- private BlurEffect? _libraryBlur;
- private double _libraryBlurStartRadius;
- private double _libraryBlurTargetRadius;
- private long _libraryBlurStartedAt;
- private bool _clearLibraryBlurWhenComplete;
private GuiSettings _settings = new();
private IReadOnlyList _hostDisplays = [];
@@ -123,23 +116,19 @@ public partial class MainWindow : Window
private int _detailLoadGeneration;
private int _backdropGeneration;
private bool _isClosing;
+ private bool _restoringGameSelection;
+ private bool _addFolderInProgress;
+ private GameEntry? _lastSelectedGame;
// Bundled key art shown whenever no game-specific backdrop applies; the
// plain window color remains the fallback when the asset fails to load.
private Bitmap? _defaultBackdrop;
- // Whether the native loading/closing popup should be showing; it is a
- // desktop-topmost popup, so it closes while the launcher is in the
- // background or minimized and reopens from this flag on activation.
- private bool _sessionLoadingActive;
-
// Controller navigation state.
private readonly DispatcherTimer _gamepadTimer;
private HostGamepadButtons _previousPadButtons;
private long _navLeftNextAt;
private long _navRightNextAt;
- private long _navUpNextAt;
- private long _navDownNextAt;
//Github http client for latest commit
private static readonly HttpClient GithubHttpClient = CreateGithubHttpClient();
@@ -156,6 +145,7 @@ public partial class MainWindow : Window
{
InitializeComponent();
InitializeLocalizedChoiceBoxes();
+ _libraryTiles = new LibraryTileCollection(_visibleGames);
try
{
@@ -169,7 +159,7 @@ public partial class MainWindow : Window
_defaultBackdrop = null; // color background remains the fallback
}
- GameList.ItemsSource = _visibleGames;
+ GameList.ItemsSource = _libraryTiles;
_libraryWatcher.RefreshRequested += OnLibraryRefreshRequested;
ConsoleList.ItemsSource = _consoleLines;
_consoleMirror = GuiConsoleMirror.Install((line, isError) =>
@@ -187,23 +177,6 @@ public partial class MainWindow : Window
};
_consoleFlushTimer.Start();
- _libraryBlurTimer = new DispatcherTimer
- {
- Interval = TimeSpan.FromMilliseconds(16),
- };
- _libraryBlurTimer.Tick += (_, _) => AdvanceLibraryBlur();
-
- // Native popups float above every window on the desktop; they must
- // follow the launcher into the background or a minimized state.
- Activated += (_, _) =>
- {
- SessionLoadingPopup.IsOpen = _sessionLoadingActive;
- };
- Deactivated += (_, _) =>
- {
- SessionLoadingPopup.IsOpen = false;
- };
-
TitleBar.PointerPressed += OnTitleBarPointerPressed;
TitleBar.DoubleTapped += OnTitleBarDoubleTapped;
MinimizeButton.Click += (_, _) => WindowState = WindowState.Minimized;
@@ -221,18 +194,26 @@ public partial class MainWindow : Window
UpdateWindowChromeState();
};
UpdateWindowChromeState();
- GameList.SelectionChanged += (_, _) => UpdateSelectedGame();
- GameList.DoubleTapped += (_, _) => LaunchSelected();
+ GameList.SelectionChanged += (_, _) => OnGameListSelectionChanged();
+ GameList.DoubleTapped += OnGameListDoubleTapped;
SearchBox.TextChanged += (_, _) => RefreshVisibleGames();
ConsoleSearchBox.TextChanged += (_, _) => RefreshVisibleConsoleLines();
- AddFolderButton.Click += async (_, _) => await AddFolderAsync();
- EmptyAddFolderButton.Click += async (_, _) => await AddFolderAsync();
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
- LaunchButton.Click += (_, _) => LaunchSelected();
+ LaunchButton.Click += (_, _) =>
+ {
+ if (_isRunning)
+ {
+ StopEmulator();
+ }
+ else
+ {
+ LaunchSelected();
+ }
+ };
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
- StopButton.Click += (_, _) => StopEmulator();
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
+ CloseConsoleButton.Click += (_, _) => ConsoleToggle.IsChecked = false;
LibraryTabButton.Click += (_, _) => SetActivePage(0);
OptionsTabButton.Click += (_, _) => SetActivePage(1);
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
@@ -520,8 +501,6 @@ public partial class MainWindow : Window
var now = Environment.TickCount64;
var left = (pad.Buttons & HostGamepadButtons.Left) != 0 || pad.LeftX < 64;
var right = (pad.Buttons & HostGamepadButtons.Right) != 0 || pad.LeftX > 192;
- var up = (pad.Buttons & HostGamepadButtons.Up) != 0 || pad.LeftY < 64;
- var down = (pad.Buttons & HostGamepadButtons.Down) != 0 || pad.LeftY > 192;
if (ShouldNavigate(left, ref _navLeftNextAt, now))
{
@@ -533,16 +512,6 @@ public partial class MainWindow : Window
MoveSelection(1);
}
- if (ShouldNavigate(up, ref _navUpNextAt, now))
- {
- MoveSelection(-TilesPerRow());
- }
-
- if (ShouldNavigate(down, ref _navDownNextAt, now))
- {
- MoveSelection(TilesPerRow());
- }
-
var pressed = pad.Buttons & ~_previousPadButtons;
if ((pressed & HostGamepadButtons.Cross) != 0)
{
@@ -581,26 +550,13 @@ public partial class MainWindow : Window
private void MoveSelection(int delta)
{
- if (_visibleGames.Count == 0)
- {
- return;
- }
-
var index = GameList.SelectedIndex < 0
? 0
- : Math.Clamp(GameList.SelectedIndex + delta, 0, _visibleGames.Count - 1);
+ : Math.Clamp(GameList.SelectedIndex + delta, 0, _libraryTiles.Count - 1);
GameList.SelectedIndex = index;
GameList.ScrollIntoView(index);
}
- private int TilesPerRow()
- {
- // Tile footprint: 128 content + 20 item padding + 10 item margin.
- const double TileOuterWidth = 158;
- var width = GameList.Bounds.Width;
- return width > TileOuterWidth ? (int)(width / TileOuterWidth) : 1;
- }
-
private async Task OnOpenedAsync()
{
var version = Assembly.GetExecutingAssembly().GetName().Version;
@@ -662,7 +618,7 @@ public partial class MainWindow : Window
RefreshHostRefreshRates(_settings.RefreshRate);
RefreshUpdateText();
UpdateEmptyStateTexts();
- UpdateSelectedGameTexts();
+ UpdateRunButtons();
}
// ---- Discord Rich Presence ----
@@ -730,7 +686,6 @@ public partial class MainWindow : Window
_libraryWatcher.Dispose();
_settings.Save();
_consoleFlushTimer.Stop();
- _libraryBlurTimer.Stop();
_gamepadTimer.Stop();
SdlLauncherGamepad.Shutdown();
_sndPreview.Stop();
@@ -1605,6 +1560,71 @@ public partial class MainWindow : Window
// ---- Game context menu ----
+ private void OnGameListSelectionChanged()
+ {
+ if (_restoringGameSelection)
+ {
+ return;
+ }
+
+ if (GameList.SelectedItem is AddFolderTile)
+ {
+ _restoringGameSelection = true;
+ try
+ {
+ GameList.SelectedItem = _lastSelectedGame;
+ }
+ finally
+ {
+ _restoringGameSelection = false;
+ }
+
+ // A held direction must not reopen the picker after it closes.
+ _navLeftNextAt = long.MaxValue;
+ _navRightNextAt = long.MaxValue;
+ StartAddFolderFromTile();
+ return;
+ }
+
+ _lastSelectedGame = GameList.SelectedItem as GameEntry;
+ UpdateSelectedGame();
+ }
+
+ private void OnGameListDoubleTapped(object? sender, TappedEventArgs e)
+ {
+ var item = (e.Source as Visual)?.FindAncestorOfType(includeSelf: true);
+ if (item?.DataContext is AddFolderTile)
+ {
+ e.Handled = true;
+ return;
+ }
+
+ LaunchSelected();
+ }
+
+ private void StartAddFolderFromTile()
+ {
+ if (_addFolderInProgress)
+ {
+ return;
+ }
+
+ _addFolderInProgress = true;
+ _ = AddFolderFromTileAsync();
+ }
+
+ private async Task AddFolderFromTileAsync()
+ {
+ try
+ {
+ await AddFolderAsync();
+ }
+ finally
+ {
+ _addFolderInProgress = false;
+ }
+ }
+
///
/// Selects the tile under the pointer before its context menu opens, and
/// suppresses the menu on empty grid space.
@@ -1746,7 +1766,6 @@ public partial class MainWindow : Window
}
else
{
- UpdateSelectedGameTexts();
UpdateRunButtons();
if (selectedAfter is not null && backgroundsChanged?.Contains(selectedAfter) == true)
{
@@ -1774,26 +1793,21 @@ public partial class MainWindow : Window
EmptyStateHint.Text = hasFilter
? Localization.Instance.Format("Library.Empty.SearchHint", query)
: Localization.Instance.Get("Library.Empty.Hint");
- EmptyAddFolderButton.IsVisible = !hasFilter;
}
private void UpdateSelectedGame()
{
if (GameList.SelectedItem is GameEntry game)
{
- UpdateSelectedGameTexts();
- SelectedCoverPanel.DataContext = game;
- SelectedBadgesRow.DataContext = game;
- SelectedBadgesRow.IsVisible = true;
+ LibrarySelectedDetails.DataContext = game;
+ LibrarySelectedDetails.IsVisible = true;
_ = UpdateBackdropAsync(game);
PlaySelectedGamePreview(game);
}
else
{
- UpdateSelectedGameTexts();
- SelectedCoverPanel.DataContext = null;
- SelectedBadgesRow.DataContext = null;
- SelectedBadgesRow.IsVisible = false;
+ LibrarySelectedDetails.DataContext = null;
+ LibrarySelectedDetails.IsVisible = false;
_ = UpdateBackdropAsync(null);
_sndPreview.Stop();
}
@@ -1801,25 +1815,6 @@ public partial class MainWindow : Window
UpdateRunButtons();
}
- ///
- /// Text-only refresh of the launch bar's title/path, split out of
- /// so a language change can re-apply it
- /// without restarting the backdrop fade or preview music.
- ///
- private void UpdateSelectedGameTexts()
- {
- if (GameList.SelectedItem is GameEntry game)
- {
- SelectedGameTitle.Text = game.Name;
- SelectedGamePath.Text = game.Path;
- }
- else
- {
- SelectedGameTitle.Text = Localization.Instance.Get("Launch.NoGameSelected");
- SelectedGamePath.Text = Localization.Instance.Get("Launch.NoGameHint");
- }
- }
-
///
/// Loops the selected game's sce_sys/snd0.at9 preview music, console
/// home screen style. Silent while a game is running or when disabled
@@ -1869,18 +1864,10 @@ public partial class MainWindow : Window
if (WindowState == WindowState.Minimized)
{
_sndPreview.Pause();
- if (SessionLoadingPopup is { } popup)
- {
- popup.IsOpen = false;
- }
}
else
{
_sndPreview.Resume();
- if (SessionLoadingPopup is { } popup)
- {
- popup.IsOpen = _sessionLoadingActive;
- }
}
}
}
@@ -2044,16 +2031,14 @@ public partial class MainWindow : Window
};
_isRunning = true;
+ _isStopping = false;
_runningGameName = displayName;
_runningGameTitleId = resolvedTitleId;
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
- StatusDot.Fill = SuccessLineBrush;
- StatusText.Text = Localization.Instance.Format("Launch.Running", displayName);
StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName);
UpdateRunButtons();
UpdateDiscordPresence();
- BeginSessionUi();
_pendingLaunch = new PendingLaunch(
Path.GetFullPath(ebootPath),
displayName,
@@ -2099,15 +2084,14 @@ public partial class MainWindow : Window
}
_isStopping = true;
- StopButton.IsEnabled = false;
- ShowSessionLoading("Closing game", "Waiting for the emulation session to exit...");
+ UpdateRunButtons();
_emulator.Stop();
_runningGameName = null;
_runningGameTitleId = null;
- StatusText.Text = Localization.Instance.Get("Launch.Stopping");
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
UpdateDiscordPresence();
- ReturnToLibraryWhileStopping();
+ ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
+ Console.Error.WriteLine("[GUI][INFO] Waiting for the SDL game process to exit.");
}
///
@@ -2149,7 +2133,7 @@ public partial class MainWindow : Window
_emulator?.Dispose();
_emulator = null;
_pendingLaunch = null;
- EndSessionUi();
+ ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
var meaningKey = exitCode switch
{
@@ -2171,10 +2155,6 @@ public partial class MainWindow : Window
brush);
CloseFileLogSoon();
- StatusDot.Fill = exitCode == 0 || stoppedByUser ? (IBrush)SuccessLineBrush : ErrorLineBrush;
- StatusText.Text = stoppedByUser
- ? "Game closed by the user."
- : Localization.Instance.Format("Launch.Exited", exitCode, meaning);
StatusBarRight.Text = Localization.Instance.Get("Status.Idle");
_runningGameName = null;
_runningGameTitleId = null;
@@ -2255,137 +2235,6 @@ public partial class MainWindow : Window
private void OnEmulatorOutput(string line, bool isError)
{
_pendingLines.Enqueue((line, isError));
- if (!line.Contains("Vulkan VideoOut presented first frame:", StringComparison.Ordinal) &&
- !line.Contains("Vulkan VideoOut ready:", StringComparison.Ordinal))
- {
- return;
- }
-
- Dispatcher.UIThread.Post(() =>
- {
- if (_isRunning && !_isStopping)
- {
- ShowSessionStatus("Game is running");
- }
- });
- }
-
- private void BeginSessionUi()
- {
- _isStopping = false;
- AnimateLibraryBlur(LaunchBlurRadius);
- ShowSessionLoading("Loading game", "Preparing the emulation session...");
- LaunchBar.IsVisible = true;
- }
-
- private void EndSessionUi()
- {
- HideSessionLoading();
- AnimateLibraryBlur(0, clearWhenComplete: true);
- LaunchBar.IsVisible = true;
- ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
- }
-
- private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false)
- {
- _libraryBlur ??= new BlurEffect();
- PagesHost.Effect = _libraryBlur;
-
- _libraryBlurStartRadius = _libraryBlur.Radius;
- _libraryBlurTargetRadius = Math.Max(0, targetRadius);
- _libraryBlurStartedAt = Stopwatch.GetTimestamp();
- _clearLibraryBlurWhenComplete = clearWhenComplete && _libraryBlurTargetRadius == 0;
-
- if (Math.Abs(_libraryBlurStartRadius - _libraryBlurTargetRadius) < 0.01)
- {
- CompleteLibraryBlur();
- return;
- }
-
- _libraryBlurTimer.Start();
- }
-
- private void AdvanceLibraryBlur()
- {
- if (_libraryBlur is null)
- {
- _libraryBlurTimer.Stop();
- return;
- }
-
- var elapsed = (Stopwatch.GetTimestamp() - _libraryBlurStartedAt) /
- (double)Stopwatch.Frequency;
- var progress = Math.Clamp(elapsed / BlurTransitionSeconds, 0, 1);
- // Cubic ease-out gives the loading transition a quick response while
- // keeping the final change of sharpness unobtrusive.
- var easedProgress = 1 - Math.Pow(1 - progress, 3);
- _libraryBlur.Radius = _libraryBlurStartRadius +
- ((_libraryBlurTargetRadius - _libraryBlurStartRadius) * easedProgress);
-
- if (progress >= 1)
- {
- CompleteLibraryBlur();
- }
- }
-
- private void CompleteLibraryBlur()
- {
- _libraryBlurTimer.Stop();
- if (_libraryBlur is not null)
- {
- _libraryBlur.Radius = _libraryBlurTargetRadius;
- }
-
- if (_clearLibraryBlurWhenComplete)
- {
- PagesHost.Effect = null;
- _libraryBlur = null;
- _clearLibraryBlurWhenComplete = false;
- }
- }
-
- private void ClearLibraryBlur()
- {
- _libraryBlurTimer.Stop();
- _libraryBlur = null;
- _clearLibraryBlurWhenComplete = false;
- PagesHost.Effect = null;
- }
-
- private void ShowSessionLoading(string title, string detail)
- {
- SessionLoadingTitle.Text = title;
- SessionLoadingTitle.IsVisible = true;
- SessionLoadingDetail.Text = detail;
- SessionLoadingDetail.IsVisible = true;
- SessionLoadingProgress.IsVisible = true;
- _sessionLoadingActive = true;
- SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
- }
-
- private void ShowSessionStatus(string message)
- {
- SessionLoadingTitle.Text = message;
- SessionLoadingTitle.IsVisible = true;
- SessionLoadingDetail.IsVisible = false;
- SessionLoadingProgress.IsVisible = false;
- _sessionLoadingActive = true;
- SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
- }
-
- private void HideSessionLoading()
- {
- _sessionLoadingActive = false;
- SessionLoadingPopup.IsOpen = false;
- }
-
- private void ReturnToLibraryWhileStopping()
- {
- AnimateLibraryBlur(LaunchBlurRadius);
- ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
- LaunchBar.IsVisible = true;
- UpdateRunButtons();
- Console.Error.WriteLine("[GUI][INFO] Waiting for the SDL game process to exit.");
}
private void OpenFileLog(string? titleId)
@@ -2442,8 +2291,23 @@ public partial class MainWindow : Window
private void UpdateRunButtons()
{
- LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry;
- StopButton.IsEnabled = _isRunning && !_isStopping;
+ LaunchButton.Classes.Remove("accent");
+ LaunchButton.Classes.Remove("danger");
+
+ if (_isRunning)
+ {
+ LaunchButton.Classes.Add("danger");
+ LaunchButton.Content = Localization.Instance.Get(
+ _isStopping ? "Launch.Stopping" : "Launch.Stop");
+ LaunchButton.IsEnabled = !_isStopping;
+ }
+ else
+ {
+ LaunchButton.Classes.Add("accent");
+ LaunchButton.Content = Localization.Instance.Get("Launch.Launch");
+ LaunchButton.IsEnabled = GameList.SelectedItem is GameEntry;
+ }
+
OpenFileButton.IsEnabled = !_isRunning;
}
diff --git a/src/SharpEmu.GUI/Themes/Styles/Library.axaml b/src/SharpEmu.GUI/Themes/Styles/Library.axaml
index c4ce1645..f104c2cf 100644
--- a/src/SharpEmu.GUI/Themes/Styles/Library.axaml
+++ b/src/SharpEmu.GUI/Themes/Styles/Library.axaml
@@ -6,32 +6,121 @@ Cover-art library item states and motion.
-
+
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/tests/SharpEmu.Libs.Tests/GUI/LibraryTileCollectionTests.cs b/tests/SharpEmu.Libs.Tests/GUI/LibraryTileCollectionTests.cs
new file mode 100644
index 00000000..d459992f
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/GUI/LibraryTileCollectionTests.cs
@@ -0,0 +1,52 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.GUI;
+using System.Collections;
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.GUI;
+
+public sealed class LibraryTileCollectionTests
+{
+ [Fact]
+ public void KeepsAddFolderTileAfterVisibleGames()
+ {
+ var games = new ObservableCollection();
+ var tiles = new LibraryTileCollection(games);
+ var first = CreateGame("First");
+ var second = CreateGame("Second");
+
+ games.Add(first);
+ games.Add(second);
+
+ Assert.Equal(3, tiles.Count);
+ Assert.True(tiles is IList { IsReadOnly: true });
+ Assert.Same(first, tiles[0]);
+ Assert.Same(second, tiles[1]);
+ Assert.Same(AddFolderTile.Instance, tiles[2]);
+ }
+
+ [Fact]
+ public void ForwardsGameChangesAtTheirOriginalIndices()
+ {
+ var games = new ObservableCollection();
+ var tiles = new LibraryTileCollection(games);
+ NotifyCollectionChangedEventArgs? change = null;
+ tiles.CollectionChanged += (_, args) => change = args;
+
+ var game = CreateGame("Game");
+ games.Add(game);
+
+ Assert.NotNull(change);
+ Assert.Equal(NotifyCollectionChangedAction.Add, change.Action);
+ Assert.Equal(0, change.NewStartingIndex);
+ Assert.Same(game, Assert.Single(change.NewItems!.Cast()));
+ Assert.Same(AddFolderTile.Instance, tiles[1]);
+ }
+
+ private static GameEntry CreateGame(string name) =>
+ new(name, null, null, $"/games/{name}/eboot.bin", 0, null, null);
+}