mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-15 23:31:07 +00:00
[GUI] Added localization support, more clean design, and improved settings management
This commit is contained in:
@@ -48,6 +48,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<TargetPath>LICENSE.txt</TargetPath>
|
||||
<Visible>False</Visible>
|
||||
</Content>
|
||||
<Content Include="..\SharpEmu.GUI\Languages\*.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<TargetPath>Languages\%(Filename)%(Extension)</TargetPath>
|
||||
<Visible>False</Visible>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Keep glfw as a loose file next to the executable; every other native
|
||||
|
||||
@@ -127,6 +127,34 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Top-level page switcher (Library / Options): plain transparent
|
||||
buttons, not TabItem, so there is no Fluent selected-tab underline.
|
||||
The active page is conveyed by brightness alone; LB/RB gamepad
|
||||
hints flank the pair. -->
|
||||
<Style Selector="Button.segment">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
|
||||
<Setter Property="FontSize" Value="22" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="Padding" Value="6,4" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.segment:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.segment.active">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Gamepad shoulder-button hint chip (LB/RB, L1/R1). -->
|
||||
<Style Selector="Border.padHint">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="8,3" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ContextMenu">
|
||||
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
|
||||
@@ -27,8 +27,10 @@ public sealed class ConsoleWindow : Window
|
||||
Action clear,
|
||||
bool autoScroll)
|
||||
{
|
||||
var loc = Localization.Instance;
|
||||
|
||||
_sourceLines = lines;
|
||||
Title = "SharpEmu Console";
|
||||
Title = loc.Get("Console.WindowTitle");
|
||||
Width = 980;
|
||||
Height = 620;
|
||||
MinWidth = 520;
|
||||
@@ -36,10 +38,15 @@ public sealed class ConsoleWindow : Window
|
||||
Background = new SolidColorBrush(Color.Parse("#0D1017"));
|
||||
Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://SharpEmu.GUI/Assets/SharpEmu.ico")));
|
||||
|
||||
_searchBox = new TextBox { Watermark = "Search...", Width = 320, Margin = new Thickness(0, 0, 12, 0) };
|
||||
_searchBox = new TextBox
|
||||
{
|
||||
Watermark = loc.Get("Console.SearchWatermark"),
|
||||
Width = 320,
|
||||
Margin = new Thickness(0, 0, 12, 0),
|
||||
};
|
||||
_autoScrollCheck = new CheckBox
|
||||
{
|
||||
Content = "Auto-scroll",
|
||||
Content = loc.Get("Console.AutoScroll"),
|
||||
IsChecked = autoScroll,
|
||||
FontSize = 12,
|
||||
Margin = new Thickness(0, 0, 12, 0),
|
||||
@@ -48,11 +55,16 @@ public sealed class ConsoleWindow : Window
|
||||
var copyButton = new Button
|
||||
{
|
||||
Classes = { "ghost" },
|
||||
Content = "Copy",
|
||||
Content = loc.Get("Console.Copy"),
|
||||
Padding = new Thickness(10, 4),
|
||||
Margin = new Thickness(0, 0, 8, 0),
|
||||
};
|
||||
var clearButton = new Button { Classes = { "ghost" }, Content = "Clear", Padding = new Thickness(10, 4) };
|
||||
var clearButton = new Button
|
||||
{
|
||||
Classes = { "ghost" },
|
||||
Content = loc.Get("Console.Clear"),
|
||||
Padding = new Thickness(10, 4),
|
||||
};
|
||||
copyButton.Click += async (_, _) => await CopyAsync();
|
||||
clearButton.Click += (_, _) => clear();
|
||||
_searchBox.TextChanged += (_, _) => RefreshVisibleLines();
|
||||
@@ -87,7 +99,7 @@ public sealed class ConsoleWindow : Window
|
||||
new TextBlock
|
||||
{
|
||||
Classes = { "sectionTitle" },
|
||||
Text = "CONSOLE",
|
||||
Text = loc.Get("Console.Title"),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
},
|
||||
_searchBox.WithGridColumn(1),
|
||||
|
||||
@@ -29,10 +29,12 @@ public sealed class GameEntry : INotifyPropertyChanged
|
||||
private long _sizeBytes;
|
||||
|
||||
public GameEntry(
|
||||
string name, string? titleId, string path, long sizeBytes, string? coverPath, string? backgroundPath)
|
||||
string name, string? titleId, string? version, string path, long sizeBytes,
|
||||
string? coverPath, string? backgroundPath)
|
||||
{
|
||||
Name = name;
|
||||
TitleId = titleId;
|
||||
Version = version;
|
||||
Path = path;
|
||||
_sizeBytes = sizeBytes;
|
||||
CoverPath = coverPath;
|
||||
@@ -46,6 +48,9 @@ public sealed class GameEntry : INotifyPropertyChanged
|
||||
|
||||
public string? TitleId { get; }
|
||||
|
||||
/// <summary>Content version from sce_sys/param.json, e.g. "01.000.000".</summary>
|
||||
public string? Version { get; }
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
/// <summary>
|
||||
@@ -65,7 +70,7 @@ public sealed class GameEntry : INotifyPropertyChanged
|
||||
|
||||
_sizeBytes = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SizeBytes)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Detail)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SizeText)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +111,15 @@ public sealed class GameEntry : INotifyPropertyChanged
|
||||
|
||||
public bool HasCover => _cover is not null;
|
||||
|
||||
public string Detail => TitleId is not null
|
||||
? $"{TitleId} • {FormatSize(SizeBytes)}"
|
||||
: FormatSize(SizeBytes);
|
||||
public bool HasTitleId => TitleId is not null;
|
||||
|
||||
/// <summary>Badge text shown in the launch bar, e.g. "v01.000.000".</summary>
|
||||
public string? VersionText => Version is null ? null : $"v{Version}";
|
||||
|
||||
public bool HasVersion => Version is not null;
|
||||
|
||||
/// <summary>Formatted install size badge shown in the launch bar.</summary>
|
||||
public string SizeText => FormatSize(SizeBytes);
|
||||
|
||||
private static string ComputeInitials(string name)
|
||||
{
|
||||
|
||||
@@ -42,6 +42,9 @@ public sealed class GuiSettings
|
||||
|
||||
public string? EmulatorPath { get; set; }
|
||||
|
||||
/// <summary>UI language, matching a file code under Languages/ (e.g. "en", "tr").</summary>
|
||||
public string Language { get; set; } = "en";
|
||||
|
||||
/// <summary>Publish launcher/game status to Discord Rich Presence.</summary>
|
||||
public bool DiscordRichPresence { get; set; } = true;
|
||||
|
||||
|
||||
129
src/SharpEmu.GUI/Languages/en.json
Normal file
129
src/SharpEmu.GUI/Languages/en.json
Normal file
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"_languageName": "English",
|
||||
|
||||
"Page.Library": "Library",
|
||||
"Page.Options": "Options",
|
||||
"Page.GameCount.One": "1 game",
|
||||
"Page.GameCount.Other": "{0} games",
|
||||
|
||||
"Library.SearchWatermark": "Search library…",
|
||||
"Library.AddFolder": "+ Add folder",
|
||||
"Library.Rescan": "⟳ Rescan",
|
||||
"Library.OpenFile": "Open file…",
|
||||
|
||||
"Library.Context.Launch": "Launch",
|
||||
"Library.Context.OpenFolder": "Open game folder",
|
||||
"Library.Context.CopyPath": "Copy path",
|
||||
"Library.Context.CopyTitleId": "Copy title ID",
|
||||
"Library.Context.Remove": "Remove from library",
|
||||
|
||||
"Library.Empty.Title": "Your library is empty",
|
||||
"Library.Empty.Hint": "Add a folder containing your games to get started.",
|
||||
"Library.Empty.SearchTitle": "No games match your search",
|
||||
"Library.Empty.SearchHint": "Nothing in the library matches “{0}”.",
|
||||
"Library.Empty.AddFolder": "+ Add game folder",
|
||||
|
||||
"Library.Loading": "Loading library…",
|
||||
|
||||
"Options.General": "General",
|
||||
"Options.Section.Emulation": "EMULATION",
|
||||
"Options.Section.Logging": "LOGGING",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
|
||||
"Options.CpuEngine.Label": "CPU engine",
|
||||
"Options.CpuEngine.Desc": "Execution engine used to run game code.",
|
||||
"Options.CpuEngine.Native": "Native",
|
||||
|
||||
"Options.Strict.Label": "Strict dynlib resolution",
|
||||
"Options.Strict.Desc": "Fail the launch when an imported symbol cannot be resolved.",
|
||||
|
||||
"Options.LogLevel.Label": "Log level",
|
||||
"Options.LogLevel.Desc": "Verbosity of the emulator console output.",
|
||||
"Options.LogLevel.Trace": "Trace",
|
||||
"Options.LogLevel.Debug": "Debug",
|
||||
"Options.LogLevel.Info": "Info",
|
||||
"Options.LogLevel.Warning": "Warning",
|
||||
"Options.LogLevel.Error": "Error",
|
||||
"Options.LogLevel.Critical": "Critical",
|
||||
|
||||
"Options.TraceImports.Label": "Import trace limit",
|
||||
"Options.TraceImports.Desc": "Trace the first N imports per module (0 = off).",
|
||||
|
||||
"Options.LogToFile.Label": "Log to file",
|
||||
"Options.LogToFile.Desc": "Mirror emulator output to a log file.",
|
||||
|
||||
"Options.LogFilePath.Label": "Log file path",
|
||||
"Options.LogFilePath.Default": "No custom path — logs go to user/logs next to the emulator.",
|
||||
"Options.LogFilePath.Select": "Select…",
|
||||
|
||||
"Options.OverrideLogFile.Label": "Override log file",
|
||||
"Options.OverrideLogFile.Desc": "Use the exact file path instead of appending title ID and timestamp.",
|
||||
|
||||
"Options.TitleMusic.Label": "Title music",
|
||||
"Options.TitleMusic.Desc": "Loop the selected game's preview music in the library.",
|
||||
|
||||
"Options.Discord.Label": "Discord presence",
|
||||
"Options.Discord.Desc": "Show the running game on your Discord profile.",
|
||||
|
||||
"Options.Language.Label": "Emulator language",
|
||||
"Options.Language.Desc": "Language used throughout the launcher. Applies immediately.",
|
||||
|
||||
"Common.On": "On",
|
||||
"Common.Off": "Off",
|
||||
|
||||
"Console.Title": "CONSOLE",
|
||||
"Console.SearchWatermark": "Search...",
|
||||
"Console.AutoScroll": "Auto-scroll",
|
||||
"Console.Split": "Split",
|
||||
"Console.Copy": "Copy",
|
||||
"Console.Clear": "Clear",
|
||||
"Console.WindowTitle": "SharpEmu Console",
|
||||
|
||||
"Launch.NoGameSelected": "No game selected",
|
||||
"Launch.NoGameHint": "Pick a game from the library, or open an eboot.bin directly.",
|
||||
"Launch.Idle": "Idle",
|
||||
"Launch.Console": "≡ Console",
|
||||
"Launch.Launch": "▶ Launch",
|
||||
"Launch.Stop": "■ Stop",
|
||||
"Launch.Running": "Running — {0}",
|
||||
"Launch.Stopping": "Stopping…",
|
||||
"Launch.Exited": "Exited with code {0} ({1})",
|
||||
"Launch.ExeNotFound": "SharpEmu executable not found. Build the SharpEmu.CLI project first (dotnet build).",
|
||||
"Launch.LogFile": "Log file: {0}",
|
||||
"Launch.Command": "$ SharpEmu {0}",
|
||||
"Launch.StartFailed": "Failed to start the emulator: {0}",
|
||||
"Launch.ProcessExited": "Process exited with code {0} ({1}).",
|
||||
|
||||
"Exit.Ok": "OK",
|
||||
"Exit.InvalidArguments": "invalid arguments",
|
||||
"Exit.EbootNotFound": "eboot not found",
|
||||
"Exit.RuntimeException": "runtime exception",
|
||||
"Exit.EmulationError": "emulation error",
|
||||
"Exit.Unknown": "unknown",
|
||||
|
||||
"Status.EmulatorLocating": "Emulator: locating…",
|
||||
"Status.EmulatorPath": "Emulator: {0}",
|
||||
"Status.EmulatorNotFound": "Emulator: SharpEmu executable not found — build SharpEmu.CLI first.",
|
||||
"Status.ScanningLibrary": "Scanning library…",
|
||||
"Status.AddFolderPrompt": "Add a game folder to populate the library.",
|
||||
"Status.LibraryScanned": "Library scanned: {0} game(s) in {1} folder(s).",
|
||||
"Status.CouldNotOpenFolder": "Could not open folder: {0}",
|
||||
"Status.CopiedToClipboard": "{0} copied to clipboard.",
|
||||
"Status.RemovedFromLibrary": "Removed “{0}” from the library. Re-add its folder to restore it.",
|
||||
"Status.Running": "Running {0}",
|
||||
"Status.Stopping": "Stopping…",
|
||||
"Status.Idle": "Idle",
|
||||
|
||||
"Clipboard.Path": "Path",
|
||||
"Clipboard.TitleId": "Title ID",
|
||||
|
||||
"Discord.Playing": "Playing {0}",
|
||||
"Discord.Browsing": "Browsing the library",
|
||||
|
||||
"Dialog.ChooseGameFolder": "Choose a folder containing games",
|
||||
"Dialog.OpenExecutable": "Open an executable to launch",
|
||||
"Dialog.PsExecutables": "PS executables",
|
||||
"Dialog.SaveLogFile": "Select where to save the Log file",
|
||||
"Dialog.PlainTextFiles": "Plain Text Files",
|
||||
"Dialog.LogFiles": "Log Files"
|
||||
}
|
||||
129
src/SharpEmu.GUI/Languages/tr.json
Normal file
129
src/SharpEmu.GUI/Languages/tr.json
Normal file
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"_languageName": "Türkçe",
|
||||
|
||||
"Page.Library": "Kütüphane",
|
||||
"Page.Options": "Ayarlar",
|
||||
"Page.GameCount.One": "1 oyun",
|
||||
"Page.GameCount.Other": "{0} oyun",
|
||||
|
||||
"Library.SearchWatermark": "Kütüphanede ara…",
|
||||
"Library.AddFolder": "+ Klasör ekle",
|
||||
"Library.Rescan": "⟳ Yeniden tara",
|
||||
"Library.OpenFile": "Dosya aç…",
|
||||
|
||||
"Library.Context.Launch": "Başlat",
|
||||
"Library.Context.OpenFolder": "Oyun klasörünü aç",
|
||||
"Library.Context.CopyPath": "Yolu kopyala",
|
||||
"Library.Context.CopyTitleId": "Title ID'yi kopyala",
|
||||
"Library.Context.Remove": "Kütüphaneden kaldır",
|
||||
|
||||
"Library.Empty.Title": "Kütüphaneniz boş",
|
||||
"Library.Empty.Hint": "Başlamak için oyunlarınızı içeren bir klasör ekleyin.",
|
||||
"Library.Empty.SearchTitle": "Aramanızla eşleşen oyun yok",
|
||||
"Library.Empty.SearchHint": "Kütüphanede “{0}” ile eşleşen bir şey yok.",
|
||||
"Library.Empty.AddFolder": "+ Oyun klasörü ekle",
|
||||
|
||||
"Library.Loading": "Kütüphane yükleniyor…",
|
||||
|
||||
"Options.General": "Genel",
|
||||
"Options.Section.Emulation": "EMÜLASYON",
|
||||
"Options.Section.Logging": "GÜNLÜKLEME",
|
||||
"Options.Section.Launcher": "BAŞLATICI",
|
||||
|
||||
"Options.CpuEngine.Label": "CPU motoru",
|
||||
"Options.CpuEngine.Desc": "Oyun kodunu çalıştırmak için kullanılan yürütme motoru.",
|
||||
"Options.CpuEngine.Native": "Native",
|
||||
|
||||
"Options.Strict.Label": "Katı dynlib çözümü",
|
||||
"Options.Strict.Desc": "İçe aktarılan bir sembol çözümlenemediğinde başlatmayı başarısız kılar.",
|
||||
|
||||
"Options.LogLevel.Label": "Günlük seviyesi",
|
||||
"Options.LogLevel.Desc": "Emülatör konsol çıktısının ayrıntı düzeyi.",
|
||||
"Options.LogLevel.Trace": "Trace",
|
||||
"Options.LogLevel.Debug": "Debug",
|
||||
"Options.LogLevel.Info": "Info",
|
||||
"Options.LogLevel.Warning": "Warning",
|
||||
"Options.LogLevel.Error": "Error",
|
||||
"Options.LogLevel.Critical": "Critical",
|
||||
|
||||
"Options.TraceImports.Label": "Import izleme sınırı",
|
||||
"Options.TraceImports.Desc": "Modül başına ilk N import'u izle (0 = kapalı).",
|
||||
|
||||
"Options.LogToFile.Label": "Dosyaya günlükle",
|
||||
"Options.LogToFile.Desc": "Emülatör çıktısını bir günlük dosyasına yansıt.",
|
||||
|
||||
"Options.LogFilePath.Label": "Günlük dosyası yolu",
|
||||
"Options.LogFilePath.Default": "Özel yol yok — günlükler emülatörün yanındaki user/logs klasörüne yazılır.",
|
||||
"Options.LogFilePath.Select": "Seç…",
|
||||
|
||||
"Options.OverrideLogFile.Label": "Günlük dosyasının üzerine yaz",
|
||||
"Options.OverrideLogFile.Desc": "Title ID ve zaman damgası eklemek yerine tam dosya yolunu kullan.",
|
||||
|
||||
"Options.TitleMusic.Label": "Oyun müziği",
|
||||
"Options.TitleMusic.Desc": "Kütüphanede seçili oyunun önizleme müziğini döngüye al.",
|
||||
|
||||
"Options.Discord.Label": "Discord durumu",
|
||||
"Options.Discord.Desc": "Discord profilinde çalışan oyunu göster.",
|
||||
|
||||
"Options.Language.Label": "Emülatör dili",
|
||||
"Options.Language.Desc": "Başlatıcı genelinde kullanılan dil. Hemen uygulanır.",
|
||||
|
||||
"Common.On": "Açık",
|
||||
"Common.Off": "Kapalı",
|
||||
|
||||
"Console.Title": "KONSOL",
|
||||
"Console.SearchWatermark": "Ara...",
|
||||
"Console.AutoScroll": "Otomatik kaydır",
|
||||
"Console.Split": "Ayır",
|
||||
"Console.Copy": "Kopyala",
|
||||
"Console.Clear": "Temizle",
|
||||
"Console.WindowTitle": "SharpEmu Konsol",
|
||||
|
||||
"Launch.NoGameSelected": "Oyun seçilmedi",
|
||||
"Launch.NoGameHint": "Kütüphaneden bir oyun seçin veya doğrudan bir eboot.bin açın.",
|
||||
"Launch.Idle": "Boşta",
|
||||
"Launch.Console": "≡ Konsol",
|
||||
"Launch.Launch": "▶ Başlat",
|
||||
"Launch.Stop": "■ Durdur",
|
||||
"Launch.Running": "Çalışıyor — {0}",
|
||||
"Launch.Stopping": "Durduruluyor…",
|
||||
"Launch.Exited": "Çıkış kodu {0} ({1})",
|
||||
"Launch.ExeNotFound": "SharpEmu çalıştırılabilir dosyası bulunamadı. Önce SharpEmu.CLI projesini derleyin (dotnet build).",
|
||||
"Launch.LogFile": "Günlük dosyası: {0}",
|
||||
"Launch.Command": "$ SharpEmu {0}",
|
||||
"Launch.StartFailed": "Emülatör başlatılamadı: {0}",
|
||||
"Launch.ProcessExited": "İşlem {0} kodla sonlandı ({1}).",
|
||||
|
||||
"Exit.Ok": "Tamam",
|
||||
"Exit.InvalidArguments": "geçersiz argümanlar",
|
||||
"Exit.EbootNotFound": "eboot bulunamadı",
|
||||
"Exit.RuntimeException": "çalışma zamanı hatası",
|
||||
"Exit.EmulationError": "emülasyon hatası",
|
||||
"Exit.Unknown": "bilinmiyor",
|
||||
|
||||
"Status.EmulatorLocating": "Emülatör: bulunuyor…",
|
||||
"Status.EmulatorPath": "Emülatör: {0}",
|
||||
"Status.EmulatorNotFound": "Emülatör: SharpEmu çalıştırılabilir dosyası bulunamadı — önce SharpEmu.CLI'yi derleyin.",
|
||||
"Status.ScanningLibrary": "Kütüphane taranıyor…",
|
||||
"Status.AddFolderPrompt": "Kütüphaneyi doldurmak için bir oyun klasörü ekleyin.",
|
||||
"Status.LibraryScanned": "Kütüphane tarandı: {1} klasörde {0} oyun.",
|
||||
"Status.CouldNotOpenFolder": "Klasör açılamadı: {0}",
|
||||
"Status.CopiedToClipboard": "{0} panoya kopyalandı.",
|
||||
"Status.RemovedFromLibrary": "“{0}” kütüphaneden kaldırıldı. Geri getirmek için klasörünü yeniden ekleyin.",
|
||||
"Status.Running": "{0} çalışıyor",
|
||||
"Status.Stopping": "Durduruluyor…",
|
||||
"Status.Idle": "Boşta",
|
||||
|
||||
"Clipboard.Path": "Yol",
|
||||
"Clipboard.TitleId": "Title ID",
|
||||
|
||||
"Discord.Playing": "{0} oynuyor",
|
||||
"Discord.Browsing": "Kütüphaneye göz atıyor",
|
||||
|
||||
"Dialog.ChooseGameFolder": "Oyunlarınızı içeren bir klasör seçin",
|
||||
"Dialog.OpenExecutable": "Başlatılacak bir çalıştırılabilir dosya aç",
|
||||
"Dialog.PsExecutables": "PS çalıştırılabilirleri",
|
||||
"Dialog.SaveLogFile": "Günlük dosyasının kaydedileceği yeri seçin",
|
||||
"Dialog.PlainTextFiles": "Düz Metin Dosyaları",
|
||||
"Dialog.LogFiles": "Günlük Dosyaları"
|
||||
}
|
||||
109
src/SharpEmu.GUI/Localization.cs
Normal file
109
src/SharpEmu.GUI/Localization.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
/// <summary>
|
||||
/// Loads UI strings for the launcher from Languages/<code>.json next to the
|
||||
/// executable. The files are plain, pretty-printed JSON (a flat key/value
|
||||
/// map) so they stay easy to open and translate by hand; a missing or
|
||||
/// unreadable key falls back to the key name itself.
|
||||
/// </summary>
|
||||
public sealed class Localization
|
||||
{
|
||||
public static Localization Instance { get; } = new();
|
||||
|
||||
public sealed record LanguageInfo(string Code, string NativeName);
|
||||
|
||||
private Dictionary<string, string> _strings = new();
|
||||
|
||||
private Localization()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Directory holding the *.json language files; user-editable.</summary>
|
||||
public static string LanguagesDirectory => Path.Combine(AppContext.BaseDirectory, "Languages");
|
||||
|
||||
public string CurrentCode { get; private set; } = "en";
|
||||
|
||||
public string Get(string key) => _strings.TryGetValue(key, out var value) ? value : key;
|
||||
|
||||
public string Format(string key, params object?[] args) => string.Format(Get(key), args);
|
||||
|
||||
/// <summary>Languages discovered under <see cref="LanguagesDirectory"/>, sorted by code.</summary>
|
||||
public List<LanguageInfo> DiscoverLanguages()
|
||||
{
|
||||
var languages = new List<LanguageInfo>();
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(LanguagesDirectory, "*.json"))
|
||||
{
|
||||
var code = Path.GetFileNameWithoutExtension(file);
|
||||
languages.Add(new LanguageInfo(code, ReadLanguageName(file) ?? code));
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Missing Languages directory: no languages to offer.
|
||||
}
|
||||
|
||||
languages.Sort((a, b) => string.CompareOrdinal(a.Code, b.Code));
|
||||
return languages;
|
||||
}
|
||||
|
||||
private static string? ReadLanguageName(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(path));
|
||||
if (document.RootElement.TryGetProperty("_languageName", out var name) &&
|
||||
name.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return name.GetString();
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Malformed file: fall back to the file's code as its display name.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Loads a language by file code (e.g. "en"), falling back to English.</summary>
|
||||
public void Load(string code)
|
||||
{
|
||||
if (!TryLoadFile(code) && !string.Equals(code, "en", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
TryLoadFile("en");
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLoadFile(string code)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(LanguagesDirectory, $"{code}.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var loaded = JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(path));
|
||||
if (loaded is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_strings = loaded;
|
||||
CurrentCode = code;
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Title="SharpEmu"
|
||||
Width="1280" Height="820"
|
||||
MinWidth="980" MinHeight="640"
|
||||
WindowState="Maximized"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource BgBrush}"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
@@ -17,7 +18,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
|
||||
KeyDown="OnKeyDown">
|
||||
|
||||
<Grid RowDefinitions="44,*,32">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- Selected-game backdrop: key art behind the main content, dimmed by
|
||||
a scrim so tiles and text stay readable. Fades on selection. -->
|
||||
@@ -41,8 +42,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- Title bar -->
|
||||
<Grid x:Name="TitleBar" Grid.Row="0" Background="{StaticResource ChromeBrush}">
|
||||
<!-- Title bar; hidden in fullscreen (F11) along with the status bar, so
|
||||
the game gets the whole screen. -->
|
||||
<Grid x:Name="TitleBar" Grid.Row="0" Height="44" Background="{StaticResource ChromeBrush}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" Margin="16,0" VerticalAlignment="Center">
|
||||
<Image Source="avares://SharpEmu.GUI/Assets/SharpEmu.ico" Width="20" Height="20"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality" />
|
||||
@@ -54,106 +56,273 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Grid>
|
||||
|
||||
<!-- Main content -->
|
||||
<Grid Grid.Row="1" Margin="18,14,18,14" RowDefinitions="Auto,*,Auto,Auto">
|
||||
<Grid Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<!-- Library toolbar -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" Margin="6,0,6,12">
|
||||
<TextBlock Grid.Column="0" Text="Library" FontSize="22" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="1" Classes="pill" Margin="12,2,0,0" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="GameCountText" Text="0 games" FontSize="11" Foreground="{StaticResource MutedBrush}" />
|
||||
</Border>
|
||||
<TextBox Grid.Column="3" x:Name="SearchBox" Watermark="Search library…" Width="280"
|
||||
VerticalAlignment="Center" Margin="0,0,12,0" />
|
||||
<Button Grid.Column="4" x:Name="AddFolderButton" Classes="ghost" Content="+ Add folder"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||
<Button Grid.Column="5" x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||
<Button Grid.Column="6" x:Name="OpenFileButton" Classes="ghost" Content="Open file…"
|
||||
VerticalAlignment="Center" />
|
||||
<!-- Library / Options page switcher, with the library toolbar sharing
|
||||
the same row on the right. Plain buttons (not TabItem) so there is
|
||||
no underline; LB/RB hint chips flank the pair and the gamepad's
|
||||
shoulder buttons actually switch pages from anywhere. -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,20">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
|
||||
<Border Classes="padHint" VerticalAlignment="Center">
|
||||
<TextBlock Text="LB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
|
||||
</Border>
|
||||
<Button x:Name="LibraryTabButton" Classes="segment active" Content="Library" />
|
||||
<Button x:Name="OptionsTabButton" Classes="segment" Content="Options" />
|
||||
<Border Classes="padHint" VerticalAlignment="Center">
|
||||
<TextBlock Text="RB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" x:Name="LibraryToolbar" Orientation="Horizontal" Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<TextBox x:Name="SearchBox" Watermark="Search library…" Width="240" VerticalAlignment="Center" />
|
||||
<Button x:Name="AddFolderButton" Classes="ghost" Content="+ Add folder" VerticalAlignment="Center" />
|
||||
<Button x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan" VerticalAlignment="Center" />
|
||||
<Button x:Name="OpenFileButton" Classes="ghost" Content="Open file…" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Cover-art grid -->
|
||||
<Panel Grid.Row="1">
|
||||
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
|
||||
SelectionMode="Single" Padding="0">
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu x:Name="GameContextMenu" Placement="Pointer">
|
||||
<MenuItem x:Name="CtxLaunch" Header="Launch" FontWeight="SemiBold">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="▶" FontSize="11" Foreground="{StaticResource AccentHoverBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="CtxOpenFolder" Header="Open game folder">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="📂" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<MenuItem x:Name="CtxCopyPath" Header="Copy path">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="CtxCopyTitleId" Header="Copy title ID">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<MenuItem x:Name="CtxRemove" Header="Remove from library"
|
||||
Foreground="{StaticResource DangerHoverBrush}">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="✕" FontSize="12" Foreground="{StaticResource DangerHoverBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
</ListBox.ContextMenu>
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Width="128" Height="186" Spacing="7">
|
||||
<Border Classes="coverShadow" Width="128" Height="128">
|
||||
<Border Classes="coverClip">
|
||||
<Panel>
|
||||
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
|
||||
<TextBlock Text="{Binding Initials}" FontSize="36" FontWeight="Bold"
|
||||
Foreground="#E8ECF4" Opacity="0.85"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Image Source="{Binding Cover}" Stretch="UniformToFill" IsVisible="{Binding HasCover}" />
|
||||
</Panel>
|
||||
</Border>
|
||||
</Border>
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"
|
||||
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding Detail}" FontSize="11" Foreground="{StaticResource MutedBrush}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- Empty state -->
|
||||
<StackPanel x:Name="EmptyState" Spacing="10" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
IsVisible="False">
|
||||
<TextBlock Text="🎮" FontSize="44" HorizontalAlignment="Center" Opacity="0.7" />
|
||||
<TextBlock x:Name="EmptyStateTitle" Text="Your library is empty" FontSize="18" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock x:Name="EmptyStateHint" Text="Add a folder containing your games to get started."
|
||||
FontSize="13" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
|
||||
<Button x:Name="EmptyAddFolderButton" Classes="accent" Content="+ Add game folder"
|
||||
HorizontalAlignment="Center" Margin="0,8,0,0" />
|
||||
</StackPanel>
|
||||
<!-- Library page. The tile row gets extra top margin so it sits
|
||||
closer to eye level (PS5 home-screen style) instead of hugging
|
||||
the Library/Options switcher above it. -->
|
||||
<Panel x:Name="LibraryPage" Margin="0,64,0,0">
|
||||
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
|
||||
SelectionMode="Single" Padding="0">
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu x:Name="GameContextMenu" Placement="Pointer">
|
||||
<MenuItem x:Name="CtxLaunch" Header="Launch" FontWeight="SemiBold">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="▶" FontSize="11" Foreground="{StaticResource AccentHoverBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="CtxOpenFolder" Header="Open game folder">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="📂" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<MenuItem x:Name="CtxCopyPath" Header="Copy path">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="CtxCopyTitleId" Header="Copy title ID">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<MenuItem x:Name="CtxRemove" Header="Remove from library"
|
||||
Foreground="{StaticResource DangerHoverBrush}">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="✕" FontSize="12" Foreground="{StaticResource DangerHoverBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
</ListBox.ContextMenu>
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Width="128" Height="172" Spacing="7">
|
||||
<Border Classes="coverShadow" Width="128" Height="128">
|
||||
<Border Classes="coverClip">
|
||||
<Panel>
|
||||
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
|
||||
<TextBlock Text="{Binding Initials}" FontSize="36" FontWeight="Bold"
|
||||
Foreground="#E8ECF4" Opacity="0.85"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Image Source="{Binding Cover}" Stretch="UniformToFill" IsVisible="{Binding HasCover}" />
|
||||
</Panel>
|
||||
</Border>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"
|
||||
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
|
||||
TextAlignment="Center" HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- Empty state -->
|
||||
<StackPanel x:Name="EmptyState" Spacing="10" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
IsVisible="False">
|
||||
<TextBlock Text="🎮" FontSize="44" HorizontalAlignment="Center" Opacity="0.7" />
|
||||
<TextBlock x:Name="EmptyStateTitle" Text="Your library is empty" FontSize="18" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock x:Name="EmptyStateHint" Text="Add a folder containing your games to get started."
|
||||
FontSize="13" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
|
||||
<Button x:Name="EmptyAddFolderButton" Classes="accent" Content="+ Add game folder"
|
||||
HorizontalAlignment="Center" Margin="0,8,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Loading state: covers the grid while a scan (initial load,
|
||||
rescan, or a just-added folder) is in flight, so the screen
|
||||
never looks blank mid-scan. -->
|
||||
<StackPanel x:Name="LoadingState" Spacing="14" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
IsVisible="False">
|
||||
<ProgressBar IsIndeterminate="True" Width="180" Height="3" />
|
||||
<TextBlock x:Name="LoadingStateText" Text="Loading library…" FontSize="13"
|
||||
Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
|
||||
<!-- Options page: sub-tabs so future categories (Graphics, …) slot
|
||||
in next to General. Card-grouped sections match the visual
|
||||
language used by the console panel and launch bar below. -->
|
||||
<Grid x:Name="OptionsPage" IsVisible="False">
|
||||
<TabControl>
|
||||
<TabItem x:Name="GeneralTabItem" Header="General" FontSize="15">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock x:Name="EmulationSectionTitle" Classes="sectionTitle" Text="EMULATION" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="CpuEngineLabel" Text="CPU engine" FontSize="13" />
|
||||
<TextBlock x:Name="CpuEngineDesc" Text="Execution engine used to run game code."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ComboBox Grid.Column="1" x:Name="CpuEngineBox" Width="160" SelectedIndex="0"
|
||||
VerticalAlignment="Center" CornerRadius="8">
|
||||
<ComboBoxItem x:Name="CpuEngineNativeItem" Content="Native" />
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="StrictLabel" Text="Strict dynlib resolution" FontSize="13" />
|
||||
<TextBlock x:Name="StrictDesc" Text="Fail the launch when an imported symbol cannot be resolved."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="StrictToggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock x:Name="LoggingSectionTitle" Classes="sectionTitle" Text="LOGGING" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="LogLevelLabel" Text="Log level" FontSize="13" />
|
||||
<TextBlock x:Name="LogLevelDesc" Text="Verbosity of the emulator console output."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ComboBox Grid.Column="1" x:Name="LogLevelBox" Width="160" SelectedIndex="2"
|
||||
VerticalAlignment="Center" CornerRadius="8">
|
||||
<ComboBoxItem x:Name="LogLevelTraceItem" Content="Trace" />
|
||||
<ComboBoxItem x:Name="LogLevelDebugItem" Content="Debug" />
|
||||
<ComboBoxItem x:Name="LogLevelInfoItem" Content="Info" />
|
||||
<ComboBoxItem x:Name="LogLevelWarningItem" Content="Warning" />
|
||||
<ComboBoxItem x:Name="LogLevelErrorItem" Content="Error" />
|
||||
<ComboBoxItem x:Name="LogLevelCriticalItem" Content="Critical" />
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="TraceImportsLabel" Text="Import trace limit" FontSize="13" />
|
||||
<TextBlock x:Name="TraceImportsDesc" Text="Trace the first N imports per module (0 = off)."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<NumericUpDown Grid.Column="1" x:Name="TraceImportsBox" Width="160" Minimum="0"
|
||||
Maximum="4096" Increment="16" Value="0" FormatString="0"
|
||||
VerticalAlignment="Center" CornerRadius="8" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="LogToFileLabel" Text="Log to file" FontSize="13" />
|
||||
<TextBlock x:Name="LogToFileDesc" Text="Mirror emulator output to a log file."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="LogToFileToggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="LogFilePathLabel" Text="Log file path" FontSize="13" />
|
||||
<TextBlock x:Name="LogFilePathText" Text="No custom path"
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" x:Name="SelectLogFilePathButton" Classes="ghost" Content="Select…"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="OverrideLogFileLabel" Text="Override log file" FontSize="13" />
|
||||
<TextBlock x:Name="OverrideLogFileDesc"
|
||||
Text="Use the exact file path instead of appending title ID and timestamp."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="OverrideLogFileToggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock x:Name="LauncherSectionTitle" Classes="sectionTitle" Text="LAUNCHER" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="LanguageLabel" Text="Emulator language" FontSize="13" />
|
||||
<TextBlock x:Name="LanguageDesc"
|
||||
Text="Language used throughout the launcher. Applies immediately."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ComboBox Grid.Column="1" x:Name="LanguageBox" Width="160"
|
||||
VerticalAlignment="Center" CornerRadius="8"
|
||||
DisplayMemberBinding="{Binding NativeName}" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="TitleMusicLabel" Text="Title music" FontSize="13" />
|
||||
<TextBlock x:Name="TitleMusicDesc" Text="Loop the selected game's preview music in the library."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="TitleMusicToggle" OnContent="On" OffContent="Off"
|
||||
IsChecked="True" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="DiscordLabel" Text="Discord presence" FontSize="13" />
|
||||
<TextBlock x:Name="DiscordDesc" Text="Show the running game on your Discord profile."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="DiscordToggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Grid>
|
||||
</Panel>
|
||||
|
||||
<!-- Console (collapsible) -->
|
||||
@@ -161,7 +330,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Margin="0,12,0,0" IsVisible="False">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto" Margin="16,12,16,8">
|
||||
<TextBlock Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="ConsoleSectionTitle" Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" FontSize="12" Margin="0,0,12,0" x:Name="ConsoleSearchBox"
|
||||
Watermark="Search..." Width="320" />
|
||||
<CheckBox Grid.Column="2" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
|
||||
@@ -184,8 +353,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Launch bar -->
|
||||
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14">
|
||||
<!-- Launch bar; capped so it does not sprawl on a maximized window. -->
|
||||
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14" MaxWidth="1280">
|
||||
<StackPanel Spacing="14">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
|
||||
@@ -204,9 +373,32 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1" Spacing="3" VerticalAlignment="Center" Margin="14,0,14,0">
|
||||
<TextBlock x:Name="SelectedGameTitle" Text="No game selected" FontSize="16" FontWeight="Bold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<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" 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">
|
||||
@@ -218,69 +410,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<ToggleButton x:Name="OptionsToggle" Classes="ghost" Content="⚙ Options" />
|
||||
<ToggleButton x:Name="ConsoleToggle" Classes="ghost" Content="≡ Console" />
|
||||
<Button x:Name="LaunchButton" Classes="accent" Content="▶ Launch" IsEnabled="False" />
|
||||
<Button x:Name="StopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Launch options (collapsible) -->
|
||||
<WrapPanel x:Name="OptionsPanel" IsVisible="False">
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="CPU engine" />
|
||||
<ComboBox x:Name="CpuEngineBox" Width="150" SelectedIndex="0" CornerRadius="8">
|
||||
<ComboBoxItem Content="Native" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="Log level" />
|
||||
<ComboBox x:Name="LogLevelBox" Width="150" SelectedIndex="2" CornerRadius="8">
|
||||
<ComboBoxItem Content="Trace" />
|
||||
<ComboBoxItem Content="Debug" />
|
||||
<ComboBoxItem Content="Info" />
|
||||
<ComboBoxItem Content="Warning" />
|
||||
<ComboBoxItem Content="Error" />
|
||||
<ComboBoxItem Content="Critical" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="Import trace limit (0 = off)" />
|
||||
<NumericUpDown x:Name="TraceImportsBox" Width="170" Minimum="0" Maximum="4096" Increment="16"
|
||||
Value="0" FormatString="0" CornerRadius="8" />
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="Strict dynlib resolution" />
|
||||
<ToggleSwitch x:Name="StrictToggle" OnContent="On" OffContent="Off" />
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="Log to file" />
|
||||
<ToggleSwitch x:Name="LogToFileToggle" OnContent="On" OffContent="Off" />
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="Log file path" />
|
||||
<Button x:Name="SelectLogFilePathButton" Classes="ghost" Content="Select…"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="Override log file" />
|
||||
<ToggleSwitch x:Name="OverrideLogFileToggle" OnContent="On" OffContent="Off" />
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="Title music" />
|
||||
<ToggleSwitch x:Name="TitleMusicToggle" OnContent="On" OffContent="Off" IsChecked="True" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="fieldLabel" Text="Discord presence" />
|
||||
<ToggleSwitch x:Name="DiscordToggle" OnContent="On" OffContent="Off" />
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Status bar -->
|
||||
<Grid Grid.Row="2" Background="{StaticResource ChromeBrush}" ColumnDefinitions="*,Auto">
|
||||
<Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}"
|
||||
ColumnDefinitions="*,Auto">
|
||||
<TextBlock x:Name="EmulatorPathText" Grid.Column="0" Text="Emulator: locating…" FontSize="11"
|
||||
Foreground="{StaticResource FaintBrush}" VerticalAlignment="Center" Margin="16,0"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
@@ -48,6 +48,7 @@ public partial class MainWindow : Window
|
||||
private string? _emulatorExePath;
|
||||
private bool _isRunning;
|
||||
private int _autoScrollTicks;
|
||||
private int _activePageIndex;
|
||||
|
||||
// Discord Rich Presence state.
|
||||
private readonly long _launcherStartUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
@@ -100,23 +101,38 @@ public partial class MainWindow : Window
|
||||
ClearLogButton.Click += (_, _) => _consoleLines.Clear();
|
||||
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
|
||||
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
|
||||
OptionsToggle.IsCheckedChanged += (_, _) => OptionsPanel.IsVisible = OptionsToggle.IsChecked == true;
|
||||
LibraryTabButton.Click += (_, _) => SetActivePage(0);
|
||||
OptionsTabButton.Click += (_, _) => SetActivePage(1);
|
||||
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
||||
SelectLogFilePathButton.Click += async (_, _) => await SelectFilePathAsync();
|
||||
TitleMusicToggle.IsCheckedChanged += (_, _) => OnTitleMusicToggled();
|
||||
|
||||
// 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();
|
||||
};
|
||||
SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync();
|
||||
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
|
||||
|
||||
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
|
||||
CtxLaunch.Click += (_, _) => LaunchSelected();
|
||||
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
|
||||
CtxCopyPath.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Path");
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Clipboard.Path");
|
||||
CtxCopyTitleId.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Title ID");
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Clipboard.TitleId");
|
||||
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
|
||||
|
||||
Opened += async (_, _) => await OnOpenedAsync();
|
||||
@@ -132,6 +148,45 @@ public partial class MainWindow : Window
|
||||
_gamepadTimer.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switches between the Library and Options pages. Also reachable via
|
||||
/// the gamepad's shoulder buttons (LB/RB, L1/R1) from <see cref="PollGamepad"/>.
|
||||
/// </summary>
|
||||
private void SetActivePage(int index)
|
||||
{
|
||||
if (index == _activePageIndex)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_activePageIndex == 1)
|
||||
{
|
||||
_settings.Save(); // leaving the Options page
|
||||
}
|
||||
|
||||
_activePageIndex = index;
|
||||
SetActiveClass(LibraryTabButton, index == 0);
|
||||
SetActiveClass(OptionsTabButton, index == 1);
|
||||
LibraryPage.IsVisible = index == 0;
|
||||
LibraryToolbar.IsVisible = index == 0;
|
||||
OptionsPage.IsVisible = index == 1;
|
||||
}
|
||||
|
||||
private static void SetActiveClass(Button button, bool active)
|
||||
{
|
||||
if (active)
|
||||
{
|
||||
if (!button.Classes.Contains("active"))
|
||||
{
|
||||
button.Classes.Add("active");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
button.Classes.Remove("active");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Controller navigation ----
|
||||
|
||||
private void PollGamepad()
|
||||
@@ -145,8 +200,25 @@ public partial class MainWindow : Window
|
||||
|
||||
if (!IsActive)
|
||||
{
|
||||
// Ignore input while the launcher is in the background (e.g. the
|
||||
// game window is focused and using the same controller).
|
||||
// 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;
|
||||
}
|
||||
|
||||
var shoulderPressed = pad.Buttons & ~_previousPadButtons;
|
||||
if ((shoulderPressed & OrbisPadButton.L1) != 0)
|
||||
{
|
||||
SetActivePage(0);
|
||||
}
|
||||
|
||||
if ((shoulderPressed & OrbisPadButton.R1) != 0)
|
||||
{
|
||||
SetActivePage(1);
|
||||
}
|
||||
|
||||
if (_activePageIndex != 0)
|
||||
{
|
||||
_previousPadButtons = pad.Buttons;
|
||||
return;
|
||||
}
|
||||
@@ -254,12 +326,126 @@ public partial class MainWindow : Window
|
||||
ToolTip.SetTip(VersionText, BuildInfo.Banner);
|
||||
|
||||
_settings = GuiSettings.Load();
|
||||
Localization.Instance.Load(_settings.Language);
|
||||
PopulateLanguageBox();
|
||||
ApplyLocalization();
|
||||
ApplySettingsToControls();
|
||||
LocateEmulator();
|
||||
UpdateDiscordPresence();
|
||||
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 Localization.LanguageInfo language)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_settings.Language = language.Code;
|
||||
Localization.Instance.Load(language.Code);
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-applies every UI string from the current language, so switching
|
||||
/// languages in Options takes effect immediately without reopening the
|
||||
/// window.
|
||||
/// </summary>
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
var loc = Localization.Instance;
|
||||
|
||||
LibraryTabButton.Content = loc.Get("Page.Library");
|
||||
OptionsTabButton.Content = loc.Get("Page.Options");
|
||||
|
||||
SearchBox.Watermark = loc.Get("Library.SearchWatermark");
|
||||
AddFolderButton.Content = loc.Get("Library.AddFolder");
|
||||
RescanButton.Content = loc.Get("Library.Rescan");
|
||||
OpenFileButton.Content = loc.Get("Library.OpenFile");
|
||||
|
||||
CtxLaunch.Header = loc.Get("Library.Context.Launch");
|
||||
CtxOpenFolder.Header = loc.Get("Library.Context.OpenFolder");
|
||||
CtxCopyPath.Header = loc.Get("Library.Context.CopyPath");
|
||||
CtxCopyTitleId.Header = loc.Get("Library.Context.CopyTitleId");
|
||||
CtxRemove.Header = loc.Get("Library.Context.Remove");
|
||||
|
||||
EmptyAddFolderButton.Content = loc.Get("Library.Empty.AddFolder");
|
||||
LoadingStateText.Text = loc.Get("Library.Loading");
|
||||
|
||||
GeneralTabItem.Header = loc.Get("Options.General");
|
||||
EmulationSectionTitle.Text = loc.Get("Options.Section.Emulation");
|
||||
LoggingSectionTitle.Text = loc.Get("Options.Section.Logging");
|
||||
LauncherSectionTitle.Text = loc.Get("Options.Section.Launcher");
|
||||
|
||||
CpuEngineLabel.Text = loc.Get("Options.CpuEngine.Label");
|
||||
CpuEngineDesc.Text = loc.Get("Options.CpuEngine.Desc");
|
||||
CpuEngineNativeItem.Content = loc.Get("Options.CpuEngine.Native");
|
||||
|
||||
StrictLabel.Text = loc.Get("Options.Strict.Label");
|
||||
StrictDesc.Text = loc.Get("Options.Strict.Desc");
|
||||
|
||||
LogLevelLabel.Text = loc.Get("Options.LogLevel.Label");
|
||||
LogLevelDesc.Text = loc.Get("Options.LogLevel.Desc");
|
||||
LogLevelTraceItem.Content = loc.Get("Options.LogLevel.Trace");
|
||||
LogLevelDebugItem.Content = loc.Get("Options.LogLevel.Debug");
|
||||
LogLevelInfoItem.Content = loc.Get("Options.LogLevel.Info");
|
||||
LogLevelWarningItem.Content = loc.Get("Options.LogLevel.Warning");
|
||||
LogLevelErrorItem.Content = loc.Get("Options.LogLevel.Error");
|
||||
LogLevelCriticalItem.Content = loc.Get("Options.LogLevel.Critical");
|
||||
|
||||
TraceImportsLabel.Text = loc.Get("Options.TraceImports.Label");
|
||||
TraceImportsDesc.Text = loc.Get("Options.TraceImports.Desc");
|
||||
|
||||
LogToFileLabel.Text = loc.Get("Options.LogToFile.Label");
|
||||
LogToFileDesc.Text = loc.Get("Options.LogToFile.Desc");
|
||||
|
||||
LogFilePathLabel.Text = loc.Get("Options.LogFilePath.Label");
|
||||
SelectLogFilePathButton.Content = loc.Get("Options.LogFilePath.Select");
|
||||
UpdateLogFilePathText();
|
||||
|
||||
OverrideLogFileLabel.Text = loc.Get("Options.OverrideLogFile.Label");
|
||||
OverrideLogFileDesc.Text = loc.Get("Options.OverrideLogFile.Desc");
|
||||
|
||||
LanguageLabel.Text = loc.Get("Options.Language.Label");
|
||||
LanguageDesc.Text = loc.Get("Options.Language.Desc");
|
||||
|
||||
TitleMusicLabel.Text = loc.Get("Options.TitleMusic.Label");
|
||||
TitleMusicDesc.Text = loc.Get("Options.TitleMusic.Desc");
|
||||
|
||||
DiscordLabel.Text = loc.Get("Options.Discord.Label");
|
||||
DiscordDesc.Text = loc.Get("Options.Discord.Desc");
|
||||
|
||||
foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle })
|
||||
{
|
||||
toggle.OnContent = loc.Get("Common.On");
|
||||
toggle.OffContent = loc.Get("Common.Off");
|
||||
}
|
||||
|
||||
ConsoleSectionTitle.Text = loc.Get("Console.Title");
|
||||
ConsoleSearchBox.Watermark = loc.Get("Console.SearchWatermark");
|
||||
AutoScrollCheck.Content = loc.Get("Console.AutoScroll");
|
||||
DetachConsoleButton.Content = loc.Get("Console.Split");
|
||||
CopyLogButton.Content = loc.Get("Console.Copy");
|
||||
ClearLogButton.Content = loc.Get("Console.Clear");
|
||||
|
||||
ConsoleToggle.Content = loc.Get("Launch.Console");
|
||||
LaunchButton.Content = loc.Get("Launch.Launch");
|
||||
StopButton.Content = loc.Get("Launch.Stop");
|
||||
|
||||
UpdateEmptyStateTexts();
|
||||
UpdateSelectedGameTexts();
|
||||
}
|
||||
|
||||
// ---- Discord Rich Presence ----
|
||||
|
||||
/// <summary>
|
||||
@@ -280,7 +466,7 @@ public partial class MainWindow : Window
|
||||
if (_isRunning && _runningGameName is { } gameName)
|
||||
{
|
||||
_discord.SetPresence(
|
||||
$"Playing {gameName}",
|
||||
Localization.Instance.Format("Discord.Playing", gameName),
|
||||
_runningGameTitleId,
|
||||
_runningSinceUnixSeconds);
|
||||
}
|
||||
@@ -288,9 +474,12 @@ public partial class MainWindow : Window
|
||||
{
|
||||
// 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(
|
||||
"Browsing the library",
|
||||
$"{_allGames.Count} game(s)",
|
||||
Localization.Instance.Get("Discord.Browsing"),
|
||||
count,
|
||||
_launcherStartUnixSeconds);
|
||||
}
|
||||
}
|
||||
@@ -315,17 +504,20 @@ public partial class MainWindow : Window
|
||||
{
|
||||
WindowState = WindowState.Normal;
|
||||
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
|
||||
TitleBar.IsVisible = true;
|
||||
StatusBar.IsVisible = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
WindowState = WindowState.FullScreen;
|
||||
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
|
||||
TitleBar.IsVisible = false;
|
||||
StatusBar.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnWindowClosing()
|
||||
{
|
||||
ReadControlsIntoSettings();
|
||||
_settings.Save();
|
||||
_consoleFlushTimer.Stop();
|
||||
_gamepadTimer.Stop();
|
||||
@@ -344,7 +536,7 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Settings <-> controls ----
|
||||
// ---- Settings ----
|
||||
|
||||
private void ApplySettingsToControls()
|
||||
{
|
||||
@@ -363,19 +555,8 @@ public partial class MainWindow : Window
|
||||
LogToFileToggle.IsChecked = _settings.LogToFile;
|
||||
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
|
||||
TitleMusicToggle.IsChecked = _settings.PlayTitleMusic;
|
||||
ToolTip.SetTip(SelectLogFilePathButton, string.IsNullOrWhiteSpace(_settings.LogFilePath) ? "No path selected" : _settings.LogFilePath);
|
||||
DiscordToggle.IsChecked = _settings.DiscordRichPresence;
|
||||
}
|
||||
|
||||
private void ReadControlsIntoSettings()
|
||||
{
|
||||
_settings.LogLevel = SelectedLogLevel();
|
||||
_settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0);
|
||||
_settings.StrictDynlibResolution = StrictToggle.IsChecked == true;
|
||||
_settings.LogToFile = LogToFileToggle.IsChecked == true;
|
||||
_settings.OverrideLogFile = OverrideLogFileToggle.IsChecked == true;
|
||||
_settings.PlayTitleMusic = TitleMusicToggle.IsChecked == true;
|
||||
_settings.DiscordRichPresence = DiscordToggle.IsChecked == true;
|
||||
UpdateLogFilePathText();
|
||||
}
|
||||
|
||||
private string SelectedLogLevel()
|
||||
@@ -392,6 +573,35 @@ public partial class MainWindow : Window
|
||||
};
|
||||
}
|
||||
|
||||
private void UpdateLogFilePathText()
|
||||
{
|
||||
LogFilePathText.Text = string.IsNullOrWhiteSpace(_settings.LogFilePath)
|
||||
? Localization.Instance.Get("Options.LogFilePath.Default")
|
||||
: _settings.LogFilePath;
|
||||
}
|
||||
|
||||
private async Task SelectLogFilePathAsync()
|
||||
{
|
||||
var loc = Localization.Instance;
|
||||
SaveFilePickerResult result = await StorageProvider.SaveFilePickerWithResultAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = loc.Get("Dialog.SaveLogFile"),
|
||||
SuggestedFileName = "SharpEmuLog",
|
||||
DefaultExtension = "log",
|
||||
FileTypeChoices =
|
||||
[
|
||||
new FilePickerFileType(loc.Get("Dialog.PlainTextFiles")) { Patterns = ["*.txt"] },
|
||||
new FilePickerFileType(loc.Get("Dialog.LogFiles")) { Patterns = ["*.log"] }
|
||||
]
|
||||
});
|
||||
|
||||
if (result.File is not null)
|
||||
{
|
||||
_settings.LogFilePath = result.File.Path.LocalPath;
|
||||
UpdateLogFilePathText();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Emulator discovery ----
|
||||
|
||||
private void LocateEmulator()
|
||||
@@ -421,8 +631,8 @@ public partial class MainWindow : Window
|
||||
: null;
|
||||
|
||||
EmulatorPathText.Text = _emulatorExePath is not null
|
||||
? $"Emulator: {_emulatorExePath}"
|
||||
: "Emulator: SharpEmu executable not found — build SharpEmu.CLI first.";
|
||||
? Localization.Instance.Format("Status.EmulatorPath", _emulatorExePath)
|
||||
: Localization.Instance.Get("Status.EmulatorNotFound");
|
||||
}
|
||||
|
||||
// ---- Game library ----
|
||||
@@ -431,7 +641,7 @@ public partial class MainWindow : Window
|
||||
{
|
||||
var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Choose a folder containing games",
|
||||
Title = Localization.Instance.Get("Dialog.ChooseGameFolder"),
|
||||
AllowMultiple = false,
|
||||
});
|
||||
|
||||
@@ -466,18 +676,21 @@ public partial class MainWindow : Window
|
||||
{
|
||||
var folders = _settings.GameFolders.ToArray();
|
||||
var excluded = new HashSet<string>(_settings.ExcludedGames, StringComparer.OrdinalIgnoreCase);
|
||||
StatusBarRight.Text = "Scanning library…";
|
||||
StatusBarRight.Text = Localization.Instance.Get("Status.ScanningLibrary");
|
||||
EmptyState.IsVisible = false;
|
||||
LoadingState.IsVisible = true;
|
||||
|
||||
var games = await Task.Run(() => ScanFolders(folders, excluded));
|
||||
|
||||
_allGames.Clear();
|
||||
_allGames.AddRange(games);
|
||||
RefreshVisibleGames();
|
||||
LoadingState.IsVisible = false;
|
||||
LoadGameDetailsInBackground(games);
|
||||
UpdateDiscordPresence();
|
||||
StatusBarRight.Text = folders.Length == 0
|
||||
? "Add a game folder to populate the library."
|
||||
: $"Library scanned: {games.Count} game(s) in {folders.Length} folder(s).";
|
||||
? Localization.Instance.Get("Status.AddFolderPrompt")
|
||||
: Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -614,9 +827,9 @@ public partial class MainWindow : Window
|
||||
{
|
||||
}
|
||||
|
||||
var (title, titleId) = TryReadParamJson(fullPath);
|
||||
var (title, titleId, version) = TryReadParamJson(fullPath);
|
||||
games.Add(new GameEntry(
|
||||
title ?? GameNameFor(fullPath), titleId, fullPath, size,
|
||||
title ?? GameNameFor(fullPath), titleId, version, fullPath, size,
|
||||
FindCoverFor(fullPath), FindBackgroundFor(fullPath)));
|
||||
}
|
||||
}
|
||||
@@ -631,23 +844,23 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the game title and title id from sce_sys/param.json next to the
|
||||
/// executable, when present.
|
||||
/// Reads the game title, title id and content version from
|
||||
/// sce_sys/param.json next to the executable, when present.
|
||||
/// </summary>
|
||||
private static (string? Title, string? TitleId) TryReadParamJson(string ebootPath)
|
||||
private static (string? Title, string? TitleId, string? Version) TryReadParamJson(string ebootPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(ebootPath);
|
||||
if (directory is null)
|
||||
{
|
||||
return (null, null);
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
var paramPath = Path.Combine(directory, "sce_sys", "param.json");
|
||||
if (!File.Exists(paramPath))
|
||||
{
|
||||
return (null, null);
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
// ReadAllText handles a UTF-8 BOM, which JsonDocument rejects in
|
||||
@@ -661,6 +874,20 @@ public partial class MainWindow : Window
|
||||
titleId = idElement.GetString();
|
||||
}
|
||||
|
||||
// contentVersion carries the installed app version
|
||||
// ("01.000.000"); masterVersion is the fallback on older dumps.
|
||||
string? version = null;
|
||||
if (root.TryGetProperty("contentVersion", out var versionElement) &&
|
||||
versionElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
version = versionElement.GetString();
|
||||
}
|
||||
else if (root.TryGetProperty("masterVersion", out var masterElement) &&
|
||||
masterElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
version = masterElement.GetString();
|
||||
}
|
||||
|
||||
string? title = null;
|
||||
if (root.TryGetProperty("localizedParameters", out var localized) &&
|
||||
localized.ValueKind == JsonValueKind.Object)
|
||||
@@ -691,11 +918,12 @@ public partial class MainWindow : Window
|
||||
|
||||
return (
|
||||
string.IsNullOrWhiteSpace(title) ? null : title,
|
||||
string.IsNullOrWhiteSpace(titleId) ? null : titleId);
|
||||
string.IsNullOrWhiteSpace(titleId) ? null : titleId,
|
||||
string.IsNullOrWhiteSpace(version) ? null : version.Trim());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return (null, null);
|
||||
return (null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,11 +1034,12 @@ public partial class MainWindow : Window
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusBarRight.Text = $"Could not open folder: {ex.Message}";
|
||||
StatusBarRight.Text = Localization.Instance.Format("Status.CouldNotOpenFolder", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CopyToClipboardAsync(string? text, string what)
|
||||
/// <summary>Copies <paramref name="text"/> and reports it via <paramref name="whatKey"/>, e.g. "Clipboard.Path".</summary>
|
||||
private async Task CopyToClipboardAsync(string? text, string whatKey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || Clipboard is null)
|
||||
{
|
||||
@@ -818,7 +1047,7 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
await Clipboard.SetTextAsync(text);
|
||||
StatusBarRight.Text = $"{what} copied to clipboard.";
|
||||
StatusBarRight.Text = Localization.Instance.Format("Status.CopiedToClipboard", Localization.Instance.Get(whatKey));
|
||||
}
|
||||
|
||||
private void RemoveSelectedFromLibrary()
|
||||
@@ -837,7 +1066,7 @@ public partial class MainWindow : Window
|
||||
_allGames.RemoveAll(g => string.Equals(g.Path, game.Path, StringComparison.OrdinalIgnoreCase));
|
||||
GameList.SelectedItem = null;
|
||||
RefreshVisibleGames();
|
||||
StatusBarRight.Text = $"Removed “{game.Name}” from the library. Re-add its folder to restore it.";
|
||||
StatusBarRight.Text = Localization.Instance.Format("Status.RemovedFromLibrary", game.Name);
|
||||
}
|
||||
|
||||
private void RefreshVisibleGames()
|
||||
@@ -857,8 +1086,6 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
GameCountText.Text = _visibleGames.Count == 1 ? "1 game" : $"{_visibleGames.Count} games";
|
||||
|
||||
if (selectedPath is not null &&
|
||||
_visibleGames.FirstOrDefault(g => g.Path.Equals(selectedPath, StringComparison.OrdinalIgnoreCase))
|
||||
is { } reselected)
|
||||
@@ -867,34 +1094,50 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
EmptyState.IsVisible = _visibleGames.Count == 0;
|
||||
if (_visibleGames.Count == 0)
|
||||
{
|
||||
var hasFilter = query.Length > 0;
|
||||
EmptyStateTitle.Text = hasFilter ? "No games match your search" : "Your library is empty";
|
||||
EmptyStateHint.Text = hasFilter
|
||||
? $"Nothing in the library matches “{query}”."
|
||||
: "Add a folder containing your games to get started.";
|
||||
EmptyAddFolderButton.IsVisible = !hasFilter;
|
||||
}
|
||||
UpdateEmptyStateTexts();
|
||||
|
||||
UpdateSelectedGame();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the empty-state title/hint from the current language and
|
||||
/// search text; a no-op while the empty state is not showing.
|
||||
/// </summary>
|
||||
private void UpdateEmptyStateTexts()
|
||||
{
|
||||
if (_visibleGames.Count != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var query = SearchBox.Text?.Trim() ?? string.Empty;
|
||||
var hasFilter = query.Length > 0;
|
||||
EmptyStateTitle.Text = hasFilter
|
||||
? Localization.Instance.Get("Library.Empty.SearchTitle")
|
||||
: Localization.Instance.Get("Library.Empty.Title");
|
||||
EmptyStateHint.Text = hasFilter
|
||||
? Localization.Instance.Format("Library.Empty.SearchHint", query)
|
||||
: Localization.Instance.Get("Library.Empty.Hint");
|
||||
EmptyAddFolderButton.IsVisible = !hasFilter;
|
||||
}
|
||||
|
||||
private void UpdateSelectedGame()
|
||||
{
|
||||
if (GameList.SelectedItem is GameEntry game)
|
||||
{
|
||||
SelectedGameTitle.Text = game.Name;
|
||||
SelectedGamePath.Text = game.Path;
|
||||
UpdateSelectedGameTexts();
|
||||
SelectedCoverPanel.DataContext = game;
|
||||
SelectedBadgesRow.DataContext = game;
|
||||
SelectedBadgesRow.IsVisible = true;
|
||||
_ = UpdateBackdropAsync(game);
|
||||
PlaySelectedGamePreview(game);
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedGameTitle.Text = "No game selected";
|
||||
SelectedGamePath.Text = "Pick a game from the library, or open an eboot.bin directly.";
|
||||
UpdateSelectedGameTexts();
|
||||
SelectedCoverPanel.DataContext = null;
|
||||
SelectedBadgesRow.DataContext = null;
|
||||
SelectedBadgesRow.IsVisible = false;
|
||||
_ = UpdateBackdropAsync(null);
|
||||
_sndPreview.Stop();
|
||||
}
|
||||
@@ -902,6 +1145,25 @@ public partial class MainWindow : Window
|
||||
UpdateRunButtons();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Text-only refresh of the launch bar's title/path, split out of
|
||||
/// <see cref="UpdateSelectedGame"/> so a language change can re-apply it
|
||||
/// without restarting the backdrop fade or preview music.
|
||||
/// </summary>
|
||||
private void UpdateSelectedGameTexts()
|
||||
{
|
||||
if (GameList.SelectedItem is GameEntry game)
|
||||
{
|
||||
SelectedGameTitle.Text = game.Name;
|
||||
SelectedGamePath.Text = game.Path;
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedGameTitle.Text = Localization.Instance.Get("Launch.NoGameSelected");
|
||||
SelectedGamePath.Text = Localization.Instance.Get("Launch.NoGameHint");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loops the selected game's sce_sys/snd0.at9 preview music, console
|
||||
/// home screen style. Silent while a game is running or when disabled
|
||||
@@ -926,9 +1188,8 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTitleMusicToggled()
|
||||
private void OnTitleMusicSettingChanged()
|
||||
{
|
||||
_settings.PlayTitleMusic = TitleMusicToggle.IsChecked == true;
|
||||
if (!_settings.PlayTitleMusic)
|
||||
{
|
||||
_sndPreview.Stop();
|
||||
@@ -1001,11 +1262,12 @@ public partial class MainWindow : Window
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Open an executable to launch",
|
||||
Title = Localization.Instance.Get("Dialog.OpenExecutable"),
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter = new[]
|
||||
{
|
||||
new FilePickerFileType("PS executables") { Patterns = new[] { "eboot.bin", "*.bin", "*.self", "*.elf" } },
|
||||
new FilePickerFileType(Localization.Instance.Get("Dialog.PsExecutables"))
|
||||
{ Patterns = new[] { "eboot.bin", "*.bin", "*.self", "*.elf" } },
|
||||
FilePickerFileTypes.All,
|
||||
},
|
||||
});
|
||||
@@ -1037,14 +1299,12 @@ public partial class MainWindow : Window
|
||||
LocateEmulator();
|
||||
if (_emulatorExePath is null)
|
||||
{
|
||||
AppendConsoleLine("SharpEmu executable not found. Build the SharpEmu.CLI project first (dotnet build).", ErrorLineBrush);
|
||||
AppendConsoleLine(Localization.Instance.Get("Launch.ExeNotFound"), ErrorLineBrush);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_sndPreview.Stop();
|
||||
ReadControlsIntoSettings();
|
||||
_settings.Save();
|
||||
|
||||
var arguments = new List<string>
|
||||
{
|
||||
@@ -1062,7 +1322,6 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
_consoleLines.Clear();
|
||||
ConsoleToggle.IsChecked = true;
|
||||
|
||||
// Let the CLI mirror stdout/stderr itself; it sees loader/native
|
||||
// diagnostics before the GUI pipe reader can filter or batch them.
|
||||
@@ -1105,13 +1364,15 @@ public partial class MainWindow : Window
|
||||
{
|
||||
arguments.Add("--log-file");
|
||||
arguments.Add(filePath);
|
||||
AppendConsoleLine($"Log file: {filePath}", DimLineBrush);
|
||||
AppendConsoleLine(Localization.Instance.Format("Launch.LogFile", filePath), DimLineBrush);
|
||||
}
|
||||
}
|
||||
|
||||
arguments.Add(ebootPath);
|
||||
|
||||
AppendConsoleLine($"$ SharpEmu {string.Join(' ', arguments)}", DimLineBrush);
|
||||
AppendConsoleLine(
|
||||
Localization.Instance.Format("Launch.Command", string.Join(' ', arguments)),
|
||||
DimLineBrush);
|
||||
|
||||
var emulator = new EmulatorProcess();
|
||||
emulator.OutputReceived += (line, isError) => _pendingLines.Enqueue((line, isError));
|
||||
@@ -1124,7 +1385,7 @@ public partial class MainWindow : Window
|
||||
catch (Exception ex)
|
||||
{
|
||||
emulator.Dispose();
|
||||
AppendConsoleLine($"Failed to start the emulator: {ex.Message}", ErrorLineBrush);
|
||||
AppendConsoleLine(Localization.Instance.Format("Launch.StartFailed", ex.Message), ErrorLineBrush);
|
||||
DropFileLog();
|
||||
return;
|
||||
}
|
||||
@@ -1137,8 +1398,8 @@ public partial class MainWindow : Window
|
||||
.TitleId;
|
||||
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
StatusDot.Fill = SuccessLineBrush;
|
||||
StatusText.Text = $"Running — {displayName}";
|
||||
StatusBarRight.Text = $"Running {displayName}";
|
||||
StatusText.Text = Localization.Instance.Format("Launch.Running", displayName);
|
||||
StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName);
|
||||
UpdateRunButtons();
|
||||
UpdateDiscordPresence();
|
||||
}
|
||||
@@ -1160,8 +1421,8 @@ public partial class MainWindow : Window
|
||||
_emulator?.Stop();
|
||||
_runningGameName = null;
|
||||
_runningGameTitleId = null;
|
||||
StatusText.Text = "Stopping…";
|
||||
StatusBarRight.Text = "Stopping…";
|
||||
StatusText.Text = Localization.Instance.Get("Launch.Stopping");
|
||||
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
|
||||
UpdateDiscordPresence();
|
||||
}
|
||||
|
||||
@@ -1203,22 +1464,23 @@ public partial class MainWindow : Window
|
||||
_emulator?.Dispose();
|
||||
_emulator = null;
|
||||
|
||||
var meaning = exitCode switch
|
||||
var meaningKey = exitCode switch
|
||||
{
|
||||
0 => "OK",
|
||||
1 => "invalid arguments",
|
||||
2 => "eboot not found",
|
||||
3 => "runtime exception",
|
||||
4 => "emulation error",
|
||||
_ => "unknown",
|
||||
0 => "Exit.Ok",
|
||||
1 => "Exit.InvalidArguments",
|
||||
2 => "Exit.EbootNotFound",
|
||||
3 => "Exit.RuntimeException",
|
||||
4 => "Exit.EmulationError",
|
||||
_ => "Exit.Unknown",
|
||||
};
|
||||
var meaning = Localization.Instance.Get(meaningKey);
|
||||
var brush = exitCode == 0 ? SuccessLineBrush : ErrorLineBrush;
|
||||
AppendConsoleLine($"Process exited with code {exitCode} ({meaning}).", brush);
|
||||
AppendConsoleLine(Localization.Instance.Format("Launch.ProcessExited", exitCode, meaning), brush);
|
||||
CloseFileLogSoon();
|
||||
|
||||
StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush;
|
||||
StatusText.Text = $"Exited with code {exitCode} ({meaning})";
|
||||
StatusBarRight.Text = "Idle";
|
||||
StatusText.Text = Localization.Instance.Format("Launch.Exited", exitCode, meaning);
|
||||
StatusBarRight.Text = Localization.Instance.Get("Status.Idle");
|
||||
_runningGameName = null;
|
||||
_runningGameTitleId = null;
|
||||
UpdateRunButtons();
|
||||
@@ -1232,27 +1494,6 @@ public partial class MainWindow : Window
|
||||
OpenFileButton.IsEnabled = !_isRunning;
|
||||
}
|
||||
|
||||
private async Task SelectFilePathAsync()
|
||||
{
|
||||
SaveFilePickerResult result = await StorageProvider.SaveFilePickerWithResultAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Select where to save the Log file",
|
||||
SuggestedFileName = "SharpEmuLog",
|
||||
DefaultExtension = "log",
|
||||
FileTypeChoices =
|
||||
[
|
||||
new FilePickerFileType("Plain Text Files") { Patterns = ["*.txt"] },
|
||||
new FilePickerFileType("Log Files") { Patterns = ["*.log"] }
|
||||
]
|
||||
});
|
||||
|
||||
if (result.File is not null)
|
||||
{
|
||||
_settings.LogFilePath = result.File.Path.LocalPath;
|
||||
ToolTip.SetTip(SelectLogFilePathButton, _settings.LogFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Console ----
|
||||
|
||||
private void FlushPendingConsoleLines()
|
||||
|
||||
@@ -47,7 +47,7 @@ internal sealed class SndPreviewPlayer
|
||||
{
|
||||
// Debounce so skimming through the library does not decode (or
|
||||
// start) a preview per tile.
|
||||
await Task.Delay(300).ConfigureAwait(false);
|
||||
await Task.Delay(120).ConfigureAwait(false);
|
||||
|
||||
byte[]? wav;
|
||||
lock (_sync)
|
||||
|
||||
Reference in New Issue
Block a user