PKHeX/PKHeX.Avalonia/ViewModels/MainWindowViewModel.cs
montanon 807007ad8a Fix bitmap memory leaks, load concurrency, money clamp
Bitmap disposal (6 files):
- SlotModel.SetImage: dispose old Avalonia Bitmap + input SKBitmap
- PKMEditorVM: dispose old SpriteImage, LegalityImage, BallSprite
  and intermediate SKBitmaps on every update
- SAVEditorVM: dispose old BoxWallpaper + SKBitmap on box navigation
- WondercardVM: dispose old GiftSlotModel.Sprite on refresh
- QRDialogVM: dispose intermediate SKBitmaps during QR generation
- Added ToAvaloniaBitmapAndDispose helper for owned SKBitmap conversion

Concurrency:
- MainWindowVM: add _isLoading guard to prevent concurrent LoadFileAsync
  calls from drag-drop or rapid Open clicks

Money clamp:
- Trainer8/8a/8b/9/9a: clamp Money to sav.MaxMoney on save
  (was allowing values exceeding game maximums)
2026-03-18 15:32:37 -03:00

1078 lines
32 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input.Platform;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PKHeX.Avalonia.Services;
using PKHeX.Avalonia.Util;
using PKHeX.Avalonia.ViewModels.Subforms;
using PKHeX.Avalonia.Views.Subforms;
using PKHeX.Avalonia.Settings;
using PKHeX.Core;
using PKHeX.Drawing.PokeSprite.Avalonia;
namespace PKHeX.Avalonia.ViewModels;
/// <summary>
/// ViewModel for the main application window. Handles file operations, menu commands, and lifecycle.
/// </summary>
public partial class MainWindowViewModel : ObservableObject
{
private readonly IDialogService _dialogService;
/// <summary>
/// Guards against concurrent invocations of <see cref="LoadFileAsync"/>.
/// </summary>
private bool _isLoading;
/// <summary>
/// The file path from which the current save file was loaded.
/// Used to create an automatic backup before overwriting.
/// </summary>
private string? _loadedFilePath;
[ObservableProperty]
private string _title = "PKHeX - Cross-Platform";
[ObservableProperty]
private SaveFile? _saveFile;
[ObservableProperty]
private PKMEditorViewModel? _pkmEditor;
[ObservableProperty]
private SAVEditorViewModel? _savEditor;
[ObservableProperty]
private bool _hasSaveFile;
[ObservableProperty]
private string _statusMessage = "Ready. Open a save file to begin.";
/// <summary>
/// Indicates whether the currently loaded save file has been modified since it was last saved or loaded.
/// </summary>
[ObservableProperty]
private bool _hasUnsavedChanges;
/// <summary>
/// The currently active UI language code, matching <see cref="GameLanguage"/> codes.
/// </summary>
[ObservableProperty]
private string _currentLanguage = GameInfo.CurrentLanguage;
/// <summary>
/// All supported UI language codes for the language picker.
/// </summary>
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
[
new("English", "en"),
new("日本語", "ja"),
new("Français", "fr"),
new("Italiano", "it"),
new("Deutsch", "de"),
new("Español", "es"),
new("Español (LA)","es-419"),
new("한국어", "ko"),
new("中文简体", "zh-Hans"),
new("中文繁體", "zh-Hant"),
];
partial void OnCurrentLanguageChanged(string value)
{
// Update GameInfo strings and filtered sources
GameInfo.CurrentLanguage = value;
if (SaveFile is not null)
LocalizeUtil.InitializeStrings(value, SaveFile);
else
LocalizeUtil.InitializeStrings(value);
// Persist language preference
App.Settings.Startup.Language = value;
// Refresh all combo lists in the PKM editor
RefreshGameDataAfterLanguageChange();
StatusMessage = $"Language changed to {value}.";
}
private void RefreshGameDataAfterLanguageChange()
{
if (PkmEditor is null)
return;
// If a save is loaded, re-create the filtered sources and re-initialize
if (SaveFile is not null)
{
GameInfo.FilteredSources = new FilteredGameDataSource(SaveFile, GameInfo.Sources);
// Notify the editor that list sources changed
PkmEditor.NotifyListsChanged();
// Re-populate the current PKM so display names refresh
if (PkmEditor.Entity is { } pk)
PkmEditor.PopulateFields(pk);
}
}
/// <summary>
/// Loaded plugin instances.
/// </summary>
public List<IPlugin> Plugins { get; } = [];
/// <summary>
/// Plugin load result for managing plugin lifecycle.
/// </summary>
private PluginLoadResult? _pluginLoadResult;
public MainWindowViewModel() : this(new AvaloniaDialogService())
{
}
public MainWindowViewModel(IDialogService dialogService)
{
_dialogService = dialogService;
PkmEditor = new PKMEditorViewModel();
SavEditor = new SAVEditorViewModel
{
SlotSelected = pk => PkmEditor.PopulateFields(pk),
GetEditorPKM = () => PkmEditor.PreparePKM(),
SetStatusMessage = msg => StatusMessage = msg,
OnModified = () => HasUnsavedChanges = true,
OpenSettingsEditorCommand = OpenSettingsEditorCommand,
OpenDatabaseCommand = OpenDatabaseCommand,
OpenBatchEditorCommand = OpenBatchEditorCommand,
OpenEncountersCommand = OpenEncountersCommand,
OpenReportGridCommand = OpenReportGridCommand,
OpenBoxViewerCommand = OpenBoxViewerCommand,
OpenMysteryGiftDBCommand = OpenMysteryGiftDBCommand,
OpenRibbonEditorCommand = OpenRibbonEditorCommand,
OpenMemoryAmieCommand = OpenMemoryAmieCommand,
OpenTechRecordEditorCommand = OpenTechRecordEditorCommand,
};
LoadPlugins();
}
/// <summary>
/// Loads plugins from the plugins directory relative to the working directory.
/// </summary>
private void LoadPlugins()
{
var pluginPath = Path.Combine(App.WorkingDirectory, "plugins");
try
{
_pluginLoadResult = PluginLoader.LoadPlugins<IPlugin>(pluginPath, Plugins, false);
foreach (var plugin in Plugins.OrderBy(p => p.Priority))
{
try
{
plugin.Initialize(this);
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to initialize plugin {plugin.Name}: {ex.Message}");
}
}
if (Plugins.Count > 0)
StatusMessage = $"Loaded {Plugins.Count} plugin(s).";
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to load plugins: {ex.Message}");
}
}
/// <summary>
/// Notifies all loaded plugins that a save file was loaded.
/// </summary>
private void NotifyPluginsSaveLoaded()
{
foreach (var plugin in Plugins)
{
try
{
plugin.NotifySaveLoaded();
}
catch (Exception ex)
{
Debug.WriteLine($"Plugin {plugin.Name} failed on NotifySaveLoaded: {ex.Message}");
}
}
}
[RelayCommand]
private async Task OpenFileAsync()
{
var path = await _dialogService.OpenFileAsync("Open Save File");
if (path is null)
return;
await LoadFileAsync(path);
}
[RelayCommand]
private async Task SaveFileAsync()
{
if (SaveFile is null)
return;
var path = await _dialogService.SaveFileAsync("Save File", SaveFile.Metadata.FileName ?? "save");
if (path is null)
return;
try
{
// Create an automatic backup of the original file before overwriting
CreateAutoBackup(path);
ExportSAV(SaveFile, path);
HasUnsavedChanges = false;
StatusMessage = $"Saved to {Path.GetFileName(path)}";
}
catch (Exception ex)
{
await _dialogService.ShowErrorAsync("Save Error", ex.Message);
}
}
/// <summary>
/// Creates an automatic backup of the file at the specified path before it is overwritten.
/// If the file does not exist yet, no backup is created.
/// </summary>
private static void CreateAutoBackup(string originalPath)
{
try
{
if (!File.Exists(originalPath))
return;
var backupDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PKHeX", "backups");
Directory.CreateDirectory(backupDir);
var filename = $"{Path.GetFileNameWithoutExtension(originalPath)}_{DateTime.Now:yyyyMMdd_HHmmss}.bak";
File.Copy(originalPath, Path.Combine(backupDir, filename), overwrite: true);
}
catch
{
// Silently fail backup — saving should proceed regardless
}
}
[RelayCommand]
private async Task ExportPKMAsync()
{
if (PkmEditor?.Entity is not { } pk)
return;
var ext = pk.Extension;
var path = await _dialogService.SaveFileAsync("Export PKM", $"exported.{ext}");
if (path is null)
return;
try
{
File.WriteAllBytes(path, pk.DecryptedBoxData);
StatusMessage = $"Exported PKM to {Path.GetFileName(path)}";
}
catch (Exception ex)
{
await _dialogService.ShowErrorAsync("Export Error", ex.Message);
}
}
[RelayCommand]
private async Task ExportSAVAsync()
{
if (SaveFile is null)
return;
var path = await _dialogService.SaveFileAsync("Export SAV", SaveFile.Metadata.FileName ?? "save");
if (path is null)
return;
try
{
ExportSAV(SaveFile, path);
StatusMessage = $"Exported SAV to {Path.GetFileName(path)}";
}
catch (Exception ex)
{
await _dialogService.ShowErrorAsync("Export Error", ex.Message);
}
}
[RelayCommand]
private async Task DumpAllBoxesAsync()
{
if (SaveFile is null)
return;
try
{
var folder = await _dialogService.OpenFolderAsync("Select output folder for all boxes");
if (string.IsNullOrEmpty(folder))
return;
int dumped = 0;
for (int box = 0; box < SaveFile.BoxCount; box++)
{
var boxDir = Path.Combine(folder, $"Box {box + 1:00}");
Directory.CreateDirectory(boxDir);
for (int slot = 0; slot < SaveFile.BoxSlotCount; slot++)
{
var pk = SaveFile.GetBoxSlotAtIndex(box, slot);
if (pk is null || pk.Species == 0)
continue;
var fileName = $"{pk.Species:000}_{pk.Nickname}.{pk.Extension}";
foreach (var c in Path.GetInvalidFileNameChars())
fileName = fileName.Replace(c, '_');
await File.WriteAllBytesAsync(Path.Combine(boxDir, fileName), pk.DecryptedBoxData);
dumped++;
}
}
StatusMessage = $"Dumped {dumped} Pokemon from all boxes.";
}
catch (Exception ex)
{
StatusMessage = $"Dump All Boxes error: {ex.Message}";
}
}
public async Task LoadFileAsync(string path)
{
if (_isLoading)
return;
_isLoading = true;
try
{
var data = await File.ReadAllBytesAsync(path);
var sav = SaveUtil.GetSaveFile(data);
if (sav is not null)
{
LoadSaveFile(sav, path);
return;
}
// Try loading as PKM
var pk = EntityFormat.GetFromBytes(data);
if (pk is not null)
{
PkmEditor?.PopulateFields(pk);
StatusMessage = $"Loaded {pk.Species} from {Path.GetFileName(path)}";
return;
}
await _dialogService.ShowAlertAsync("Unsupported File", "The file format is not recognized.");
}
catch (Exception ex)
{
await _dialogService.ShowErrorAsync("Load Error", ex.Message);
}
finally
{
_isLoading = false;
}
}
private void LoadSaveFile(SaveFile sav, string path)
{
SaveFile = sav;
HasSaveFile = true;
HasUnsavedChanges = false;
_loadedFilePath = path;
SpriteUtil.Initialize(sav);
SavEditor?.LoadSaveFile(sav);
PkmEditor?.Initialize(sav);
App.Settings.Startup.LoadSaveFile(path);
Title = $"PKHeX - {sav.GetType().Name} ({Path.GetFileName(path)})";
StatusMessage = $"Loaded {sav.GetType().Name} - {Path.GetFileName(path)}";
NotifyPluginsSaveLoaded();
}
private static void ExportSAV(SaveFile sav, string path)
{
var data = sav.Write();
File.WriteAllBytes(path, data.ToArray());
}
[RelayCommand]
private async Task OpenDatabaseAsync()
{
if (SaveFile is null)
return;
try
{
var dbPath = GetDatabasePath();
var vm = new DatabaseViewModel(SaveFile, dbPath)
{
SlotClicked = pk => PkmEditor?.PopulateFields(pk),
};
var view = new DatabaseView { DataContext = vm };
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (mainWindow is not null)
{
// Start loading in the background, then show
_ = vm.LoadDatabaseAsync();
await view.ShowDialog(mainWindow);
vm.CancelLoad();
}
}
catch (Exception ex)
{
StatusMessage = $"Database error: {ex.Message}";
}
}
[RelayCommand]
private async Task OpenBatchEditorAsync()
{
if (SaveFile is null)
return;
try
{
var vm = new BatchEditorViewModel(SaveFile)
{
CurrentBox = SavEditor?.CurrentBox ?? 0,
};
var view = new BatchEditorView { DataContext = vm };
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (mainWindow is not null)
await view.ShowDialog(mainWindow);
if (vm.Modified)
{
SavEditor?.ReloadSlots();
HasUnsavedChanges = true;
}
}
catch (Exception ex)
{
StatusMessage = $"Batch Editor error: {ex.Message}";
}
}
[RelayCommand]
private async Task OpenEncountersAsync()
{
if (SaveFile is null)
return;
try
{
var vm = new EncountersViewModel(SaveFile)
{
SlotClicked = pk => PkmEditor?.PopulateFields(pk),
};
var view = new EncountersView { DataContext = vm };
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (mainWindow is not null)
await view.ShowDialog(mainWindow);
vm.CancelSearch();
}
catch (Exception ex)
{
StatusMessage = $"Encounters error: {ex.Message}";
}
}
private static string GetDatabasePath()
{
var pokemon = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "PKHeX");
return Path.Combine(pokemon, "pkmdb");
}
[RelayCommand]
private async Task OpenRibbonEditorAsync()
{
if (PkmEditor?.Entity is not { } pk)
return;
try
{
var vm = new RibbonEditorViewModel(pk);
var view = new RibbonEditorView { DataContext = vm };
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (mainWindow is not null)
await view.ShowDialog(mainWindow);
}
catch (Exception ex)
{
StatusMessage = $"Ribbon Editor error: {ex.Message}";
}
}
[RelayCommand]
private async Task OpenReportGridAsync()
{
if (SaveFile is null)
return;
try
{
var vm = new ReportGridViewModel(SaveFile)
{
GetExportPath = () => _dialogService.SaveFileAsync("Export CSV", "report.csv"),
};
// Auto-load all boxes
vm.LoadDataCommand.Execute(null);
var view = new ReportGridView { DataContext = vm };
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (mainWindow is not null)
await view.ShowDialog(mainWindow);
}
catch (Exception ex)
{
StatusMessage = $"Report Grid error: {ex.Message}";
}
}
[RelayCommand]
private void OpenBoxViewer()
{
if (SaveFile is null)
return;
try
{
var vm = new BoxViewerViewModel(SaveFile, SavEditor?.CurrentBox ?? 0)
{
SlotSelected = pk => PkmEditor?.PopulateFields(pk),
};
var view = new BoxViewerView { DataContext = vm };
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (mainWindow is not null)
view.Show(mainWindow); // non-modal
}
catch (Exception ex)
{
StatusMessage = $"Box Viewer error: {ex.Message}";
}
}
[RelayCommand]
private async Task OpenSettingsEditorAsync()
{
try
{
var settings = App.Settings;
var vm = new SettingsEditorViewModel(settings);
var view = new SettingsEditorView { DataContext = vm };
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (mainWindow is not null)
await view.ShowDialog(mainWindow);
}
catch (Exception ex)
{
StatusMessage = $"Settings error: {ex.Message}";
}
}
[RelayCommand]
private async Task OpenMysteryGiftDBAsync()
{
if (SaveFile is null)
return;
try
{
var vm = new MysteryGiftDBViewModel(SaveFile)
{
SlotClicked = pk => PkmEditor?.PopulateFields(pk),
};
var view = new MysteryGiftDBView { DataContext = vm };
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (mainWindow is not null)
{
_ = vm.LoadDatabaseAsync();
await view.ShowDialog(mainWindow);
vm.CancelLoad();
}
}
catch (Exception ex)
{
StatusMessage = $"Mystery Gift DB error: {ex.Message}";
}
}
[RelayCommand]
private async Task OpenMemoryAmieAsync()
{
if (PkmEditor?.Entity is not { } pk)
return;
try
{
if (pk is not ITrainerMemories && pk is not IAffection && pk is not IFullnessEnjoyment)
{
await _dialogService.ShowAlertAsync("Not Supported", "This Pokemon does not support memories or affection.");
return;
}
var vm = new MemoryAmieViewModel(pk);
var view = new MemoryAmieView { DataContext = vm };
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (mainWindow is not null)
await view.ShowDialog(mainWindow);
}
catch (Exception ex)
{
StatusMessage = $"Memory/Amie error: {ex.Message}";
}
}
[RelayCommand]
private async Task OpenTechRecordEditorAsync()
{
if (PkmEditor?.Entity is not { } pk)
return;
try
{
if (pk is not ITechRecord record)
{
await _dialogService.ShowAlertAsync("Not Supported", "This Pokemon does not support Tech Records.");
return;
}
var vm = new TechRecordEditorViewModel(record, pk);
var view = new TechRecordEditorView { DataContext = vm };
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (mainWindow is not null)
await view.ShowDialog(mainWindow);
}
catch (Exception ex)
{
StatusMessage = $"Tech Record error: {ex.Message}";
}
}
#region Language
[RelayCommand]
private void ChangeLanguage(string? lang)
{
if (lang is null || lang == CurrentLanguage)
return;
CurrentLanguage = lang;
}
#endregion
#region Undo/Redo
[RelayCommand]
private void UndoSlot() => SavEditor?.Undo();
[RelayCommand]
private void RedoSlot() => SavEditor?.Redo();
#endregion
#region Showdown Import/Export
private static IClipboard? GetClipboard()
{
var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime;
var mainWindow = lifetime?.MainWindow;
if (mainWindow is null)
return null;
return TopLevel.GetTopLevel(mainWindow)?.Clipboard;
}
[RelayCommand]
private async Task ImportShowdownAsync()
{
try
{
var clipboard = GetClipboard();
if (clipboard is null)
{
await _dialogService.ShowAlertAsync("Clipboard Error", "Could not access clipboard.");
return;
}
var text = await clipboard.GetTextAsync();
if (string.IsNullOrWhiteSpace(text))
{
await _dialogService.ShowAlertAsync("Clipboard Empty", "No text found on the clipboard.");
return;
}
var sets = BattleTemplateTeams.TryGetSets(text);
var set = sets.FirstOrDefault() ?? new ShowdownSet(string.Empty);
if (set.Species == 0)
{
await _dialogService.ShowAlertAsync("Import Failed", "No valid Showdown set found on the clipboard.");
return;
}
var reformatted = set.Text;
var confirm = await _dialogService.ShowConfirmAsync("Import Showdown Set?", reformatted);
if (!confirm)
return;
if (PkmEditor?.Entity is null)
{
await _dialogService.ShowAlertAsync("No Pokemon", "Load a save file first to import a Showdown set.");
return;
}
var pk = PkmEditor.PreparePKM();
if (pk is null)
return;
pk.ApplySetDetails(set);
PkmEditor.PopulateFields(pk);
StatusMessage = "Imported Showdown set from clipboard.";
}
catch (Exception ex)
{
StatusMessage = $"Import Showdown error: {ex.Message}";
}
}
[RelayCommand]
private async Task ExportShowdownAsync()
{
try
{
if (PkmEditor?.Entity is not { } pk || pk.Species == 0)
{
await _dialogService.ShowAlertAsync("No Pokemon", "No Pokemon data to export.");
return;
}
var text = ShowdownParsing.GetShowdownText(pk);
if (string.IsNullOrWhiteSpace(text))
{
await _dialogService.ShowAlertAsync("Export Failed", "Could not generate Showdown text.");
return;
}
var clipboard = GetClipboard();
if (clipboard is null)
{
await _dialogService.ShowAlertAsync("Clipboard Error", "Could not access clipboard.");
return;
}
await clipboard.SetTextAsync(text);
StatusMessage = "Exported Showdown set to clipboard.";
await _dialogService.ShowAlertAsync("Showdown Export", text);
}
catch (Exception ex)
{
StatusMessage = $"Export Showdown error: {ex.Message}";
}
}
[RelayCommand]
private async Task ExportPartyShowdownAsync()
{
if (SaveFile is null)
return;
try
{
var party = SaveFile.PartyData;
if (party is null) return;
var text = string.Join("\n\n", party.Where(p => p.Species > 0).Select(ShowdownParsing.GetShowdownText));
if (string.IsNullOrWhiteSpace(text))
{
StatusMessage = "No party Pokemon to export.";
return;
}
var clipboard = GetClipboard();
if (clipboard is not null)
{
try
{
await clipboard.SetTextAsync(text);
StatusMessage = "Party exported to clipboard.";
}
catch { StatusMessage = "Clipboard unavailable."; }
}
else
{
StatusMessage = "Clipboard unavailable.";
}
}
catch (Exception ex)
{
StatusMessage = $"Export Party error: {ex.Message}";
}
}
[RelayCommand]
private async Task ExportBoxShowdownAsync()
{
if (SaveFile is null || SavEditor is null)
return;
try
{
var box = SaveFile.GetBoxData(SavEditor.CurrentBox);
var text = string.Join("\n\n", box.Where(p => p is not null && p.Species > 0).Select(ShowdownParsing.GetShowdownText));
if (string.IsNullOrWhiteSpace(text))
{
StatusMessage = "No Pokemon in box to export.";
return;
}
var clipboard = GetClipboard();
if (clipboard is not null)
{
try
{
await clipboard.SetTextAsync(text);
StatusMessage = $"Box {SavEditor.CurrentBox + 1} exported to clipboard.";
}
catch { StatusMessage = "Clipboard unavailable."; }
}
else
{
StatusMessage = "Clipboard unavailable.";
}
}
catch (Exception ex)
{
StatusMessage = $"Export Box error: {ex.Message}";
}
}
#endregion
#region Folder / About
[RelayCommand]
private void OpenFolder()
{
var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "PKHeX");
if (!Directory.Exists(path))
Directory.CreateDirectory(path);
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
[RelayCommand]
private async Task ShowAboutAsync()
{
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (mainWindow is null)
return;
var coreVersion = typeof(PKM).Assembly.GetName().Version?.ToString() ?? "unknown";
var dialog = new Window
{
Title = "About PKHeX",
Width = 320,
Height = 220,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Content = new StackPanel
{
Margin = new Thickness(24),
Spacing = 8,
Children =
{
new TextBlock { Text = "PKHeX", FontSize = 22, FontWeight = global::Avalonia.Media.FontWeight.Bold },
new TextBlock { Text = "Pokemon Save Editor", FontSize = 14 },
new TextBlock { Text = "Cross-Platform Avalonia Port", FontSize = 12, Opacity = 0.7 },
new TextBlock { Text = $"Core Version: {coreVersion}", FontSize = 11 },
new TextBlock { Text = "https://github.com/kwsch/PKHeX", FontSize = 11, Opacity = 0.6 },
}
}
};
await dialog.ShowDialog(mainWindow);
}
#endregion
#region QR Code
[RelayCommand]
private async Task OpenQRDialogAsync()
{
try
{
if (PkmEditor?.Entity is not { } pk || pk.Species == 0)
{
await _dialogService.ShowAlertAsync("No Pokemon", "No Pokemon data to generate QR code.");
return;
}
var vm = new QRDialogViewModel(pk);
var view = new QRDialogView { DataContext = vm };
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (mainWindow is not null)
await view.ShowDialog(mainWindow);
}
catch (Exception ex)
{
StatusMessage = $"QR Code error: {ex.Message}";
}
}
#endregion
public void HandleFileDrop(string[] files)
{
if (files.Length == 0 || _isLoading)
return;
_ = LoadFileAsync(files[0]);
}
#region Exit
[RelayCommand]
private void Exit()
{
var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime;
lifetime?.MainWindow?.Close();
}
#endregion
#region Load Boxes / Dump Box
[RelayCommand]
private async Task LoadBoxesAsync()
{
if (SaveFile is null || SavEditor is null)
return;
try
{
var paths = await _dialogService.OpenFilesAsync("Select PKM files to load into boxes");
if (paths is null || paths.Length == 0)
return;
int loaded = 0;
int currentBox = SavEditor.CurrentBox;
int slot = 0;
// Find first empty slot in current box
for (int i = 0; i < SaveFile.BoxSlotCount; i++)
{
var existing = SaveFile.GetBoxSlotAtIndex(currentBox, i);
if (existing is null || existing.Species == 0)
{
slot = i;
break;
}
slot = i + 1;
}
foreach (var path in paths)
{
if (slot >= SaveFile.BoxSlotCount)
break;
var data = await File.ReadAllBytesAsync(path);
var pk = EntityFormat.GetFromBytes(data);
if (pk is null)
continue;
var converted = EntityConverter.ConvertToType(pk, SaveFile.PKMType, out _);
if (converted is null)
continue;
SaveFile.SetBoxSlotAtIndex(converted, currentBox, slot);
loaded++;
slot++;
}
SavEditor.ReloadSlots();
if (loaded > 0)
HasUnsavedChanges = true;
StatusMessage = $"Loaded {loaded} Pokemon into Box {currentBox + 1}.";
}
catch (Exception ex)
{
StatusMessage = $"Load Boxes error: {ex.Message}";
}
}
[RelayCommand]
private async Task DumpBoxAsync()
{
if (SaveFile is null || SavEditor is null)
return;
try
{
var folder = await _dialogService.OpenFolderAsync("Select output folder for box dump");
if (string.IsNullOrEmpty(folder))
return;
var box = SavEditor.CurrentBox;
int dumped = 0;
for (int i = 0; i < SaveFile.BoxSlotCount; i++)
{
var pk = SaveFile.GetBoxSlotAtIndex(box, i);
if (pk is null || pk.Species == 0)
continue;
var fileName = $"{pk.Species:000}_{pk.Nickname}.{pk.Extension}";
// Sanitize filename
foreach (var c in Path.GetInvalidFileNameChars())
fileName = fileName.Replace(c, '_');
var filePath = Path.Combine(folder, fileName);
await File.WriteAllBytesAsync(filePath, pk.DecryptedBoxData);
dumped++;
}
StatusMessage = $"Dumped {dumped} Pokemon from Box {box + 1}.";
}
catch (Exception ex)
{
StatusMessage = $"Dump Box error: {ex.Message}";
}
}
#endregion
}
/// <summary>
/// Represents a selectable UI language with a display name and language code.
/// </summary>
public sealed record LanguageOption(string DisplayName, string Code)
{
public override string ToString() => DisplayName;
}