PKHeX/PKHeX.WinForms/Util/WinFormsUtil.cs
Kurt d690f1c5d3 Check unsaved entity on sav export
Add setting to skip the unsaved entity check
Add setting to skip the overwrite? prompt and always call Save As

Change Overwrite prompt to have distinct buttons rather than rows that can be mis-clicked.

fix some comments/strings from Pokemon=>Pokémon

add some underline shortcut key for main menu for English translation
2026-02-22 11:11:16 -06:00

584 lines
22 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Drawing;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Windows.Forms;
using PKHeX.Core;
using static PKHeX.Core.MessageStrings;
namespace PKHeX.WinForms;
public static class WinFormsUtil
{
internal static void TranslateInterface(Control form, string lang) => form.TranslateInterface(lang);
extension(Control child)
{
/// <summary>
/// Centers the <see cref="child"/> horizontally and vertically so that its center is the same as the <see cref="parent"/>'s center.
/// </summary>
internal void CenterToForm(Control? parent)
{
if (parent is null)
return;
int x = parent.Location.X + ((parent.Width - child.Width) / 2);
int y = parent.Location.Y + ((parent.Height - child.Height) / 2);
child.Location = new Point(x, y);
}
/// <summary>
/// Horizontally centers the <see cref="child"/> to the <see cref="parent"/>'s horizontal center.
/// </summary>
internal void HorizontallyCenter(Control parent)
{
int midpoint = (parent.Width - child.Width) / 2;
if (child.Location.X != midpoint)
child.SetBounds(midpoint, 0, 0, 0, BoundsSpecified.X);
}
}
public static T? FirstFormOfType<T>() where T : Form => Application.OpenForms.OfType<T>().FirstOrDefault();
public static bool TryFindFirstControlOfType<T>(Control aParent, [NotNullWhen(true)] out T? result) where T : class
{
while (true)
{
if (aParent is T t)
{
result = t;
return true;
}
if (aParent.Parent is null)
{
result = null;
return false;
}
aParent = aParent.Parent;
}
}
/// <summary>
/// Searches upwards through the control hierarchy to find the first parent control of type <typeparamref name="T"/>.
/// </summary>
/// <param name="sender">Child control to start searching from.</param>
/// <param name="result">The first parent control of type <typeparamref name="T"/>, or null if none found.</param>
public static bool TryGetUnderlying<T>(object sender, [NotNullWhen(true)] out T? result) where T : class
{
while (true)
{
switch (sender)
{
case T p:
result = p;
return true;
case ToolStripItem { Owner: { } o }:
sender = o;
continue;
case ContextMenuStrip { SourceControl: { } s }:
sender = s;
continue;
default:
result = null;
return false;
}
}
}
/// <summary>
/// Checks if a window of type <typeparamref name="T"/> already exists and brings it to the front if it does.
/// </summary>
/// <param name="parent">The parent form to center the window on.</param>
/// <returns><c>true</c> if the window exists and was brought to the front; otherwise, <c>false</c>.</returns>
public static bool OpenWindowExists<T>(this Form parent) where T : Form
{
var form = FirstFormOfType<T>();
if (form is null)
return false;
form.CenterToForm(parent);
form.BringToFront();
return true;
}
#region Message Displays
/// <summary>
/// Displays a dialog showing the details of an error.
/// </summary>
/// <param name="friendlyMessage">User-friendly message about the error.</param>
/// <param name="exception">Instance of the error's <see cref="Exception"/>.</param>
/// <returns>The <see cref="DialogResult"/> associated with the dialog.</returns>
internal static DialogResult Error(string friendlyMessage, Exception exception)
{
System.Media.SystemSounds.Exclamation.Play();
return ErrorWindow.ShowErrorDialog(friendlyMessage, exception, true);
}
/// <summary>
/// Displays a dialog showing the details of an error.
/// </summary>
/// <param name="lines">User-friendly message about the error.</param>
/// <returns>The <see cref="DialogResult"/> associated with the dialog.</returns>
internal static DialogResult Error(params ReadOnlySpan<string?> lines)
{
System.Media.SystemSounds.Hand.Play();
string msg = string.Join(Environment.NewLine + Environment.NewLine, lines);
return MessageBox.Show(msg, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
internal static DialogResult Alert(params ReadOnlySpan<string?> lines) => Alert(true, lines);
internal static DialogResult Alert(bool sound, params ReadOnlySpan<string?> lines)
{
if (sound)
System.Media.SystemSounds.Asterisk.Play();
string msg = string.Join(Environment.NewLine + Environment.NewLine, lines);
return MessageBox.Show(msg, "Alert", MessageBoxButtons.OK, sound ? MessageBoxIcon.Information : MessageBoxIcon.None);
}
internal static DialogResult Prompt(MessageBoxButtons btn, params ReadOnlySpan<string?> lines)
{
System.Media.SystemSounds.Asterisk.Play();
string msg = string.Join(Environment.NewLine + Environment.NewLine, lines);
return MessageBox.Show(msg, "Prompt", btn, MessageBoxIcon.Question);
}
#endregion
internal static bool SetClipboardText(string text)
{
try
{
Clipboard.SetText(text);
return true;
}
catch (ExternalException x)
{
Error(MsgClipboardFailWrite, x);
}
// Clipboard might be locked sometimes
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine(ex);
Error(MsgClipboardFailWrite);
}
return false;
}
/// <summary>
/// Gets the selected value of the input <see cref="cb"/>. If no value is selected, will return 0.
/// </summary>
/// <param name="cb">ComboBox to retrieve value for.</param>
internal static int GetIndex(ListControl cb)
{
return (int)(cb.SelectedValue ?? 0);
}
public static void PanelScroll(object? sender, ScrollEventArgs e)
{
if (sender is not ScrollableControl p || e.NewValue < 0)
return;
switch (e.ScrollOrientation)
{
case ScrollOrientation.HorizontalScroll:
p.HorizontalScroll.Value = Clamp(e.NewValue, p.HorizontalScroll);
break;
case ScrollOrientation.VerticalScroll:
p.VerticalScroll.Value = Clamp(e.NewValue, p.VerticalScroll);
break;
default:
throw new IndexOutOfRangeException(nameof(e.ScrollOrientation));
}
static int Clamp(int value, ScrollProperties prop) => Math.Clamp(value, prop.Minimum, prop.Maximum);
}
/// <summary>
/// Initializes the <see cref="control"/> to be bound to a provided <see cref="ComboItem"/> list.
/// </summary>
/// <param name="control">Control to initialize binding</param>
public static void InitializeBinding(this ListControl control)
{
control.DisplayMember = nameof(ComboItem.Text);
control.ValueMember = nameof(ComboItem.Value);
}
/// <inheritdoc cref="InitializeBinding(ListControl)"/>
public static void InitializeBinding(this DataGridViewComboBoxColumn control)
{
control.DisplayMember = nameof(ComboItem.Text);
control.ValueMember = nameof(ComboItem.Value);
}
extension(NumericUpDown nud)
{
public void SetValueClamped(int value) => nud.Value = Math.Clamp(value, nud.Minimum, nud.Maximum);
public void SetValueClamped(uint value) => nud.Value = Math.Clamp(value, nud.Minimum, nud.Maximum);
}
public static void RemoveDropCB(object? sender, KeyEventArgs e) => (sender as ComboBox)?.DroppedDown = false;
public static void MouseWheelIncrement1(object? sender, MouseEventArgs e) => Adjust(sender, e, 1);
public static void MouseWheelIncrement4(object? sender, MouseEventArgs e) => Adjust(sender, e, 4);
private static void Adjust(object? sender, MouseEventArgs e, uint increment)
{
if (sender is not TextBoxBase tb)
return;
var text = tb.Text;
var value = Util.ToUInt32(text);
if (e.Delta > 0)
value += increment;
else if (value >= increment)
value -= increment;
tb.Text = value.ToString();
}
/// <summary>
/// Iterates the Control's child controls recursively to obtain all controls of the specified type.
/// </summary>
/// <typeparam name="T">Type of control</typeparam>
/// <param name="control"></param>
/// <returns>All children and sub-children contained by <see cref="control"/>.</returns>
public static IEnumerable<Control> GetAllControlsOfType<T>(Control control) where T : Control
{
foreach (var c in control.Controls.Cast<Control>())
{
if (c is T match)
yield return match;
foreach (var sub in GetAllControlsOfType<T>(c))
yield return sub;
}
}
/// <summary>
/// Reads in custom extension types that allow the program to open more extensions.
/// </summary>
/// <param name="exts">Extensions to add</param>
public static void AddSaveFileExtensions(IEnumerable<string> exts)
{
// Only add new (unique) extensions
var dest = CustomSaveExtensions;
foreach (var ext in exts)
{
if (!dest.Contains(ext))
dest.Add(ext);
}
}
private static List<string> CustomSaveExtensions => SaveFileMetadata.CustomSaveExtensions;
public static bool IsFileExtensionSAV(ReadOnlySpan<char> file)
{
var ext = Path.GetExtension(file);
foreach (var other in CustomSaveExtensions)
{
if (ext.EndsWith(other))
return true;
}
return false;
}
private static string ExtraSaveExtensions => ";" + string.Join(";", CustomSaveExtensions.Select(z => $"*.{z}"));
public static bool DetectSaveFileOnFileOpen { private get; set; } = true;
/// <summary>
/// Opens a dialog to open a <see cref="SaveFile"/>, <see cref="PKM"/> file, or any other supported file.
/// </summary>
/// <param name="extensions">Misc extensions of <see cref="PKM"/> files supported by the Save File.</param>
/// <param name="path">Output result path</param>
/// <returns>Result of the dialog menu indicating if a file is to be loaded from the output path.</returns>
public static bool OpenSAVPKMDialog(IEnumerable<string> extensions, [NotNullWhen(true)] out string? path)
{
var sb = new StringBuilder(128);
foreach (var type in extensions)
sb.Append($"*.{type};");
string supported = sb.ToString();
using var ofd = new OpenFileDialog();
ofd.Filter = "All Files|*.*" +
$"|Supported Files (*.*)|main;*.bin;{supported};*.bak" + ExtraSaveExtensions +
"|Save Files (*.sav)|main" + ExtraSaveExtensions +
"|Decrypted PKM File (*.pk)|" + supported +
"|Binary File|*.bin" +
"|Backup File|*.bak";
ofd.FileName = SuggestInitialFileName();
if (ofd.ShowDialog() != DialogResult.OK)
{
path = null;
return false;
}
path = ofd.FileName;
return true;
}
private static string? SuggestInitialFileName()
{
if (DetectSaveFileOnFileOpen)
{
try
{
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var sav = SaveFinder.FindMostRecentSaveFile(cts.Token);
return sav?.Metadata.FilePath;
}
catch (Exception ex)
{
Error(ex.Message);
}
}
return null;
}
/// <summary>
/// Opens a dialog to save a <see cref="PKM"/> file.
/// </summary>
/// <param name="pk"><see cref="PKM"/> file to be saved.</param>
/// <returns>True if the file was saved.</returns>
public static bool SavePKMDialog(PKM pk)
{
string pkx = pk.Extension;
bool allowEncrypted = pk.Format >= 3 && pkx.StartsWith('p');
var genericFilter = $"Decrypted PKM File|*.{pkx}" +
(allowEncrypted ? $"|Encrypted PKM File|*.e{pkx.AsSpan(1)}" : string.Empty) +
"|Binary File|*.bin" +
"|All Files|*.*";
using var sfd = new SaveFileDialog();
sfd.Filter = genericFilter;
sfd.DefaultExt = pkx;
sfd.FileName = PathUtil.CleanFileName(pk.FileName);
if (sfd.ShowDialog() != DialogResult.OK)
return false;
SavePKM(pk, sfd.FileName, pkx);
return true;
}
private static void SavePKM(PKM pk, string path, ReadOnlySpan<char> pkx)
{
SaveBackup(path);
var ext = Path.GetExtension(path);
var data = ext == $".{pkx}" ? pk.DecryptedPartyData : pk.EncryptedPartyData;
File.WriteAllBytes(path, data);
}
private static void SaveBackup(string path)
{
if (!File.Exists(path))
return;
// File already exists, save a .bak
string bakpath = $"{path}.bak";
if (!File.Exists(bakpath))
File.Move(path, bakpath);
}
/// <summary>
/// Opens a dialog to save a <see cref="SaveFile"/> file.
/// </summary>
/// <param name="c">Control to anchor dialog to.</param>
/// <param name="sav"><see cref="SaveFile"/> to be saved.</param>
/// <param name="currentBox">Box the player will be greeted with when accessing the PC in-game.</param>
/// <param name="forceSaveAs">Whether to force the Save As dialog even if the file exists.</param>
/// <returns>True if the file was saved.</returns>
public static bool ExportSAVDialog(Control c, SaveFile sav, int currentBox = 0, bool forceSaveAs = false)
{
// Try to request an overwrite first; if they defer, do the save file dialog.
if (!forceSaveAs && !sav.Metadata.IsBackup && File.Exists(sav.Metadata.FilePath))
{
var exist = sav.Metadata.FilePath;
var task = c.FindForm()!.RequestOverwrite(exist);
if (task == DialogResult.Cancel)
return false;
if (task == DialogResult.Yes)
{
ExportSAV(sav, exist);
return true;
}
}
using var sfd = new SaveFileDialog();
sfd.Filter = sav.Metadata.Filter;
sfd.FileName = sav.Metadata.FileName;
sfd.FilterIndex = 0; // default to first filter rather than All Files (last), if one is available.
sfd.RestoreDirectory = true;
if (Directory.Exists(sav.Metadata.FileFolder))
sfd.InitialDirectory = sav.Metadata.FileFolder;
if (sfd.ShowDialog() != DialogResult.OK)
return false;
// Set box now that we're saving
if (sav.HasBox)
sav.CurrentBox = currentBox;
var path = sfd.FileName;
ArgumentNullException.ThrowIfNull(path);
ExportSAV(sav, path);
return true;
}
private static void ExportSAV(SaveFile sav, string path)
{
var ext = Path.GetExtension(path.AsSpan());
var flags = sav.Metadata.GetSuggestedFlags(ext);
try
{
var data = sav.Write(flags).Span;
ExportSAVInternal(data, path, sav.Metadata.FilePath);
sav.State.Edited = false;
sav.Metadata.SetExtraInfo(path);
Alert(MsgSaveExportSuccessPath, path);
}
catch (Exception x)
{
if (x is UnauthorizedAccessException or FileNotFoundException or IOException)
Error(MsgFileWriteFail + Environment.NewLine + x.Message, MsgFileWriteProtectedAdvice);
else // Don't know what threw, but it wasn't I/O related.
throw;
}
}
private static void ExportSAVInternal(ReadOnlySpan<byte> data, string path, string? exist)
{
// If it originated from a zip, and a zip is being written, update the zip.
if (Path.GetExtension(path) is ".zip")
{
if (Path.Exists(exist) && Path.GetExtension(exist) is ".zip")
{
// If the paths are different, copy the original zip to the new location first.
if (path != exist)
File.Copy(exist, path, true);
ZipReader.Update(path, data);
return;
}
}
// Otherwise, just write the raw data.
File.WriteAllBytes(path, data);
}
/// <summary>
/// Opens a dialog to save a <see cref="MysteryGift"/> file.
/// </summary>
/// <param name="gift"><see cref="MysteryGift"/> to be saved.</param>
/// <returns>True if the file was saved.</returns>
public static bool ExportMGDialog(DataMysteryGift gift)
{
using var sfd = new SaveFileDialog();
sfd.Filter = GetMysterGiftFilter(gift.Context);
sfd.FileName = PathUtil.CleanFileName(gift.FileName);
if (sfd.ShowDialog() != DialogResult.OK)
return false;
string path = sfd.FileName;
SaveBackup(path);
File.WriteAllBytes(path, gift.Write());
return true;
}
/// <summary>
/// Gets the File Dialog filter for a Mystery Gift I/O operation.
/// </summary>
/// <param name="context">Context specifier for the </param>
public static string GetMysterGiftFilter(EntityContext context) => context switch
{
EntityContext.Gen4 => "Gen4 Mystery Gift|*.pgt;*.pcd;*.wc4" + all,
EntityContext.Gen5 => "Gen5 Mystery Gift|*.pgf;*.wc5full" + all,
EntityContext.Gen6 => "Gen6 Mystery Gift|*.wc6;*.wc6full" + all,
EntityContext.Gen7 => "Gen7 Mystery Gift|*.wc7;*.wc7full" + all,
EntityContext.Gen8 => "Gen8 Mystery Gift|*.wc8" + all,
EntityContext.Gen9 => "Gen9 Mystery Gift|*.wc9" + all,
EntityContext.Gen7b => "Beluga Gift Record|*.wr7" + all,
EntityContext.Gen8b => "BD/SP Gift|*.wb8" + all,
EntityContext.Gen8a => "Legends: Arceus Gift|*.wa8" + all,
EntityContext.Gen9a => "Legends: Z-A Gift|*.wa9" + all,
_ => string.Empty,
};
private const string all = "|All Files|*.*";
/// <summary>
/// Gets the language code for a supported <see cref="GameLanguage"/> based on the current UI culture.
/// </summary>
/// <remarks>
/// Initially, CurrentUICulture is set based on the user's language preferences in Windows.
/// Once <see cref="SetCultureLanguage"/> is called, it becomes the current display language instead.
/// </remarks>
/// <returns>A supported language code.</returns>
public static string GetCultureLanguage()
{
var ci = Thread.CurrentThread.CurrentUICulture;
var name = ci.Name;
var code = ci.TwoLetterISOLanguageName;
return code switch
{
// For languages with multiple supported variants, map the language tag to one of the supported ones
// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
"es" => name switch
{
"es" or "es-ES" or "es-ES_tradnl" or "es-GQ" => "es", // Spanish (Spain)
_ => "es-419", // Spanish (Latin America)
},
"zh" => name switch
{
"zh-Hant" or "zh-HK" or "zh-MO" or "zh-TW" => "zh-Hant", // Traditional Chinese (Hong Kong/Macau/Taiwan)
_ => "zh-Hans", // Simplified Chinese (China/Singapore)
},
// Use this language code if we support it, otherwise default to English
_ => GameLanguage.IsLanguageValid(code) ? code : GameLanguage.DefaultLanguage,
};
}
/// <summary>
/// Sets the culture.
/// </summary>
/// <param name="lang">Language code</param>
/// <remarks>
/// Makes it easy to pass language to other forms.
/// </remarks>
public static void SetCultureLanguage(string lang)
{
var ci = new CultureInfo(lang);
Thread.CurrentThread.CurrentCulture = Thread.CurrentThread.CurrentUICulture = ci;
}
public static void InvertToolStripIcons(ToolStripItemCollection collection)
{
foreach (var o in collection)
{
if (o is not ToolStripMenuItem item)
continue;
InvertToolStripIcons(item.DropDownItems);
if (item.Image is not Bitmap x)
continue;
item.Image = BlackToWhite(x);
}
}
public static Bitmap BlackToWhite(Bitmap bmp) => Drawing.ImageUtil.CopyChangeAllColorTo(bmp, Color.White);
// SystemColor equivalents for dark mode support
public static Color ColorWarn => Application.IsDarkModeEnabled ? Color.OrangeRed : Color.Red;
public static Color ColorValid => Application.IsDarkModeEnabled ? Color.FromArgb(030, 070, 030) : Color.FromArgb(200, 255, 200);
public static Color ColorHint => Application.IsDarkModeEnabled ? Color.DarkKhaki : Color.LightYellow;
public static Color ColorSuspect => Application.IsDarkModeEnabled ? Color.LightCoral : Color.LightSalmon;
public static Color ColorAccept => Application.IsDarkModeEnabled ? Color.DarkSlateBlue : Color.LightBlue;
public static Color ColorPlus => Application.IsDarkModeEnabled ? Color.OrangeRed : Color.Red;
public static Color ColorMinus => Application.IsDarkModeEnabled ? Color.MediumBlue : Color.Blue;
public static Color ColorAlternate => Application.IsDarkModeEnabled ? Color.SlateGray : Color.SeaShell;
}