[GUI] Add grid settings to GUI and implement grid snapping

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