[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
+16
View File
@@ -0,0 +1,16 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.GUI;
/// <summary>
/// Stateless trailing action that opens the library folder picker.
/// </summary>
public sealed class AddFolderTile : LibraryTile
{
public static AddFolderTile Instance { get; } = new();
private AddFolderTile()
{
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ internal enum GameEntryChanges
Background = 4, Background = 4,
} }
public sealed class GameEntry : INotifyPropertyChanged public sealed class GameEntry : LibraryTile, INotifyPropertyChanged
{ {
// Placeholder gradients for games without cover art, picked // Placeholder gradients for games without cover art, picked
// deterministically from the game name so a game keeps its color. // deterministically from the game name so a game keeps its color.
+1 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} لعبة", "Page.GameCount.Other": "{0} لعبة",
"Library.SearchWatermark": "ابحث في المكتبة...", "Library.SearchWatermark": "ابحث في المكتبة...",
"Library.AddFolder": " إضافة مجلد", "Library.AddFolder": "إضافة مجلد",
"Library.OpenFile": "فتح ملف...", "Library.OpenFile": "فتح ملف...",
"Library.Context.Launch": "تشغيل", "Library.Context.Launch": "تشغيل",
+1 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} jogos", "Page.GameCount.Other": "{0} jogos",
"Library.SearchWatermark": "Pesquisar na biblioteca…", "Library.SearchWatermark": "Pesquisar na biblioteca…",
"Library.AddFolder": " Adicionar pasta", "Library.AddFolder": "Adicionar pasta",
"Library.OpenFile": "Abrir arquivo…", "Library.OpenFile": "Abrir arquivo…",
"Library.Context.Launch": "Jogar", "Library.Context.Launch": "Jogar",
+1 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} Spiele", "Page.GameCount.Other": "{0} Spiele",
"Library.SearchWatermark": "Bibliothek durchsuchen…", "Library.SearchWatermark": "Bibliothek durchsuchen…",
"Library.AddFolder": " Spielordner hinzufügen", "Library.AddFolder": "Spielordner hinzufügen",
"Library.OpenFile": "Datei öffnen…", "Library.OpenFile": "Datei öffnen…",
"Library.Context.Launch": "Starten", "Library.Context.Launch": "Starten",
+1 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} spil", "Page.GameCount.Other": "{0} spil",
"Library.SearchWatermark": "Søg i biblioteket…", "Library.SearchWatermark": "Søg i biblioteket…",
"Library.AddFolder": " Tilføj mappe", "Library.AddFolder": "Tilføj mappe",
"Library.OpenFile": "Åbn fil…", "Library.OpenFile": "Åbn fil…",
"Library.Context.Launch": "Start", "Library.Context.Launch": "Start",
+1 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} games", "Page.GameCount.Other": "{0} games",
"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.Context.Launch": "Launch", "Library.Context.Launch": "Launch",
+1 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} juegos", "Page.GameCount.Other": "{0} juegos",
"Library.SearchWatermark": "Buscar en la biblioteca…", "Library.SearchWatermark": "Buscar en la biblioteca…",
"Library.AddFolder": " Añadir carpeta", "Library.AddFolder": "Añadir carpeta",
"Library.OpenFile": "Abrir archivo…", "Library.OpenFile": "Abrir archivo…",
"Library.Context.Launch": "Iniciar", "Library.Context.Launch": "Iniciar",
+1 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} jeux", "Page.GameCount.Other": "{0} jeux",
"Library.SearchWatermark": "Rechercher dans la bibliothèque…", "Library.SearchWatermark": "Rechercher dans la bibliothèque…",
"Library.AddFolder": " Ajouter un dossier", "Library.AddFolder": "Ajouter un dossier",
"Library.OpenFile": "Ouvrir un fichier…", "Library.OpenFile": "Ouvrir un fichier…",
"Library.Context.Launch": "Lancer", "Library.Context.Launch": "Lancer",
+1 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} játékok", "Page.GameCount.Other": "{0} játékok",
"Library.SearchWatermark": "Keresés a könyvtárban", "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.OpenFile": "Fájl megnyitása…",
"Library.Context.Launch": "Inditás", "Library.Context.Launch": "Inditás",
+1 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} giochi", "Page.GameCount.Other": "{0} giochi",
"Library.SearchWatermark": "Cerca nella libreria…", "Library.SearchWatermark": "Cerca nella libreria…",
"Library.AddFolder": " Aggiungi cartella", "Library.AddFolder": "Aggiungi cartella",
"Library.OpenFile": "Apri file…", "Library.OpenFile": "Apri file…",
"Library.Context.Launch": "Avvia", "Library.Context.Launch": "Avvia",
+1 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "ゲーム {0}本", "Page.GameCount.Other": "ゲーム {0}本",
"Library.SearchWatermark": "ライブラリを検索…", "Library.SearchWatermark": "ライブラリを検索…",
"Library.AddFolder": " フォルダーを追加", "Library.AddFolder": "フォルダーを追加",
"Library.OpenFile": "ファイルを開く…", "Library.OpenFile": "ファイルを開く…",
"Library.Context.Launch": "起動", "Library.Context.Launch": "起動",
+1 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "게임 {0}개", "Page.GameCount.Other": "게임 {0}개",
"Library.SearchWatermark": "라이브러리 검색…", "Library.SearchWatermark": "라이브러리 검색…",
"Library.AddFolder": " 폴더 추가", "Library.AddFolder": "폴더 추가",
"Library.OpenFile": "파일 열기…", "Library.OpenFile": "파일 열기…",
"Library.Context.Launch": "실행", "Library.Context.Launch": "실행",
+1 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} games", "Page.GameCount.Other": "{0} games",
"Library.SearchWatermark": "Zoeken in bibliotheek…", "Library.SearchWatermark": "Zoeken in bibliotheek…",
"Library.AddFolder": " Map toevoegen", "Library.AddFolder": "Map toevoegen",
"Library.OpenFile": "Bestand openen…", "Library.OpenFile": "Bestand openen…",
"Library.Context.Launch": "Starten", "Library.Context.Launch": "Starten",
+1 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} jogos", "Page.GameCount.Other": "{0} jogos",
"Library.SearchWatermark": "Pesquisar biblioteca…", "Library.SearchWatermark": "Pesquisar biblioteca…",
"Library.AddFolder": " Adicionar pasta", "Library.AddFolder": "Adicionar pasta",
"Library.OpenFile": "Abrir ficheiro…", "Library.OpenFile": "Abrir ficheiro…",
"Library.Context.Launch": "Iniciar", "Library.Context.Launch": "Iniciar",
+1 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "Игр: {0}", "Page.GameCount.Other": "Игр: {0}",
"Library.SearchWatermark": "Поиск…", "Library.SearchWatermark": "Поиск…",
"Library.AddFolder": " Добавить папку", "Library.AddFolder": "Добавить папку",
"Library.OpenFile": "Открыть файл…", "Library.OpenFile": "Открыть файл…",
"Library.Context.Launch": "Запустить", "Library.Context.Launch": "Запустить",
+1 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} oyun", "Page.GameCount.Other": "{0} oyun",
"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.Context.Launch": "Başlat", "Library.Context.Launch": "Başlat",
+9
View File
@@ -0,0 +1,9 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.GUI;
/// <summary>
/// Base type for game cards and actions shown in the library rail.
/// </summary>
public abstract class LibraryTile;
+103
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, INotifyCollectionChanged
{
private readonly ObservableCollection<GameEntry> _games;
public LibraryTileCollection(ObservableCollection<GameEntry> 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<LibraryTile> 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);
}
+128 -133
View File
@@ -6,7 +6,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SharpEmu.GUI" xmlns:local="clr-namespace:SharpEmu.GUI"
xmlns:primitives="clr-namespace:Avalonia.Controls.Primitives;assembly=Avalonia.Controls"
x:Class="SharpEmu.GUI.MainWindow" x:Class="SharpEmu.GUI.MainWindow"
Title="SharpEmu" Title="SharpEmu"
Width="1280" Height="820" Width="1280" Height="820"
@@ -91,7 +90,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Grid> </Grid>
<!-- Main content --> <!-- Main content -->
<Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto"> <Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto">
<!-- Library / Options page switcher, with the library toolbar sharing <!-- Library / Options page switcher, with the library toolbar sharing
the same row on the right. Plain buttons (not TabItem) so there is the same row on the right. Plain buttons (not TabItem) so there is
@@ -119,10 +118,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
PlaceholderText="{Binding [Library.SearchWatermark], Source={x:Static local:Localization.Instance}}" PlaceholderText="{Binding [Library.SearchWatermark], Source={x:Static local:Localization.Instance}}"
Width="240" Width="240"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
<Button x:Name="AddFolderButton"
Classes="ghost"
Content="{Binding [Library.AddFolder], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
<Button x:Name="OpenFileButton" <Button x:Name="OpenFileButton"
Classes="ghost" Classes="ghost"
Content="{Binding [Library.OpenFile], Source={x:Static local:Localization.Instance}}" Content="{Binding [Library.OpenFile], Source={x:Static local:Localization.Instance}}"
@@ -132,12 +127,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Panel Grid.Row="1" x:Name="PagesHost"> <Panel Grid.Row="1" x:Name="PagesHost">
<!-- Library page. The tile row gets extra top margin so it sits <!-- Library page. Covers use a horizontal virtualized rail so the
closer to eye level (PS5 home-screen style) instead of hugging number of realized images stays bounded for large libraries. -->
the Library/Options switcher above it. --> <Grid x:Name="LibraryPage" Margin="0,46,0,0" RowDefinitions="188,*">
<Panel x:Name="LibraryPage" Margin="0,64,0,0">
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent" <ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
SelectionMode="Single" Padding="0"> Grid.Row="0"
SelectionMode="Single" Padding="4,0,28,0">
<ListBox.ContextMenu> <ListBox.ContextMenu>
<ContextMenu x:Name="GameContextMenu" Placement="Pointer"> <ContextMenu x:Name="GameContextMenu" Placement="Pointer">
<MenuItem x:Name="CtxLaunch" <MenuItem x:Name="CtxLaunch"
@@ -190,49 +185,135 @@ SPDX-License-Identifier: GPL-2.0-or-later
</ListBox.ContextMenu> </ListBox.ContextMenu>
<ListBox.ItemsPanel> <ListBox.ItemsPanel>
<ItemsPanelTemplate> <ItemsPanelTemplate>
<WrapPanel /> <VirtualizingStackPanel Orientation="Horizontal" />
</ItemsPanelTemplate> </ItemsPanelTemplate>
</ListBox.ItemsPanel> </ListBox.ItemsPanel>
<ListBox.ItemTemplate> <ListBox.DataTemplates>
<DataTemplate x:DataType="local:GameEntry" x:CompileBindings="True"> <DataTemplate x:DataType="local:GameEntry" x:CompileBindings="True">
<StackPanel Width="128" Height="172" Spacing="7"> <Border Classes="coverShadow libraryGameCard"
<Border Classes="coverShadow" Width="128" Height="128"> Width="148"
<Border Classes="coverClip"> Height="148"
<Panel> ToolTip.Tip="{Binding Name}"
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}"> AutomationProperties.Name="{Binding Name}">
<TextBlock Text="{Binding Initials}" FontSize="36" FontWeight="Bold" <Border Classes="coverClip">
Foreground="#E8ECF4" Opacity="0.85" <Panel>
HorizontalAlignment="Center" VerticalAlignment="Center" /> <Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
</Border> <TextBlock Text="{Binding Initials}" FontSize="38" FontWeight="Light"
<Image Source="{Binding Cover}" Stretch="UniformToFill" IsVisible="{Binding HasCover}" /> Foreground="#C4E8ECF4"
</Panel> HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border> </Border>
<Image Source="{Binding Cover}"
Stretch="UniformToFill"
RenderOptions.BitmapInterpolationMode="LowQuality"
IsVisible="{Binding HasCover}" />
</Panel>
</Border> </Border>
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold" </Border>
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
TextAlignment="Center" HorizontalAlignment="Center" />
</StackPanel>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> <DataTemplate x:DataType="local:AddFolderTile">
<Border Classes="addFolderTile"
Width="148"
Height="148"
ToolTip.Tip="{Binding [Library.AddFolder],
Source={x:Static local:Localization.Instance},
x:CompileBindings=False}"
AutomationProperties.Name="{Binding [Library.AddFolder],
Source={x:Static local:Localization.Instance},
x:CompileBindings=False}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12">
<Border Classes="addFolderTileIcon">
<TextBlock Classes="addFolderGlyph addFolderIconGlyph"
Text="+"
FontSize="34"
FontWeight="Light"
LineHeight="34"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<TextBlock Classes="addFolderGlyph"
Text="{Binding [Library.AddFolder],
Source={x:Static local:Localization.Instance},
x:CompileBindings=False}"
FontSize="12"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
</StackPanel>
</Border>
</DataTemplate>
</ListBox.DataTemplates>
</ListBox> </ListBox>
<!-- Selected-game identity and actions share one detail surface so
the selected title and metadata are never rendered twice. -->
<StackPanel x:Name="LibrarySelectedDetails"
Grid.Row="1"
x:DataType="local:GameEntry"
x:CompileBindings="True"
IsVisible="False"
MaxWidth="980"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Margin="4,8,0,0"
Spacing="18">
<TextBlock Text="{Binding Name}"
FontSize="42"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Border Classes="pill" IsVisible="{Binding HasTitleId}">
<TextBlock Text="{Binding TitleId}" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
<Border Classes="pill" IsVisible="{Binding HasVersion}">
<TextBlock Text="{Binding VersionText}" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
<Border Classes="pill">
<TextBlock Text="{Binding SizeText}" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
</StackPanel>
<Grid Width="360"
ColumnDefinitions="*,*"
ColumnSpacing="8"
HorizontalAlignment="Left">
<Button x:Name="LaunchButton"
Grid.Column="0"
Classes="accent"
Content="Launch"
HorizontalAlignment="Stretch"
IsEnabled="False" />
<ToggleButton x:Name="ConsoleToggle"
Grid.Column="1"
Classes="ghost"
Padding="22,10"
HorizontalAlignment="Stretch"
Content="{Binding [Launch.Console],
Source={x:Static local:Localization.Instance},
x:CompileBindings=False}" />
</Grid>
</StackPanel>
<!-- Empty state --> <!-- Empty state -->
<StackPanel x:Name="EmptyState" Spacing="10" HorizontalAlignment="Center" VerticalAlignment="Center" <StackPanel x:Name="EmptyState" Grid.RowSpan="2"
Spacing="10" HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="False"> IsVisible="False">
<TextBlock Text="🎮" FontSize="44" HorizontalAlignment="Center" Opacity="0.7" /> <TextBlock Text="🎮" FontSize="44" HorizontalAlignment="Center" Opacity="0.7" />
<TextBlock x:Name="EmptyStateTitle" Text="Your library is empty" FontSize="18" FontWeight="SemiBold" <TextBlock x:Name="EmptyStateTitle" Text="Your library is empty" FontSize="18" FontWeight="SemiBold"
HorizontalAlignment="Center" /> HorizontalAlignment="Center" />
<TextBlock x:Name="EmptyStateHint" Text="Add a folder containing your games to get started." <TextBlock x:Name="EmptyStateHint" Text="Add a folder containing your games to get started."
FontSize="13" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" /> FontSize="13" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
<Button x:Name="EmptyAddFolderButton" Classes="accent"
Content="{Binding [Library.Empty.AddFolder], Source={x:Static local:Localization.Instance}}"
HorizontalAlignment="Center" Margin="0,8,0,0" />
</StackPanel> </StackPanel>
<!-- Loading state: covers the grid while a scan (initial load, <!-- Loading state: covers the grid while a scan (initial load,
rescan, or a just-added folder) is in flight, so the screen rescan, or a just-added folder) is in flight, so the screen
never looks blank mid-scan. --> never looks blank mid-scan. -->
<StackPanel x:Name="LoadingState" Spacing="14" HorizontalAlignment="Center" VerticalAlignment="Center" <StackPanel x:Name="LoadingState" Grid.RowSpan="2"
Spacing="14" HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="False"> IsVisible="False">
<ProgressBar IsIndeterminate="True" Width="180" Height="3" /> <ProgressBar IsIndeterminate="True" Width="180" Height="3" />
<TextBlock x:Name="LoadingStateText" <TextBlock x:Name="LoadingStateText"
@@ -240,11 +321,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
FontSize="13" FontSize="13"
Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" /> Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
</StackPanel> </StackPanel>
</Panel> </Grid>
<!-- Options page: sub-tabs so future categories (Graphics, …) slot <!-- Options page: sub-tabs so future categories (Graphics, …) slot
in next to General. Card-grouped sections match the visual in next to General. Card-grouped sections match the visual
language used by the console panel and launch bar below. --> language used by the console panel below. -->
<Grid x:Name="OptionsPage" IsVisible="False"> <Grid x:Name="OptionsPage" IsVisible="False">
<TabControl> <TabControl>
<TabItem x:Name="GeneralTabItem" <TabItem x:Name="GeneralTabItem"
@@ -670,7 +751,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Border Grid.Row="2" x:Name="ConsolePanel" Classes="card" Padding="0" Height="240" <Border Grid.Row="2" x:Name="ConsolePanel" Classes="card" Padding="0" Height="240"
Margin="0,12,0,0" IsVisible="False"> Margin="0,12,0,0" IsVisible="False">
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto" Margin="16,12,16,8"> <Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto,Auto" Margin="16,12,16,8">
<TextBlock x:Name="ConsoleSectionTitle" Classes="sectionTitle" <TextBlock x:Name="ConsoleSectionTitle" Classes="sectionTitle"
Text="{Binding [Console.Title], Source={x:Static local:Localization.Instance}}" Text="{Binding [Console.Title], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
@@ -692,7 +773,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Button Grid.Column="5" x:Name="ClearLogButton" Classes="ghost" <Button Grid.Column="5" x:Name="ClearLogButton" Classes="ghost"
Content="{Binding [Console.Clear], Source={x:Static local:Localization.Instance}}" Content="{Binding [Console.Clear], Source={x:Static local:Localization.Instance}}"
FontSize="12" FontSize="12"
Padding="10,4" /> Padding="10,4" Margin="0,0,8,0" />
<Button Grid.Column="6"
x:Name="CloseConsoleButton"
Classes="ghost"
Content="×"
AutomationProperties.Name="{Binding [Launch.Console],
Source={x:Static local:Localization.Instance}}"
FontSize="16"
Padding="10,2" />
</Grid> </Grid>
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0" <ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
BorderBrush="{StaticResource CardBorderBrush}" CornerRadius="0,0,12,12"> BorderBrush="{StaticResource CardBorderBrush}" CornerRadius="0,0,12,12">
@@ -705,102 +794,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Grid> </Grid>
</Border> </Border>
<!-- Launch bar; capped so it does not sprawl on a maximized window. -->
<Border Grid.Row="3" x:Name="LaunchBar" Classes="card" Margin="0,12,0,0" Padding="14" MaxWidth="1280">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,*,Auto">
<!-- Selected game cover thumbnail -->
<Border Grid.Column="0" Classes="coverClip" Width="56" Height="56" CornerRadius="8"
VerticalAlignment="Center">
<Panel x:Name="SelectedCoverPanel"
x:DataType="local:GameEntry"
x:CompileBindings="True">
<Border Background="{Binding PlaceholderBrush, FallbackValue={x:Null}}"
IsVisible="{Binding !HasCover, FallbackValue=False}">
<TextBlock Text="{Binding Initials}" FontSize="20" FontWeight="Bold"
Foreground="#E8ECF4" Opacity="0.85"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<Image Source="{Binding Cover}" Stretch="UniformToFill"
IsVisible="{Binding HasCover, FallbackValue=False}" />
</Panel>
</Border>
<StackPanel Grid.Column="1" Spacing="4" VerticalAlignment="Center" Margin="14,0,14,0">
<Grid ColumnDefinitions="Auto,Auto,*">
<TextBlock Grid.Column="0" x:Name="SelectedGameTitle" Text="No game selected" FontSize="16"
FontWeight="Bold" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
MaxWidth="340" Margin="0,0,10,0" />
<!-- Title id / version / size badges, right next to the
title. The title's own MaxWidth (not a "*" column) is
what keeps them from drifting to the far right. -->
<StackPanel Grid.Column="1" x:Name="SelectedBadgesRow"
x:DataType="local:GameEntry"
x:CompileBindings="True"
Orientation="Horizontal" Spacing="6"
IsVisible="False" VerticalAlignment="Center">
<Border Classes="pill" IsVisible="{Binding HasTitleId, FallbackValue=False}">
<TextBlock Text="{Binding TitleId}" FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
<Border Classes="pill" IsVisible="{Binding HasVersion, FallbackValue=False}">
<TextBlock Text="{Binding VersionText}" FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
<Border Classes="pill">
<TextBlock Text="{Binding SizeText, FallbackValue=''}" FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
</StackPanel>
</Grid>
<TextBlock x:Name="SelectedGamePath" Text="Pick a game from the library, or open an eboot.bin directly."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="7">
<Ellipse x:Name="StatusDot" Width="8" Height="8" Fill="{StaticResource FaintBrush}"
VerticalAlignment="Center" />
<TextBlock x:Name="StatusText" Text="Idle" FontSize="11" Foreground="{StaticResource MutedBrush}"
VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<ToggleButton x:Name="ConsoleToggle" Classes="ghost"
Content="{Binding [Launch.Console], Source={x:Static local:Localization.Instance}}" />
<Button x:Name="LaunchButton" Classes="accent"
Content="{Binding [Launch.Launch], Source={x:Static local:Localization.Instance}}"
IsEnabled="False" />
<Button x:Name="StopButton" Classes="danger"
Content="{Binding [Launch.Stop], Source={x:Static local:Localization.Instance}}"
IsEnabled="False" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
</Grid> </Grid>
<!-- Keep launch progress above the blurred library while the SDL game
process owns its independent top-level window. -->
<primitives:Popup x:Name="SessionLoadingPopup"
IsOpen="False"
PlacementTarget="{Binding #MainContent}"
Placement="Center"
Topmost="True"
ShouldUseOverlayLayer="False"
TakesFocusFromNativeControl="False"
IsLightDismissEnabled="False">
<Border Classes="card" Width="380" CornerRadius="18" Padding="22,18">
<StackPanel Spacing="12">
<TextBlock x:Name="SessionLoadingTitle" Text="Loading game" FontSize="16" FontWeight="SemiBold" />
<TextBlock x:Name="SessionLoadingDetail" Text="Preparing the emulation session..." FontSize="12"
Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
<ProgressBar x:Name="SessionLoadingProgress" IsIndeterminate="True" Height="5" />
</StackPanel>
</Border>
</primitives:Popup>
<!-- Status bar --> <!-- Status bar -->
<Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}" <Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}"
ColumnDefinitions="*,Auto"> ColumnDefinitions="*,Auto">
+113 -249
View File
@@ -33,8 +33,6 @@ 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 const double LaunchBlurRadius = 12;
private const double BlurTransitionSeconds = 0.24;
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"));
@@ -83,17 +81,12 @@ public partial class MainWindow : Window
]; ];
private readonly List<GameEntry> _allGames = new(); private readonly List<GameEntry> _allGames = new();
private readonly ObservableCollection<GameEntry> _visibleGames = new(); private readonly ObservableCollection<GameEntry> _visibleGames = new();
private readonly LibraryTileCollection _libraryTiles;
private readonly GameLibraryWatcher _libraryWatcher = new(); private readonly GameLibraryWatcher _libraryWatcher = new();
private readonly AvaloniaList<LogLine> _consoleLines = new(); private readonly AvaloniaList<LogLine> _consoleLines = new();
private readonly List<LogLine> _allConsoleLines = new(); private readonly List<LogLine> _allConsoleLines = new();
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new(); private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
private readonly DispatcherTimer _consoleFlushTimer; 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 GuiSettings _settings = new();
private IReadOnlyList<HostDisplayOption> _hostDisplays = []; private IReadOnlyList<HostDisplayOption> _hostDisplays = [];
@@ -123,23 +116,19 @@ public partial class MainWindow : Window
private int _detailLoadGeneration; private int _detailLoadGeneration;
private int _backdropGeneration; private int _backdropGeneration;
private bool _isClosing; private bool _isClosing;
private bool _restoringGameSelection;
private bool _addFolderInProgress;
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
// plain window color remains the fallback when the asset fails to load. // plain window color remains the fallback when the asset fails to load.
private Bitmap? _defaultBackdrop; 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. // Controller navigation state.
private readonly DispatcherTimer _gamepadTimer; private readonly DispatcherTimer _gamepadTimer;
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();
@@ -156,6 +145,7 @@ public partial class MainWindow : Window
{ {
InitializeComponent(); InitializeComponent();
InitializeLocalizedChoiceBoxes(); InitializeLocalizedChoiceBoxes();
_libraryTiles = new LibraryTileCollection(_visibleGames);
try try
{ {
@@ -169,7 +159,7 @@ public partial class MainWindow : Window
_defaultBackdrop = null; // color background remains the fallback _defaultBackdrop = null; // color background remains the fallback
} }
GameList.ItemsSource = _visibleGames; GameList.ItemsSource = _libraryTiles;
_libraryWatcher.RefreshRequested += OnLibraryRefreshRequested; _libraryWatcher.RefreshRequested += OnLibraryRefreshRequested;
ConsoleList.ItemsSource = _consoleLines; ConsoleList.ItemsSource = _consoleLines;
_consoleMirror = GuiConsoleMirror.Install((line, isError) => _consoleMirror = GuiConsoleMirror.Install((line, isError) =>
@@ -187,23 +177,6 @@ public partial class MainWindow : Window
}; };
_consoleFlushTimer.Start(); _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.PointerPressed += OnTitleBarPointerPressed;
TitleBar.DoubleTapped += OnTitleBarDoubleTapped; TitleBar.DoubleTapped += OnTitleBarDoubleTapped;
MinimizeButton.Click += (_, _) => WindowState = WindowState.Minimized; MinimizeButton.Click += (_, _) => WindowState = WindowState.Minimized;
@@ -221,18 +194,26 @@ public partial class MainWindow : Window
UpdateWindowChromeState(); UpdateWindowChromeState();
}; };
UpdateWindowChromeState(); UpdateWindowChromeState();
GameList.SelectionChanged += (_, _) => UpdateSelectedGame(); GameList.SelectionChanged += (_, _) => OnGameListSelectionChanged();
GameList.DoubleTapped += (_, _) => LaunchSelected(); GameList.DoubleTapped += OnGameListDoubleTapped;
SearchBox.TextChanged += (_, _) => RefreshVisibleGames(); SearchBox.TextChanged += (_, _) => RefreshVisibleGames();
ConsoleSearchBox.TextChanged += (_, _) => RefreshVisibleConsoleLines(); ConsoleSearchBox.TextChanged += (_, _) => RefreshVisibleConsoleLines();
AddFolderButton.Click += async (_, _) => await AddFolderAsync();
EmptyAddFolderButton.Click += async (_, _) => await AddFolderAsync();
OpenFileButton.Click += async (_, _) => await OpenFileAsync(); OpenFileButton.Click += async (_, _) => await OpenFileAsync();
LaunchButton.Click += (_, _) => LaunchSelected(); LaunchButton.Click += (_, _) =>
{
if (_isRunning)
{
StopEmulator();
}
else
{
LaunchSelected();
}
};
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); }; ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
StopButton.Click += (_, _) => StopEmulator();
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync(); CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow(); DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
CloseConsoleButton.Click += (_, _) => ConsoleToggle.IsChecked = false;
LibraryTabButton.Click += (_, _) => SetActivePage(0); LibraryTabButton.Click += (_, _) => SetActivePage(0);
OptionsTabButton.Click += (_, _) => SetActivePage(1); OptionsTabButton.Click += (_, _) => SetActivePage(1);
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null; ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
@@ -520,8 +501,6 @@ public partial class MainWindow : Window
var now = Environment.TickCount64; var now = Environment.TickCount64;
var left = (pad.Buttons & HostGamepadButtons.Left) != 0 || pad.LeftX < 64; var left = (pad.Buttons & HostGamepadButtons.Left) != 0 || pad.LeftX < 64;
var right = (pad.Buttons & HostGamepadButtons.Right) != 0 || pad.LeftX > 192; 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)) if (ShouldNavigate(left, ref _navLeftNextAt, now))
{ {
@@ -533,16 +512,6 @@ public partial class MainWindow : Window
MoveSelection(1); MoveSelection(1);
} }
if (ShouldNavigate(up, ref _navUpNextAt, now))
{
MoveSelection(-TilesPerRow());
}
if (ShouldNavigate(down, ref _navDownNextAt, now))
{
MoveSelection(TilesPerRow());
}
var pressed = pad.Buttons & ~_previousPadButtons; var pressed = pad.Buttons & ~_previousPadButtons;
if ((pressed & HostGamepadButtons.Cross) != 0) if ((pressed & HostGamepadButtons.Cross) != 0)
{ {
@@ -581,26 +550,13 @@ public partial class MainWindow : Window
private void MoveSelection(int delta) private void MoveSelection(int delta)
{ {
if (_visibleGames.Count == 0)
{
return;
}
var index = GameList.SelectedIndex < 0 var index = GameList.SelectedIndex < 0
? 0 ? 0
: Math.Clamp(GameList.SelectedIndex + delta, 0, _visibleGames.Count - 1); : Math.Clamp(GameList.SelectedIndex + delta, 0, _libraryTiles.Count - 1);
GameList.SelectedIndex = index; GameList.SelectedIndex = index;
GameList.ScrollIntoView(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() private async Task OnOpenedAsync()
{ {
var version = Assembly.GetExecutingAssembly().GetName().Version; var version = Assembly.GetExecutingAssembly().GetName().Version;
@@ -662,7 +618,7 @@ public partial class MainWindow : Window
RefreshHostRefreshRates(_settings.RefreshRate); RefreshHostRefreshRates(_settings.RefreshRate);
RefreshUpdateText(); RefreshUpdateText();
UpdateEmptyStateTexts(); UpdateEmptyStateTexts();
UpdateSelectedGameTexts(); UpdateRunButtons();
} }
// ---- Discord Rich Presence ---- // ---- Discord Rich Presence ----
@@ -730,7 +686,6 @@ public partial class MainWindow : Window
_libraryWatcher.Dispose(); _libraryWatcher.Dispose();
_settings.Save(); _settings.Save();
_consoleFlushTimer.Stop(); _consoleFlushTimer.Stop();
_libraryBlurTimer.Stop();
_gamepadTimer.Stop(); _gamepadTimer.Stop();
SdlLauncherGamepad.Shutdown(); SdlLauncherGamepad.Shutdown();
_sndPreview.Stop(); _sndPreview.Stop();
@@ -1605,6 +1560,71 @@ public partial class MainWindow : Window
// ---- Game context menu ---- // ---- 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> /// <summary>
/// Selects the tile under the pointer before its context menu opens, and /// Selects the tile under the pointer before its context menu opens, and
/// suppresses the menu on empty grid space. /// suppresses the menu on empty grid space.
@@ -1746,7 +1766,6 @@ public partial class MainWindow : Window
} }
else else
{ {
UpdateSelectedGameTexts();
UpdateRunButtons(); UpdateRunButtons();
if (selectedAfter is not null && backgroundsChanged?.Contains(selectedAfter) == true) if (selectedAfter is not null && backgroundsChanged?.Contains(selectedAfter) == true)
{ {
@@ -1774,26 +1793,21 @@ public partial class MainWindow : Window
EmptyStateHint.Text = hasFilter EmptyStateHint.Text = hasFilter
? Localization.Instance.Format("Library.Empty.SearchHint", query) ? Localization.Instance.Format("Library.Empty.SearchHint", query)
: Localization.Instance.Get("Library.Empty.Hint"); : Localization.Instance.Get("Library.Empty.Hint");
EmptyAddFolderButton.IsVisible = !hasFilter;
} }
private void UpdateSelectedGame() private void UpdateSelectedGame()
{ {
if (GameList.SelectedItem is GameEntry game) if (GameList.SelectedItem is GameEntry game)
{ {
UpdateSelectedGameTexts(); LibrarySelectedDetails.DataContext = game;
SelectedCoverPanel.DataContext = game; LibrarySelectedDetails.IsVisible = true;
SelectedBadgesRow.DataContext = game;
SelectedBadgesRow.IsVisible = true;
_ = UpdateBackdropAsync(game); _ = UpdateBackdropAsync(game);
PlaySelectedGamePreview(game); PlaySelectedGamePreview(game);
} }
else else
{ {
UpdateSelectedGameTexts(); LibrarySelectedDetails.DataContext = null;
SelectedCoverPanel.DataContext = null; LibrarySelectedDetails.IsVisible = false;
SelectedBadgesRow.DataContext = null;
SelectedBadgesRow.IsVisible = false;
_ = UpdateBackdropAsync(null); _ = UpdateBackdropAsync(null);
_sndPreview.Stop(); _sndPreview.Stop();
} }
@@ -1801,25 +1815,6 @@ public partial class MainWindow : Window
UpdateRunButtons(); 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> /// <summary>
/// Loops the selected game's sce_sys/snd0.at9 preview music, console /// Loops the selected game's sce_sys/snd0.at9 preview music, console
/// home screen style. Silent while a game is running or when disabled /// 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) if (WindowState == WindowState.Minimized)
{ {
_sndPreview.Pause(); _sndPreview.Pause();
if (SessionLoadingPopup is { } popup)
{
popup.IsOpen = false;
}
} }
else else
{ {
_sndPreview.Resume(); _sndPreview.Resume();
if (SessionLoadingPopup is { } popup)
{
popup.IsOpen = _sessionLoadingActive;
}
} }
} }
} }
@@ -2044,16 +2031,14 @@ public partial class MainWindow : Window
}; };
_isRunning = true; _isRunning = true;
_isStopping = false;
_runningGameName = displayName; _runningGameName = displayName;
_runningGameTitleId = resolvedTitleId; _runningGameTitleId = resolvedTitleId;
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); _runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
StatusDot.Fill = SuccessLineBrush;
StatusText.Text = Localization.Instance.Format("Launch.Running", displayName);
StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName); StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName);
UpdateRunButtons(); UpdateRunButtons();
UpdateDiscordPresence(); UpdateDiscordPresence();
BeginSessionUi();
_pendingLaunch = new PendingLaunch( _pendingLaunch = new PendingLaunch(
Path.GetFullPath(ebootPath), Path.GetFullPath(ebootPath),
displayName, displayName,
@@ -2099,15 +2084,14 @@ public partial class MainWindow : Window
} }
_isStopping = true; _isStopping = true;
StopButton.IsEnabled = false; UpdateRunButtons();
ShowSessionLoading("Closing game", "Waiting for the emulation session to exit...");
_emulator.Stop(); _emulator.Stop();
_runningGameName = null; _runningGameName = null;
_runningGameTitleId = null; _runningGameTitleId = null;
StatusText.Text = Localization.Instance.Get("Launch.Stopping");
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping"); StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
UpdateDiscordPresence(); UpdateDiscordPresence();
ReturnToLibraryWhileStopping(); ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
Console.Error.WriteLine("[GUI][INFO] Waiting for the SDL game process to exit.");
} }
/// <summary> /// <summary>
@@ -2149,7 +2133,7 @@ public partial class MainWindow : Window
_emulator?.Dispose(); _emulator?.Dispose();
_emulator = null; _emulator = null;
_pendingLaunch = null; _pendingLaunch = null;
EndSessionUi(); ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
var meaningKey = exitCode switch var meaningKey = exitCode switch
{ {
@@ -2171,10 +2155,6 @@ public partial class MainWindow : Window
brush); brush);
CloseFileLogSoon(); 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"); StatusBarRight.Text = Localization.Instance.Get("Status.Idle");
_runningGameName = null; _runningGameName = null;
_runningGameTitleId = null; _runningGameTitleId = null;
@@ -2255,137 +2235,6 @@ public partial class MainWindow : Window
private void OnEmulatorOutput(string line, bool isError) private void OnEmulatorOutput(string line, bool isError)
{ {
_pendingLines.Enqueue((line, 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) private void OpenFileLog(string? titleId)
@@ -2442,8 +2291,23 @@ public partial class MainWindow : Window
private void UpdateRunButtons() private void UpdateRunButtons()
{ {
LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry; LaunchButton.Classes.Remove("accent");
StopButton.IsEnabled = _isRunning && !_isStopping; 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; OpenFileButton.IsEnabled = !_isRunning;
} }
+105 -16
View File
@@ -6,32 +6,121 @@ Cover-art library item states and motion.
<Styles xmlns="https://github.com/avaloniaui" <Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="ListBox.tileGrid ListBoxItem"> <Style Selector="ListBox.tileGrid">
<Setter Property="Padding" Value="10" />
<Setter Property="Margin" Value="5" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Background" Value="Transparent" /> <Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="1" /> <Setter Property="BorderThickness" Value="0" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Hidden" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem">
<Setter Property="Width" Value="160" />
<Setter Property="Height" Value="172" />
<Setter Property="Padding" Value="4" />
<Setter Property="Margin" Value="0,8,12,8" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="BorderBrush" Value="Transparent" /> <Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Opacity" Value="0.72" />
<Setter Property="RenderTransformOrigin" Value="50%,50%" />
<Setter Property="RenderTransform" Value="translateY(0px)" /> <Setter Property="RenderTransform" Value="translateY(0px)" />
<Setter Property="Transitions"> <Setter Property="Transitions">
<Transitions> <Transitions>
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" /> <DoubleTransition Property="Opacity"
Duration="0:0:0.11"
Easing="QuadraticEaseOut" />
<TransformOperationsTransition Property="RenderTransform"
Duration="0:0:0.16"
Easing="CubicEaseOut" />
</Transitions> </Transitions>
</Setter> </Setter>
</Style> </Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover"> <Style Selector="ListBox.tileGrid ListBoxItem:pointerover,
<Setter Property="RenderTransform" Value="translateY(-3px)" /> ListBox.tileGrid ListBoxItem:selected">
<Setter Property="Opacity" Value="1" />
<Setter Property="RenderTransform" Value="translateY(-5px) scale(1.035)" />
</Style> </Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter"> <Style Selector="ListBox.tileGrid ListBoxItem /template/ ContentPresenter#PART_ContentPresenter,
<Setter Property="Background" Value="{StaticResource TileHoverBrush}" /> ListBox.tileGrid ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter,
ListBox.tileGrid ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style> </Style>
<Style Selector="ListBox.tileGrid ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" /> <Style Selector="Border.libraryGameCard">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" /> <Setter Property="CornerRadius" Value="7" />
<Setter Property="BorderThickness" Value="2" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BoxShadow" Value="0 12 28 0 #61000000" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="BorderBrush"
Duration="0:0:0.12"
Easing="QuadraticEaseOut" />
</Transitions>
</Setter>
</Style> </Style>
<Style Selector="ListBox.tileGrid ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter"> <Style Selector="ListBox.tileGrid ListBoxItem:selected Border.libraryGameCard">
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" /> <Setter Property="BorderBrush" Value="#E6FFFFFF" />
<Setter Property="BorderBrush" Value="{StaticResource AccentHoverBrush}" /> </Style>
<Style Selector="Border.libraryGameCard Border.coverClip">
<Setter Property="CornerRadius" Value="5" />
</Style>
<Style Selector="Border.addFolderTile">
<Setter Property="CornerRadius" Value="7" />
<Setter Property="Background" Value="#0CFFFFFF" />
<Setter Property="BorderBrush" Value="#2EFFFFFF" />
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="BoxShadow" Value="0 12 28 0 #3A000000" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
<BrushTransition Property="BorderBrush"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="Border.addFolderTileIcon">
<Setter Property="Width" Value="60" />
<Setter Property="Height" Value="60" />
<Setter Property="CornerRadius" Value="30" />
<Setter Property="Background" Value="#1BFFFFFF" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="TextBlock.addFolderGlyph">
<Setter Property="Foreground" Value="#A6FFFFFF" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Foreground"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="TextBlock.addFolderIconGlyph">
<Setter Property="RenderTransform" Value="translateY(-7px)" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover TextBlock.addFolderGlyph,
ListBox.tileGrid ListBoxItem:selected TextBlock.addFolderGlyph">
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover Border.addFolderTile,
ListBox.tileGrid ListBoxItem:selected Border.addFolderTile">
<Setter Property="Background" Value="#1AFFFFFF" />
<Setter Property="BorderBrush" Value="#55FFFFFF" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover Border.addFolderTileIcon,
ListBox.tileGrid ListBoxItem:selected Border.addFolderTileIcon">
<Setter Property="Background" Value="#2EFFFFFF" />
</Style> </Style>
</Styles> </Styles>
@@ -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<GameEntry>();
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<GameEntry>();
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<GameEntry>()));
Assert.Same(AddFolderTile.Instance, tiles[1]);
}
private static GameEntry CreateGame(string name) =>
new(name, null, null, $"/games/{name}/eboot.bin", 0, null, null);
}