[GUI] Add grid settings to GUI and implement grid snapping (#729)

This commit is contained in:
Berk
2026-07-31 13:27:43 +03:00
committed by GitHub
parent 544f588cfd
commit 7c9740fee8
10 changed files with 249 additions and 46 deletions
+3
View File
@@ -40,6 +40,8 @@ public sealed class GuiSettings
/// <summary>Loop the selected game's sce_sys/snd0.at9 preview music.</summary>
public bool PlayTitleMusic { get; set; } = true;
public string LibraryLayout { get; set; } = "Carousel";
public string? EmulatorPath { get; set; }
/// <summary>UI language, matching a file code under Languages/ (e.g. "en", "tr").</summary>
@@ -132,6 +134,7 @@ public sealed class GuiSettings
{
settings.RenderResolutionScale = 1.0;
}
settings.LibraryLayout = NormalizeChoice(settings.LibraryLayout, "Carousel", "Grid");
settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive");
settings.Resolution = NormalizeResolution(settings.Resolution);
settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
+2
View File
@@ -9,6 +9,8 @@
"Library.SearchWatermark": "Search library…",
"Library.AddFolder": "Add folder",
"Library.OpenFile": "Open file…",
"Library.View.Grid": "Stacked view",
"Library.View.Carousel": "Row view",
"Library.Context.Launch": "Launch",
"Library.Context.OpenFolder": "Open game folder",
+2
View File
@@ -9,6 +9,8 @@
"Library.SearchWatermark": "Kütüphanede ara…",
"Library.AddFolder": "Klasör ekle",
"Library.OpenFile": "Dosya aç…",
"Library.View.Grid": "Alt alta görünüm",
"Library.View.Carousel": "Tek sıra görünüm",
"Library.Context.Launch": "Başlat",
"Library.Context.OpenFolder": "Oyun klasörünü aç",
+2 -14
View File
@@ -476,18 +476,6 @@ public partial class MainWindow
("SHARPEMU_GUEST_IMAGE_CPU_SYNC", GameEnvGuestImageCpuSyncToggle),
];
private static void SetGameOptionsOpenClass(Control control, bool active)
{
if (active)
{
if (!control.Classes.Contains("gameOptionsOpen"))
{
control.Classes.Add("gameOptionsOpen");
}
}
else
{
control.Classes.Remove("gameOptionsOpen");
}
}
private static void SetGameOptionsOpenClass(Control control, bool active) =>
SetClass(control, "gameOptionsOpen", active);
}
+37 -28
View File
@@ -136,6 +136,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
<StackPanel Grid.Column="2" x:Name="LibraryToolbar" Orientation="Horizontal" Spacing="8"
VerticalAlignment="Center">
<Button x:Name="LibraryLayoutButton"
Classes="iconGhost"
VerticalAlignment="Center">
<TextBlock x:Name="LibraryLayoutGlyph"
Classes="materialSymbol compact"
Text="grid_view" />
</Button>
<TextBox x:Name="SearchBox"
PlaceholderText="{Binding [Library.SearchWatermark], Source={x:Static local:Localization.Instance}}"
Width="240"
@@ -167,7 +174,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Transitions>
</Panel.Transitions>
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
SelectionMode="Single" Padding="4,0,28,0">
SelectionMode="Single">
<ListBox.ContextMenu>
<ContextMenu x:Name="GameContextMenu" Placement="Pointer">
<MenuItem x:Name="CtxLaunch"
@@ -218,32 +225,35 @@ SPDX-License-Identifier: GPL-2.0-or-later
</MenuItem>
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.DataTemplates>
<DataTemplate x:DataType="local:GameEntry" x:CompileBindings="True">
<Border Classes="coverShadow libraryGameCard"
Width="148"
Height="148"
ToolTip.Tip="{Binding Name}"
AutomationProperties.Name="{Binding Name}">
<Border Classes="coverClip">
<Panel>
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
<TextBlock Text="{Binding Initials}" FontSize="38" FontWeight="Light"
Foreground="#C4E8ECF4"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<Image Source="{Binding Cover}"
Stretch="UniformToFill"
RenderOptions.BitmapInterpolationMode="LowQuality"
IsVisible="{Binding HasCover}" />
</Panel>
<StackPanel Spacing="7">
<Border Classes="coverShadow libraryGameCard"
Width="148"
Height="148"
ToolTip.Tip="{Binding Name}"
AutomationProperties.Name="{Binding Name}">
<Border Classes="coverClip">
<Panel>
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
<TextBlock Text="{Binding Initials}" FontSize="38" FontWeight="Light"
Foreground="#C4E8ECF4"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<Image Source="{Binding Cover}"
Stretch="UniformToFill"
RenderOptions.BitmapInterpolationMode="LowQuality"
IsVisible="{Binding HasCover}" />
</Panel>
</Border>
</Border>
</Border>
<TextBlock Classes="libraryTileName"
Text="{Binding Name}"
TextWrapping="Wrap"
MaxLines="2"
TextTrimming="CharacterEllipsis"
TextAlignment="Center" />
</StackPanel>
</DataTemplate>
<DataTemplate x:DataType="local:AddFolderTile">
<Border Classes="addFolderTile"
@@ -292,8 +302,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
MaxWidth="980"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Margin="4,8,0,0"
Spacing="18">
Margin="4,8,0,0">
<StackPanel.Transitions>
<Transitions>
<DoubleTransition Property="Opacity"
@@ -302,8 +311,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
Duration="0:0:0.18" />
</Transitions>
</StackPanel.Transitions>
<TextBlock Text="{Binding Name}"
FontSize="42"
<TextBlock Classes="selectedGameTitle"
Text="{Binding Name}"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
+107 -4
View File
@@ -123,6 +123,7 @@ public partial class MainWindow : Window
private bool _isClosing;
private bool _restoringGameSelection;
private bool _addFolderInProgress;
private bool _isLibraryGridLayout;
private GameEntry? _lastSelectedGame;
// Bundled key art shown whenever no game-specific backdrop applies; the
@@ -134,6 +135,8 @@ public partial class MainWindow : Window
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();
@@ -219,6 +222,9 @@ public partial class MainWindow : Window
CloseConsoleButton.Click += (_, _) => ConsoleToggle.IsChecked = false;
LibraryTabButton.Click += (_, _) => SetActivePage(0);
OptionsTabButton.Click += (_, _) => SetActivePage(1);
LibraryLayoutButton.Click += (_, _) => ToggleLibraryLayout();
LibraryPage.SizeChanged += (_, _) => UpdateLibraryGridHeight();
LibrarySelectedDetails.SizeChanged += (_, _) => UpdateLibraryGridHeight();
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
WireOptionsNavigation();
WireGameOptions();
@@ -379,18 +385,73 @@ public partial class MainWindow : Window
}
}
private static void SetActiveClass(Button button, bool active)
private void SetLibraryLayout(bool grid)
{
_isLibraryGridLayout = grid;
SetClass(GameList, "gridLayout", grid);
SetClass(LibrarySelectedDetails, "gridLayout", grid);
LibraryPage.RowDefinitions[0].Height = grid
? GridLength.Auto
: new GridLength(188);
LibraryPage.Margin = grid
? new Thickness(0, 6, 0, 0)
: new Thickness(0, 46, 0, 0);
UpdateLibraryGridHeight();
UpdateLibraryLayoutButton();
if (GameList.SelectedItem is { } selected)
{
Dispatcher.UIThread.Post(
() => GameList.ScrollIntoView(selected),
DispatcherPriority.Loaded);
}
}
private void UpdateLibraryGridHeight()
{
var pageHeight = LibraryPage.Bounds.Height;
if (!_isLibraryGridLayout || pageHeight <= 0)
{
GameList.MaxHeight = double.PositiveInfinity;
return;
}
GameList.MaxHeight = Math.Max(
0,
pageHeight - LibrarySelectedDetails.DesiredSize.Height);
}
private void ToggleLibraryLayout()
{
SetLibraryLayout(!_isLibraryGridLayout);
_settings.LibraryLayout = _isLibraryGridLayout ? "Grid" : "Carousel";
_settings.Save();
}
private void UpdateLibraryLayoutButton()
{
LibraryLayoutGlyph.Text = _isLibraryGridLayout ? "view_carousel" : "grid_view";
var label = Localization.Instance.Get(
_isLibraryGridLayout ? "Library.View.Carousel" : "Library.View.Grid");
ToolTip.SetTip(LibraryLayoutButton, label);
AutomationProperties.SetName(LibraryLayoutButton, label);
}
private static void SetActiveClass(Button button, bool active) =>
SetClass(button, "active", active);
private static void SetClass(Control control, string className, bool active)
{
if (active)
{
if (!button.Classes.Contains("active"))
if (!control.Classes.Contains(className))
{
button.Classes.Add("active");
control.Classes.Add(className);
}
}
else
{
button.Classes.Remove("active");
control.Classes.Remove(className);
}
}
@@ -676,6 +737,23 @@ public partial class MainWindow : Window
MoveSelection(1);
}
if (_isLibraryGridLayout)
{
var up = (pad.Buttons & HostGamepadButtons.Up) != 0 || pad.LeftY < 64;
var down = (pad.Buttons & HostGamepadButtons.Down) != 0 || pad.LeftY > 192;
var rowStep = LibraryRowStep();
if (ShouldNavigate(up, ref _navUpNextAt, now))
{
MoveSelection(-rowStep);
}
if (ShouldNavigate(down, ref _navDownNextAt, now))
{
MoveSelection(rowStep);
}
}
var pressed = pad.Buttons & ~_previousPadButtons;
if ((pressed & HostGamepadButtons.Cross) != 0)
{
@@ -712,6 +790,29 @@ public partial class MainWindow : Window
return false;
}
private int LibraryRowStep()
{
if (GameList.ContainerFromIndex(0) is not { } first)
{
return 1;
}
var top = first.Bounds.Top;
var columns = 1;
for (var index = 1; index < _libraryTiles.Count; index++)
{
if (GameList.ContainerFromIndex(index) is not { } container ||
Math.Abs(container.Bounds.Top - top) > 0.5)
{
break;
}
columns++;
}
return columns;
}
private void MoveSelection(int delta)
{
var index = GameList.SelectedIndex < 0
@@ -783,6 +884,7 @@ public partial class MainWindow : Window
RefreshHostRefreshRates(_settings.RefreshRate);
RefreshUpdateText();
UpdateEmptyStateTexts();
UpdateLibraryLayoutButton();
UpdateRunButtons();
}
@@ -1067,6 +1169,7 @@ public partial class MainWindow : Window
LogToFileToggle.IsChecked = _settings.LogToFile;
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
TitleMusicToggle.IsChecked = _settings.PlayTitleMusic;
SetLibraryLayout(string.Equals(_settings.LibraryLayout, "Grid", StringComparison.OrdinalIgnoreCase));
DiscordToggle.IsChecked = _settings.DiscordRichPresence;
AutoUpdateToggle.IsChecked = _settings.CheckForUpdatesOnStartup;
EnvBthidToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_BTHID_UNAVAILABLE");
@@ -43,6 +43,23 @@ Shared launcher button variants and page switcher styles.
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="Button.iconGhost">
<Setter Property="Width" Value="32" />
<Setter Property="Height" Value="32" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.iconGhost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="ToggleButton.ghost">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
@@ -103,6 +103,36 @@ Contextual per-game options reveal, actions, and selected-game motion.
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="StackPanel.selectedDetailsHost">
<Setter Property="Spacing" Value="18" />
</Style>
<Style Selector="TextBlock.selectedGameTitle">
<Setter Property="FontSize" Value="42" />
</Style>
<Style Selector="StackPanel.selectedDetailsHost.gridLayout">
<Setter Property="Spacing" Value="12" />
</Style>
<Style Selector="StackPanel.selectedDetailsHost.gridLayout TextBlock.selectedGameTitle">
<Setter Property="FontSize" Value="26" />
</Style>
<Style Selector="StackPanel.selectedDetailsHost.gridLayout Button.playButton">
<Setter Property="MinHeight" Value="44" />
<Setter Property="MinWidth" Value="168" />
<Setter Property="Padding" Value="28,10" />
<Setter Property="FontSize" Value="15" />
</Style>
<Style Selector="StackPanel.selectedDetailsHost.gridLayout Button.optionsCircle,
StackPanel.selectedDetailsHost.gridLayout ToggleButton.optionsCircle">
<Setter Property="Width" Value="44" />
<Setter Property="Height" Value="44" />
<Setter Property="CornerRadius" Value="22" />
</Style>
<Style Selector="Button.gameOptionsLaunch">
<Setter Property="Width" Value="220" />
<Setter Property="Height" Value="46" />
@@ -9,8 +9,25 @@ Cover-art library item states and motion.
<Style Selector="ListBox.tileGrid">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="4,0,28,0" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Hidden" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled" />
<Setter Property="ItemsPanel">
<ItemsPanelTemplate>
<VirtualizingStackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</Setter>
</Style>
<Style Selector="ListBox.tileGrid.gridLayout">
<Setter Property="Padding" Value="4,0,14,0" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto" />
<Setter Property="ItemsPanel">
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</Setter>
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem">
<Setter Property="Width" Value="160" />
@@ -35,6 +52,25 @@ Cover-art library item states and motion.
</Transitions>
</Setter>
</Style>
<!-- The title rides along in the item template so both layouts share one
template. The rail hides it because the selected game already names
itself in the details below the covers. -->
<Style Selector="TextBlock.libraryTileName">
<Setter Property="IsVisible" Value="False" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="LineHeight" Value="16" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="ListBox.tileGrid.gridLayout TextBlock.libraryTileName">
<Setter Property="IsVisible" Value="True" />
</Style>
<Style Selector="ListBox.tileGrid.gridLayout ListBoxItem">
<Setter Property="Height" Value="200" />
<Setter Property="Margin" Value="0,6,12,10" />
<Setter Property="VerticalContentAlignment" Value="Top" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover,
ListBox.tileGrid ListBoxItem:selected">
<Setter Property="Opacity" Value="1" />
@@ -61,6 +61,19 @@ public sealed class GuiSettingsTests
Assert.Equal(1000, settings.RefreshRate);
}
[Theory]
[InlineData("""{ }""", "Carousel")]
[InlineData("""{ "LibraryLayout": null }""", "Carousel")]
[InlineData("""{ "LibraryLayout": "sideways" }""", "Carousel")]
[InlineData("""{ "LibraryLayout": "grid" }""", "Grid")]
[InlineData("""{ "LibraryLayout": "Grid" }""", "Grid")]
public void NormalizeFromJson_LibraryLayout_FallsBackToCarousel(string json, string expected)
{
var settings = GuiSettings.NormalizeFromJson(json);
Assert.Equal(expected, settings.LibraryLayout);
}
[Fact]
public void NormalizeFromJson_CustomResolution_IsPreserved()
{