// Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later using Avalonia; using Avalonia.Animation.Easings; using Avalonia.Automation; using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Platform; using Avalonia.Platform.Storage; using Avalonia.Rendering.Composition; using Avalonia.Threading; using Avalonia.VisualTree; using SharpEmu.Core.Cpu; using SharpEmu.Core.Runtime; using SharpEmu.HLE.Host; using SharpEmu.Libs.Pad; using SharpEmu.Libs.VideoOut; using SharpEmu.Logging; using System.Collections.Concurrent; using System.Collections.ObjectModel; using System.Diagnostics; using System.Reflection; using System.Text.Json; using System.Net.Http.Headers; namespace SharpEmu.GUI; public partial class MainWindow : Window { private const int MaxConsoleLines = 4000; private const int MaxConsoleLinesPerFlush = 500; private static readonly TimeSpan NavigationIndicatorAnimationDuration = TimeSpan.FromMilliseconds(180); private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE")); private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488")); private static readonly IBrush InfoLineBrush = new SolidColorBrush(Color.Parse("#6FA8FF")); private static readonly IBrush WarningLineBrush = new SolidColorBrush(Color.Parse("#E8B341")); private static readonly IBrush ErrorLineBrush = new SolidColorBrush(Color.Parse("#F2777C")); private static readonly IBrush SuccessLineBrush = new SolidColorBrush(Color.Parse("#63D489")); private readonly LocalizedChoice[] _cpuEngineChoices = [ LocalizedChoice.FromKey("Native", "Options.CpuEngine.Native"), ]; private readonly LocalizedChoice[] _consoleTypeChoices = [ LocalizedChoice.Literal("PS5", "PlayStation 5"), ]; private readonly LocalizedChoice[] _consoleLanguageChoices = [ LocalizedChoice.Literal("Japanese", "日本語"), LocalizedChoice.Literal("EnglishUS", "English (United States)"), LocalizedChoice.Literal("French", "Français"), LocalizedChoice.Literal("SpanishSpain", "Español (España)"), LocalizedChoice.Literal("German", "Deutsch"), LocalizedChoice.Literal("Italian", "Italiano"), LocalizedChoice.Literal("Dutch", "Nederlands"), LocalizedChoice.Literal("PortuguesePortugal", "Português (Portugal)"), LocalizedChoice.Literal("Russian", "Русский"), LocalizedChoice.Literal("Korean", "한국어"), LocalizedChoice.Literal("ChineseTraditional", "繁體中文"), LocalizedChoice.Literal("ChineseSimplified", "简体中文"), LocalizedChoice.Literal("Finnish", "Suomi"), LocalizedChoice.Literal("Swedish", "Svenska"), LocalizedChoice.Literal("Danish", "Dansk"), LocalizedChoice.Literal("Norwegian", "Norsk"), LocalizedChoice.Literal("Polish", "Polski"), LocalizedChoice.Literal("PortugueseBrazil", "Português (Brasil)"), LocalizedChoice.Literal("EnglishUK", "English (United Kingdom)"), LocalizedChoice.Literal("Turkish", "Türkçe"), LocalizedChoice.Literal("SpanishLatinAmerica", "Español (Latinoamérica)"), LocalizedChoice.Literal("Arabic", "العربية"), LocalizedChoice.Literal("FrenchCanada", "Français (Canada)"), LocalizedChoice.Literal("Czech", "Čeština"), LocalizedChoice.Literal("Hungarian", "Magyar"), LocalizedChoice.Literal("Greek", "Ελληνικά"), LocalizedChoice.Literal("Romanian", "Română"), LocalizedChoice.Literal("Thai", "ไทย"), LocalizedChoice.Literal("Vietnamese", "Tiếng Việt"), LocalizedChoice.Literal("Indonesian", "Bahasa Indonesia"), LocalizedChoice.Literal("Ukrainian", "Українська"), ]; private readonly LocalizedChoice[] _logLevelChoices = [ LocalizedChoice.FromKey("Trace", "Options.LogLevel.Trace"), LocalizedChoice.FromKey("Debug", "Options.LogLevel.Debug"), LocalizedChoice.FromKey("Info", "Options.LogLevel.Info"), LocalizedChoice.FromKey("Warning", "Options.LogLevel.Warning"), LocalizedChoice.FromKey("Error", "Options.LogLevel.Error"), LocalizedChoice.FromKey("Critical", "Options.LogLevel.Critical"), ]; private readonly LocalizedChoice[] _windowModeChoices = [ LocalizedChoice.FromKey("Windowed", "Options.WindowMode.Windowed"), LocalizedChoice.FromKey("Borderless", "Options.WindowMode.Borderless"), LocalizedChoice.FromKey("Exclusive", "Options.WindowMode.Exclusive"), ]; private readonly LocalizedChoice[] _scalingModeChoices = [ LocalizedChoice.FromKey("Fit", "Options.Scaling.Fit"), LocalizedChoice.FromKey("Cover", "Options.Scaling.Cover"), LocalizedChoice.FromKey("Stretch", "Options.Scaling.Stretch"), LocalizedChoice.FromKey("Integer", "Options.Scaling.Integer"), ]; private readonly LocalizedChoice[] _overlayModeChoices = [ LocalizedChoice.FromKey("Full", "Options.OverlayMode.Full"), LocalizedChoice.FromKey("Minimal", "Options.OverlayMode.Minimal"), LocalizedChoice.FromKey("TitleBar", "Options.OverlayMode.TitleBar"), ]; private readonly LocalizedChoice[] _overlayCornerChoices = [ LocalizedChoice.FromKey("TopLeft", "Options.OverlayCorner.TopLeft"), LocalizedChoice.FromKey("TopRight", "Options.OverlayCorner.TopRight"), LocalizedChoice.FromKey("BottomRight", "Options.OverlayCorner.BottomRight"), LocalizedChoice.FromKey("BottomLeft", "Options.OverlayCorner.BottomLeft"), ]; private readonly LocalizedChoice[] _hdrModeChoices = [ LocalizedChoice.FromKey("Auto", "Options.Hdr.Auto"), LocalizedChoice.FromKey("On", "Common.On"), LocalizedChoice.FromKey("Off", "Common.Off"), ]; private readonly LocalizedChoice[] _binkPlaybackChoices = [ LocalizedChoice.FromKey("Host", "Options.Env.BinkPlayback.Host"), LocalizedChoice.FromKey("Guest", "Options.Env.BinkPlayback.Guest"), LocalizedChoice.FromKey("Skip", "Options.Env.BinkPlayback.Skip"), ]; private readonly List _allGames = new(); private readonly ObservableCollection _visibleGames = new(); private readonly LibraryTileCollection _libraryTiles; private readonly GameLibraryWatcher _libraryWatcher = new(); private readonly AvaloniaList _consoleLines = new(); private readonly List _allConsoleLines = new(); private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new(); private readonly DispatcherTimer _consoleFlushTimer; private readonly DispatcherTimer _libraryLayoutTimer; private GuiSettings _settings = new(); private IReadOnlyList _hostDisplays = []; private bool _updatingHostDisplayOptions; private EmulatorProcess? _emulator; private ConsoleWindow? _consoleWindow; private GuiConsoleMirror? _consoleMirror; private StreamWriter? _fileLog; private readonly SndPreviewPlayer _sndPreview = new(); private string? _emulatorExePath; private PendingLaunch? _pendingLaunch; private bool _isRunning; private bool _isStopping; private int _autoScrollTicks; private int _activePageIndex; private int _optionsSectionIndex; private Updater.UpdateInfo? _availableUpdate; private string _updateStatusKey = "Updater.Status.Ready"; private object?[] _updateStatusArgs = [BuildInfo.CommitSha ?? "dev"]; // Discord Rich Presence state. private readonly long _launcherStartUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); private DiscordRichPresence? _discord; private string? _runningGameName; private string? _runningGameTitleId; private long _runningSinceUnixSeconds; private int _libraryScanGeneration; private int _detailLoadGeneration; private int _backdropGeneration; private bool _isClosing; private bool _restoringGameSelection; private bool _addFolderInProgress; private bool _isLibraryGridLayout; private GameEntry? _lastSelectedGame; private double _libraryRailRowHeight = 188; private double _libraryGridRowHeight = 216; // Bundled key art shown whenever no game-specific backdrop applies; the // plain window color remains the fallback when the asset fails to load. private Bitmap? _defaultBackdrop; // Controller navigation state. private readonly DispatcherTimer _gamepadTimer; private HostGamepadButtons _previousPadButtons; private long _navLeftNextAt; private long _navRightNextAt; private long _navUpNextAt; private long _navDownNextAt; //Github http client for latest commit private static readonly HttpClient GithubHttpClient = CreateGithubHttpClient(); private string? _latestCommitSha; private sealed record PendingLaunch( string EbootPath, string DisplayName, string? TitleId, string ConsoleType, string ConsoleLanguage, EffectiveLaunchSettings Settings, SharpEmuRuntimeOptions RuntimeOptions); public MainWindow() { InitializeComponent(); InitializeLocalizedChoiceBoxes(); _libraryTiles = new LibraryTileCollection(_visibleGames); try { _defaultBackdrop = new Bitmap( AssetLoader.Open(new Uri("avares://SharpEmu.GUI/Assets/pic0.png"))); BackdropImage.Source = _defaultBackdrop; BackdropImage.Opacity = 1.0; } catch (Exception) { _defaultBackdrop = null; // color background remains the fallback } GameList.ItemsSource = _libraryTiles; _libraryWatcher.RefreshRequested += OnLibraryRefreshRequested; ConsoleList.ItemsSource = _consoleLines; _consoleMirror = GuiConsoleMirror.Install((line, isError) => _pendingLines.Enqueue((line, isError))); _consoleFlushTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(80), }; _consoleFlushTimer.Tick += (_, _) => { FlushPendingConsoleLines(); MaybeAutoScroll(); }; _consoleFlushTimer.Start(); _libraryLayoutTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(120), }; _libraryLayoutTimer.Tick += (_, _) => { _libraryLayoutTimer.Stop(); UpdateLibraryLayoutMetrics(); }; TitleBar.PointerPressed += OnTitleBarPointerPressed; TitleBar.DoubleTapped += OnTitleBarDoubleTapped; MinimizeButton.Click += (_, _) => WindowState = WindowState.Minimized; MaximizeButton.Click += (_, _) => ToggleMaximized(); CloseButton.Click += (_, _) => Close(); Opened += (_, _) => { // Some compositors ignore the initial maximized state while a // frameless native window is still being created. if (WindowState == WindowState.Normal) { WindowState = WindowState.Maximized; } UpdateWindowChromeState(); }; UpdateWindowChromeState(); GameList.SelectionChanged += (_, _) => OnGameListSelectionChanged(); GameList.DoubleTapped += OnGameListDoubleTapped; SearchBox.TextChanged += (_, _) => RefreshVisibleGames(); ConsoleSearchBox.TextChanged += (_, _) => RefreshVisibleConsoleLines(); OpenFileButton.Click += async (_, _) => await OpenFileAsync(); LaunchButton.Click += (_, _) => { if (_isRunning) { StopEmulator(); } else { LaunchSelected(); } }; LaunchCustomEnvButton.Click += async (_, _) => await OpenCustomEnvironmentDialogAsync(); ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); }; CopyLogButton.Click += async (_, _) => await CopyConsoleAsync(); DetachConsoleButton.Click += (_, _) => ShowConsoleWindow(); CloseConsoleButton.Click += (_, _) => ConsoleToggle.IsChecked = false; LibraryTabButton.Click += (_, _) => SetActivePage(0); OptionsTabButton.Click += (_, _) => SetActivePage(1); LibraryLayoutButton.Click += (_, _) => ToggleLibraryLayout(); LibraryPage.SizeChanged += (_, _) => ScheduleLibraryLayoutMetricsUpdate(); LibrarySelectedDetails.SizeChanged += (_, _) => UpdateLibraryMinimumHeight(); MainContent.SizeChanged += (_, _) => ClampEmbeddedConsoleHeight(); ConsoleToggle.IsCheckedChanged += (_, _) => UpdateEmbeddedConsoleVisibility(); WireOptionsNavigation(); WireGameOptions(); // The settings page edits _settings live, so a launch started while // it is open already uses the new values. LogLevelBox.SelectionChanged += (_, _) => _settings.LogLevel = SelectedLogLevel(); TraceImportsBox.ValueChanged += (_, _) => _settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0); StrictToggle.IsCheckedChanged += (_, _) => _settings.StrictDynlibResolution = StrictToggle.IsChecked == true; LogToFileToggle.IsCheckedChanged += (_, _) => _settings.LogToFile = LogToFileToggle.IsChecked == true; OverrideLogFileToggle.IsCheckedChanged += (_, _) => _settings.OverrideLogFile = OverrideLogFileToggle.IsChecked == true; TitleMusicToggle.IsCheckedChanged += (_, _) => { _settings.PlayTitleMusic = TitleMusicToggle.IsChecked == true; OnTitleMusicSettingChanged(); }; DiscordToggle.IsCheckedChanged += (_, _) => { _settings.DiscordRichPresence = DiscordToggle.IsChecked == true; UpdateDiscordPresence(); }; AutoUpdateToggle.IsCheckedChanged += (_, _) => _settings.CheckForUpdatesOnStartup = AutoUpdateToggle.IsChecked == true; ConsoleTypeBox.SelectionChanged += (_, _) => _settings.ConsoleType = SelectedComboText(ConsoleTypeBox, "PS5"); ConsoleLanguageBox.SelectionChanged += (_, _) => _settings.ConsoleLanguage = SelectedComboText(ConsoleLanguageBox, "EnglishUS"); WindowModeBox.SelectionChanged += (_, _) => _settings.WindowMode = SelectedComboText(WindowModeBox, "Windowed"); DisplayBox.SelectionChanged += (_, _) => OnHostDisplayChanged(); ResolutionBox.SelectionChanged += (_, _) => OnHostResolutionChanged(); GuestResolutionBox.SelectionChanged += (_, _) => _settings.GuestResolution = SelectedComboText(GuestResolutionBox, "1920x1080"); RenderScaleBox.SelectionChanged += (_, _) => _settings.RenderScale = GuiSettings.ParseRenderScale(SelectedComboText(RenderScaleBox, "100%")); RefreshRateBox.SelectionChanged += (_, _) => OnHostRefreshRateChanged(); ScalingModeBox.SelectionChanged += (_, _) => _settings.ScalingMode = SelectedComboText(ScalingModeBox, "Fit"); VSyncToggle.IsCheckedChanged += (_, _) => _settings.VSync = VSyncToggle.IsChecked == true; HdrModeBox.SelectionChanged += (_, _) => _settings.HdrMode = SelectedComboText(HdrModeBox, "Auto"); OverlayEnabledToggle.IsCheckedChanged += (_, _) => _settings.OverlayEnabled = OverlayEnabledToggle.IsChecked == true; OverlayModeBox.SelectionChanged += (_, _) => _settings.OverlayMode = SelectedComboText(OverlayModeBox, "TitleBar"); OverlayCornerBox.SelectionChanged += (_, _) => _settings.OverlayCorner = SelectedComboText(OverlayCornerBox, "TopRight"); UpdateButton.Click += async (_, _) => await OnUpdateButtonAsync(); SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync(); PerformanceProfileToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_PROFILE_PERFORMANCE", PerformanceProfileToggle.IsChecked == true); PerformanceFrameTraceToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_PROFILE_PERFORMANCE_FRAME_TRACE", PerformanceFrameTraceToggle.IsChecked == true); StrictComputeToggle.IsCheckedChanged += (_, _) => StrictComputeSettings.SetEnabled(_settings.EnvironmentToggles, StrictComputeToggle.IsChecked == true); EnvBthidToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_BTHID_UNAVAILABLE", EnvBthidToggle.IsChecked == true); EnvLoopGuardToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_DISABLE_IMPORT_LOOP_GUARD", EnvLoopGuardToggle.IsChecked == true); EnvWritableApp0Toggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_WRITABLE_APP0", EnvWritableApp0Toggle.IsChecked == true); BinkPlaybackBox.SelectionChanged += (_, _) => _settings.BinkPlaybackMode = SelectedComboText(BinkPlaybackBox, "Guest"); EnvVkValidationToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_VK_VALIDATION", EnvVkValidationToggle.IsChecked == true); EnvDumpSpirvToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_DUMP_SPIRV", EnvDumpSpirvToggle.IsChecked == true); EnvLogDirectMemoryToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_LOG_DIRECT_MEMORY", EnvLogDirectMemoryToggle.IsChecked == true); EnvLogIoToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_LOG_IO", EnvLogIoToggle.IsChecked == true); EnvLogNpToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_LOG_NP", EnvLogNpToggle.IsChecked == true); CrashDumpToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle("SHARPEMU_CRASH_CAPTURE", CrashDumpToggle.IsChecked == true); DefaultProfileBox.TextChanged += (_, _) => _settings.DefaultProfile = GuiSettings.NormalizeDefaultProfile(DefaultProfileBox.Text); LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged(); GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel); AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel); CtxLaunch.Click += (_, _) => LaunchSelected(); CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder(); CtxCopyPath.Click += async (_, _) => await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path); CtxCopyTitleId.Click += async (_, _) => await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId); CtxGameSettings.Click += (_, _) => OpenSelectedGameSettings(); CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary(); Opened += async (_, _) => await OnOpenedAsync(); Closing += (_, _) => BeginWindowClosing(); Closed += (_, _) => CompleteWindowClosing(); SdlLauncherGamepad.EnsureStarted(); _gamepadTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(50), }; EnvRenderDocToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle( "SHARPEMU_RENDERDOC", EnvRenderDocToggle.IsChecked == true); EnvDisableVkImplicitLayersToggle.IsCheckedChanged += (_, _) => SetEnvironmentToggle( "SHARPEMU_VK_DISABLE_IMPLICITS", EnvDisableVkImplicitLayersToggle.IsChecked == true); DefaultProfileBox.TextChanged += (_, _) => _settings.DefaultProfile = GuiSettings.NormalizeDefaultProfile(DefaultProfileBox.Text); LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged(); GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel); AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel); CtxLaunch.Click += (_, _) => LaunchSelected(); CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder(); CtxCopyPath.Click += async (_, _) => await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path); CtxCopyTitleId.Click += async (_, _) => await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId); CtxGameSettings.Click += (_, _) => OpenSelectedGameSettings(); CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary(); Opened += async (_, _) => await OnOpenedAsync(); Closing += (_, _) => BeginWindowClosing(); Closed += (_, _) => CompleteWindowClosing(); SdlLauncherGamepad.EnsureStarted(); _gamepadTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(50), }; _gamepadTimer.Tick += (_, _) => PollGamepad(); _gamepadTimer.Start(); GithubButton.Click += (_, _) => { Process.Start(new ProcessStartInfo { FileName = ProjectLinks.RepositoryUrl, UseShellExecute = true }); }; DiscordButton.Click += (_, _) => { Process.Start(new ProcessStartInfo { FileName = "https://discord.com/invite/zdTuUU9Uwn", UseShellExecute = true }); }; LatestCommitHashText.Click += (_, _) => { if (string.IsNullOrWhiteSpace(_latestCommitSha)) { return; } Process.Start(new ProcessStartInfo { FileName = ProjectLinks.CommitUrl(_latestCommitSha), UseShellExecute = true }); }; } /// /// Switches between the Library and Options pages. Also reachable via /// the gamepad's shoulder buttons (LB/RB, L1/R1) from . /// private void SetActivePage(int index) { if (index == _activePageIndex) { if (index == 0 && _isGameSettingsOpen) { CloseGameSettings(); } return; } if (_activePageIndex == 1) { _settings.Save(); // leaving the Options page } if (_isGameSettingsOpen) { CloseGameSettings(restoreLibrary: false); } _activePageIndex = index; SetActiveClass(LibraryTabButton, index == 0); SetActiveClass(OptionsTabButton, index == 1); LibraryPage.IsVisible = index == 0; LibraryToolbar.IsVisible = index == 0; OptionsPageSurface.IsVisible = index == 1; OptionsPage.IsVisible = index == 1; UpdateEmbeddedConsoleVisibility(); if (index == 1) { Dispatcher.UIThread.Post( () => SetOptionsNavigationIndicator(_optionsSectionIndex, animate: false), DispatcherPriority.Loaded); } } private void SetLibraryLayout(bool grid) { _isLibraryGridLayout = grid; SetClass(GameList, "gridLayout", grid); SetClass(LibrarySelectedDetails, "gridLayout", grid); LibraryPage.Margin = new Thickness(0, 6, 0, 0); UpdateLibraryLayoutMetrics(); UpdateLibraryLayoutButton(); if (GameList.SelectedItem is { } selected) { Dispatcher.UIThread.Post( () => GameList.ScrollIntoView(selected), DispatcherPriority.Loaded); } } private void UpdateLibraryLayoutMetrics() { var metrics = LibraryLayoutMetrics.Calculate(LibraryPage.Bounds.Width); Resources["LibraryCoverSize"] = metrics.CoverSize; Resources["LibraryTileWidth"] = metrics.ItemWidth; Resources["LibraryRailItemHeight"] = metrics.RailItemHeight; Resources["LibraryGridItemHeight"] = metrics.GridItemHeight; _libraryRailRowHeight = metrics.RailItemHeight + 16; _libraryGridRowHeight = metrics.GridItemHeight + 16; GameList.MaxHeight = double.PositiveInfinity; UpdateLibraryMinimumHeight(); } private void ScheduleLibraryLayoutMetricsUpdate() { _libraryLayoutTimer.Stop(); _libraryLayoutTimer.Start(); } private void UpdateLibraryMinimumHeight() { var detailsHeight = LibrarySelectedDetails.IsVisible ? LibrarySelectedDetails.DesiredSize.Height : 0; var cardRowHeight = _isLibraryGridLayout ? _libraryGridRowHeight : _libraryRailRowHeight; MainContent.RowDefinitions[1].MinHeight = LibraryPage.Margin.Top + detailsHeight + cardRowHeight; ClampEmbeddedConsoleHeight(); } 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 (!control.Classes.Contains(className)) { control.Classes.Add(className); } } else { control.Classes.Remove(className); } } private void WireOptionsNavigation() { var buttons = OptionsNavigationButtons(); for (var index = 0; index < buttons.Length; index++) { var section = index; buttons[index].Click += (_, _) => SetOptionsSection(section); buttons[index].PointerEntered += (_, _) => SetOptionsNavigationIndicator(section); buttons[index].GotFocus += (_, _) => SetOptionsNavigationIndicator(section); } OptionsNavHost.PointerExited += (_, _) => SetOptionsNavigationIndicator(_optionsSectionIndex); SetOptionsSection(0); } private Button[] OptionsNavigationButtons() => [ OptionsGeneralNav, OptionsLoggingNav, OptionsLauncherNav, OptionsRenderingNav, OptionsEnvironmentNav, OptionsAboutNav, ]; private Control[] OptionsSectionPanels() => [ OptionsGeneralPanel, OptionsLoggingPanel, OptionsLauncherPanel, OptionsRenderingPanel, OptionsEnvironmentPanel, OptionsAboutPanel, ]; private void SetOptionsSection(int section) { var buttons = OptionsNavigationButtons(); var panels = OptionsSectionPanels(); section = Math.Clamp(section, 0, buttons.Length - 1); _optionsSectionIndex = section; SetOptionsNavigationIndicator(section); for (var index = 0; index < buttons.Length; index++) { var active = index == section; SetActiveClass(buttons[index], active); SetOptionsPanelInteraction(panels[index], active); } } private static void SetOptionsPanelInteraction(Control panel, bool active) { if (active) { if (!panel.Classes.Contains("active")) { panel.Classes.Add("active"); } } else { panel.Classes.Remove("active"); } panel.IsHitTestVisible = active; AutomationProperties.SetAccessibilityView( panel, active ? AccessibilityView.Content : AccessibilityView.Raw); KeyboardNavigation.SetTabNavigation( panel, active ? KeyboardNavigationMode.Continue : KeyboardNavigationMode.None); } private void SetOptionsNavigationIndicator(int section, bool animate = true) { var buttons = OptionsNavigationButtons(); var button = buttons[Math.Clamp(section, 0, buttons.Length - 1)]; MoveNavigationIndicator( OptionsNavIndicator, OptionsNavHost, button, section, animate); } private static void ConfigureNavigationIndicatorAnimation(Border indicator) { if (ElementComposition.GetElementVisual(indicator) is not { } visual) { return; } var translationAnimation = visual.Compositor.CreateVector3KeyFrameAnimation(); translationAnimation.Duration = NavigationIndicatorAnimationDuration; translationAnimation.Target = nameof(CompositionVisual.Translation); translationAnimation.InsertExpressionKeyFrame( 1f, "this.FinalValue", new CubicEaseOut()); var animations = visual.Compositor.CreateImplicitAnimationCollection(); animations[nameof(CompositionVisual.Translation)] = translationAnimation; visual.ImplicitAnimations = animations; } private static void MoveNavigationIndicator( Border indicator, Control host, Button button, int section, bool animate = true) { if (ElementComposition.GetElementVisual(indicator) is not { } visual) { return; } var targetY = button.TranslatePoint(default, host)?.Y ?? section * button.Bounds.Height; if (!animate) { visual.ImplicitAnimations = null; visual.StopAnimation(nameof(CompositionVisual.Translation)); } else if (visual.ImplicitAnimations is null) { ConfigureNavigationIndicatorAnimation(indicator); } visual.Translation = new Vector3D(0, targetY, 0); if (!animate) { ConfigureNavigationIndicatorAnimation(indicator); } } // ---- Github http client config ---- // This is for getting lash commit id private static HttpClient CreateGithubHttpClient() { var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; client.DefaultRequestHeaders.UserAgent.ParseAdd("SharpEmu/1.0"); client.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue("application/vnd.github.sha")); client.DefaultRequestHeaders.Add( "X-GitHub-Api-Version", "2026-03-10"); return client; } private async Task LoadLatestCommitAsync() { _latestCommitSha = null; LatestCommitHashText.Content = "Loading…"; LatestCommitHashText.IsEnabled = false; try { using var response = await GithubHttpClient.GetAsync(ProjectLinks.LatestCommitApiUrl); var responseBody = (await response.Content.ReadAsStringAsync()).Trim(); if (!response.IsSuccessStatusCode) { LatestCommitHashText.Content = $"HTTP {(int)response.StatusCode}"; ToolTip.SetTip( LatestCommitHashText, string.IsNullOrWhiteSpace(responseBody) ? response.ReasonPhrase : responseBody); return; } if (responseBody.Length < 7) { LatestCommitHashText.Content = "Invalid response"; ToolTip.SetTip(LatestCommitHashText, responseBody); return; } // Keep the complete SHA for the URL. _latestCommitSha = responseBody; // Display only the short SHA. LatestCommitHashText.Content = responseBody[..Math.Min(7, responseBody.Length)]; LatestCommitHashText.IsEnabled = true; ToolTip.SetTip( LatestCommitHashText, $"Open commit {_latestCommitSha}"); } catch (TaskCanceledException ex) { LatestCommitHashText.Content = "Timeout"; ToolTip.SetTip(LatestCommitHashText, ex.Message); } catch (HttpRequestException ex) { LatestCommitHashText.Content = "Connection error"; ToolTip.SetTip(LatestCommitHashText, ex.Message); } catch (Exception ex) { LatestCommitHashText.Content = "Error"; ToolTip.SetTip(LatestCommitHashText, ex.Message); } } // ---- Controller navigation ---- private void PollGamepad() { if (!SdlLauncherGamepad.TryGetState(out var pad)) { _previousPadButtons = HostGamepadButtons.None; return; } if (!IsActive) { // Ignore input while the launcher is in the background, e.g. the // game window is focused and using the same controller. _previousPadButtons = pad.Buttons; return; } if (_isRunning || _isStopping) { // The controller belongs to the separate game window while a // session is active; Circle/B must never stop the session. _previousPadButtons = pad.Buttons; return; } var shoulderPressed = pad.Buttons & ~_previousPadButtons; if ((shoulderPressed & HostGamepadButtons.L1) != 0) { SetActivePage(0); } if ((shoulderPressed & HostGamepadButtons.R1) != 0) { SetActivePage(1); } if (_activePageIndex != 0 || _isGameSettingsOpen) { _previousPadButtons = pad.Buttons; return; } var now = Environment.TickCount64; var left = (pad.Buttons & HostGamepadButtons.Left) != 0 || pad.LeftX < 64; var right = (pad.Buttons & HostGamepadButtons.Right) != 0 || pad.LeftX > 192; if (ShouldNavigate(left, ref _navLeftNextAt, now)) { MoveSelection(-1); } if (ShouldNavigate(right, ref _navRightNextAt, now)) { 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) { LaunchSelected(); } _previousPadButtons = pad.Buttons; } /// /// Edge-triggered with hold-to-repeat: fires on press, then repeats /// after 400ms at 130ms intervals while held. /// private static bool ShouldNavigate(bool held, ref long nextAt, long now) { if (!held) { nextAt = 0; return false; } if (nextAt == 0) { nextAt = now + 400; return true; } if (now >= nextAt) { nextAt = now + 130; return true; } 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 ? 0 : Math.Clamp(GameList.SelectedIndex + delta, 0, _libraryTiles.Count - 1); GameList.SelectedIndex = index; GameList.ScrollIntoView(index); } private async Task OnOpenedAsync() { var version = Assembly.GetExecutingAssembly().GetName().Version; var display = version is not null ? $"v{version.ToString(3)}" : "v0.0.1"; display += BuildInfo.CommitSha is null ? " · dev" : BuildInfo.IsOfficialRelease ? $" · {BuildInfo.CommitSha}" : $" · UNOFFICIAL {BuildInfo.CommitSha}"; VersionText.Text = display; Title = $"SharpEmu {display}"; ToolTip.SetTip(VersionText, BuildInfo.Banner); _settings = GuiSettings.Load(); Localization.Instance.Load(_settings.Language); PopulateLanguageBox(); ApplyLocalization(); ApplySettingsToControls(); LocateEmulator(); UpdateDiscordPresence(); _ = LoadLatestCommitAsync(); if (_settings.CheckForUpdatesOnStartup) { _ = CheckForUpdatesAsync(); } SeedLibraryFromCache(); await RescanLibraryAsync(); } private void PopulateLanguageBox() { var languages = Localization.Instance.DiscoverLanguages(); LanguageBox.ItemsSource = languages; LanguageBox.SelectedItem = languages.FirstOrDefault(language => string.Equals(language.Code, _settings.Language, StringComparison.OrdinalIgnoreCase)) ?? languages.FirstOrDefault(); } private void OnLanguageChanged() { if (LanguageBox.SelectedItem is not LanguageInfo language) { return; } _settings.Language = language.Code; Localization.Instance.Load(language.Code); ApplyLocalization(); } /// /// Recomputes localized strings that also depend on runtime state. /// Static labels update automatically through XAML bindings. /// private void ApplyLocalization() { RefreshOptionsNavigationLabels(); RefreshLocalizedChoices(); UpdateLogFilePathText(); RefreshHostRefreshRates(_settings.RefreshRate); RefreshUpdateText(); UpdateEmptyStateTexts(); UpdateLibraryLayoutButton(); UpdateRunButtons(); } private void RefreshOptionsNavigationLabels() { var localization = Localization.Instance; SetOptionsNavigationLabel( OptionsGeneralNav, OptionsGeneralNavLabel, localization.Get("Options.General")); SetOptionsNavigationLabel( OptionsLoggingNav, OptionsLoggingNavLabel, localization.Get("Options.Section.Logging")); SetOptionsNavigationLabel( OptionsLauncherNav, OptionsLauncherNavLabel, localization.Get("Options.Section.Launcher")); SetOptionsNavigationLabel( OptionsRenderingNav, OptionsRenderingNavLabel, localization.Get("Options.Section.Rendering")); SetOptionsNavigationLabel( OptionsEnvironmentNav, OptionsEnvironmentNavLabel, localization.Get("Options.Env.Tab")); SetOptionsNavigationLabel( OptionsAboutNav, OptionsAboutNavLabel, localization.Get("Options.About")); } private static void SetOptionsNavigationLabel(Button button, TextBlock label, string value) { var navigationLabel = SectionNavigationLabel(value); label.Text = navigationLabel; AutomationProperties.SetName(button, navigationLabel); } private static string SectionNavigationLabel(string value) { if (string.IsNullOrEmpty(value) || value.Any(char.IsLower)) { return value; } var lower = value.ToLowerInvariant(); return char.ToUpperInvariant(lower[0]) + lower[1..]; } // ---- Discord Rich Presence ---- /// /// Publishes the launcher state to Discord: browsing while idle, the /// running game (with elapsed time) during emulation. No-ops when /// disabled or when no Discord application ID is configured. /// private void UpdateDiscordPresence() { if (!_settings.DiscordRichPresence || _settings.DiscordClientId.Length == 0) { _discord?.Dispose(); _discord = null; return; } _discord ??= new DiscordRichPresence(_settings.DiscordClientId); if (_isRunning && _runningGameName is { } gameName) { _discord.SetPresence( Localization.Instance.Format("Discord.Playing", gameName), _runningGameTitleId, _runningSinceUnixSeconds); } else { // Discord does not render activities without timestamps, so the // browsing state carries the launcher's start time. var count = _allGames.Count == 1 ? Localization.Instance.Get("Page.GameCount.One") : Localization.Instance.Format("Page.GameCount.Other", _allGames.Count); _discord.SetPresence( Localization.Instance.Get("Discord.Browsing"), count, _launcherStartUnixSeconds); } } private void OnKeyDown(object sender, KeyEventArgs args) { if (args.Key == Key.Escape && _isGameSettingsOpen) { CloseGameSettings(); args.Handled = true; return; } if (args.Key == Key.F11 && !_isRunning) { WindowState = WindowState == WindowState.FullScreen ? WindowState.Maximized : WindowState.FullScreen; args.Handled = true; } } private void OnPreviewKeyDown(object? sender, KeyEventArgs args) { // The session runs in its own SDL window and takes keyboard focus with // it, so launcher buttons no longer see game input and nothing has to // be swallowed here. Kept as the wired handler because the launcher // still needs a preview hook for its own shortcuts. } private void BeginWindowClosing() { if (_isClosing) { return; } _isClosing = true; Interlocked.Increment(ref _libraryScanGeneration); Interlocked.Increment(ref _detailLoadGeneration); _consoleFlushTimer.Stop(); _libraryLayoutTimer.Stop(); _gamepadTimer.Stop(); } private void CompleteWindowClosing() { RunShutdownStep("library watcher", _libraryWatcher.Dispose); RememberEmbeddedConsoleHeight(); RunShutdownStep("settings", _settings.Save); RunShutdownStep("SDL gamepad", SdlLauncherGamepad.Shutdown); RunShutdownStep("title music", _sndPreview.Stop); RunShutdownStep("Discord Rich Presence", () => _discord?.Dispose()); RunShutdownStep("console window", () => _consoleWindow?.Close()); RunShutdownStep("emulator process", () => _emulator?.Dispose()); RunShutdownStep("console mirror", () => _consoleMirror?.Dispose()); RunShutdownStep("pending console output", FlushAllPendingConsoleLines); RunShutdownStep("file log", DropFileLog); } private static void RunShutdownStep(string component, Action action) { try { action(); } catch (Exception exception) { Console.Error.WriteLine( $"[GUI][WARN] Failed to clean up {component}: {exception}"); } } private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e) { if (e.Source is Visual source && source.FindAncestorOfType