[GUI] Update library page layout (#690)

* [GUI] Add horizontal game tiles

* [GUI] Update launching elements layout

* [GUI] Change cards format to squares
This commit is contained in:
Daniel Freak
2026-07-30 02:47:15 +03:00
committed by GitHub
parent 996de70f52
commit 77f22973cb
23 changed files with 542 additions and 414 deletions
+113 -249
View File
@@ -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<GameEntry> _allGames = new();
private readonly ObservableCollection<GameEntry> _visibleGames = new();
private readonly LibraryTileCollection _libraryTiles;
private readonly GameLibraryWatcher _libraryWatcher = new();
private readonly AvaloniaList<LogLine> _consoleLines = new();
private readonly List<LogLine> _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<HostDisplayOption> _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<ListBoxItem>(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;
}
}
/// <summary>
/// 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();
}
/// <summary>
/// Text-only refresh of the launch bar's title/path, split out of
/// <see cref="UpdateSelectedGame"/> so a language change can re-apply it
/// without restarting the backdrop fade or preview music.
/// </summary>
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");
}
}
/// <summary>
/// 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.");
}
/// <summary>
@@ -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;
}