diff --git a/tools/config/.gitignore b/tools/config/.gitignore
new file mode 100644
index 0000000..7645b76
--- /dev/null
+++ b/tools/config/.gitignore
@@ -0,0 +1,15 @@
+*.suo
+*.o
+*.obj
+*.pdb
+*.lib
+*.exp
+[Dd]ebug/
+[Rr]elease/
+[Oo]bj/
+*.user
+*.ipch
+.vs/
+*.vcxproj
+*.filters
+*.pubxml
diff --git a/tools/config/TRX_ConfigToolLib.sln b/tools/config/TRX_ConfigToolLib.sln
new file mode 100644
index 0000000..efff011
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib.sln
@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.11.35219.272
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TRX_ConfigToolLib", "TRX_ConfigToolLib\TRX_ConfigToolLib.csproj", "{27F08E8C-2910-4682-B8BC-96ED4C1ECE54}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {27F08E8C-2910-4682-B8BC-96ED4C1ECE54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {27F08E8C-2910-4682-B8BC-96ED4C1ECE54}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {27F08E8C-2910-4682-B8BC-96ED4C1ECE54}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {27F08E8C-2910-4682-B8BC-96ED4C1ECE54}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {BA21B1D5-1CC7-4ED8-8C79-A1A5B0ACC840}
+ EndGlobalSection
+EndGlobal
diff --git a/tools/config/TRX_ConfigToolLib/Controls/AboutWindow.xaml b/tools/config/TRX_ConfigToolLib/Controls/AboutWindow.xaml
new file mode 100644
index 0000000..13e1b0f
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Controls/AboutWindow.xaml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/config/TRX_ConfigToolLib/Controls/AboutWindow.xaml.cs b/tools/config/TRX_ConfigToolLib/Controls/AboutWindow.xaml.cs
new file mode 100644
index 0000000..ac49f6b
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Controls/AboutWindow.xaml.cs
@@ -0,0 +1,20 @@
+using System.Windows;
+using TRX_ConfigToolLib.Models;
+using TRX_ConfigToolLib.Utils;
+
+namespace TRX_ConfigToolLib.Controls;
+
+public partial class AboutWindow : Window
+{
+ public AboutWindow()
+ {
+ InitializeComponent();
+ DataContext = new AboutWindowViewModel();
+ Owner = Application.Current.MainWindow;
+ }
+
+ private void GitHubHyperlink_Click(object sender, RoutedEventArgs e)
+ {
+ ProcessUtils.Start(TRXConstants.Instance.GitHubURL);
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Controls/CategoryControl.xaml b/tools/config/TRX_ConfigToolLib/Controls/CategoryControl.xaml
new file mode 100644
index 0000000..8c73ecd
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Controls/CategoryControl.xaml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/config/TRX_ConfigToolLib/Controls/CategoryControl.xaml.cs b/tools/config/TRX_ConfigToolLib/Controls/CategoryControl.xaml.cs
new file mode 100644
index 0000000..546c0fb
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Controls/CategoryControl.xaml.cs
@@ -0,0 +1,17 @@
+using System.Windows.Controls;
+using TRX_ConfigToolLib.Models;
+
+namespace TRX_ConfigToolLib.Controls;
+
+public partial class CategoryControl : UserControl
+{
+ public CategoryControl()
+ {
+ InitializeComponent();
+ }
+
+ private void ScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e)
+ {
+ (DataContext as CategoryViewModel).ViewPosition = e.VerticalOffset;
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Controls/NumericUpDown.xaml b/tools/config/TRX_ConfigToolLib/Controls/NumericUpDown.xaml
new file mode 100644
index 0000000..1b18788
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Controls/NumericUpDown.xaml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/config/TRX_ConfigToolLib/Controls/NumericUpDown.xaml.cs b/tools/config/TRX_ConfigToolLib/Controls/NumericUpDown.xaml.cs
new file mode 100644
index 0000000..a72cf31
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Controls/NumericUpDown.xaml.cs
@@ -0,0 +1,240 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using TRX_ConfigToolLib.Utils;
+
+namespace TRX_ConfigToolLib.Controls;
+
+public partial class NumericUpDown : UserControl
+{
+ public static readonly DependencyProperty ValueProperty = DependencyProperty.Register
+ (
+ nameof(Value), typeof(decimal), typeof(NumericUpDown), new PropertyMetadata(decimal.MinValue, (sender, args) =>
+ {
+ if (sender is NumericUpDown numericUpDown)
+ {
+ numericUpDown.OnValueChanged();
+ }
+ })
+ );
+
+ public static readonly DependencyProperty TextProperty = DependencyProperty.Register
+ (
+ nameof(Text), typeof(string), typeof(NumericUpDown), new PropertyMetadata(string.Empty)
+ );
+
+ public static readonly DependencyProperty DecimalPlacesProperty = DependencyProperty.Register
+ (
+ nameof(DecimalPlaces), typeof(int), typeof(NumericUpDown), new PropertyMetadata(0, (sender, args) =>
+ {
+ if (sender is NumericUpDown numericUpDown)
+ {
+ numericUpDown.UpdateDisplayText();
+ }
+ })
+ );
+
+ public static readonly DependencyProperty MinValueProperty = DependencyProperty.Register
+ (
+ nameof(MinValue), typeof(decimal), typeof(NumericUpDown), new PropertyMetadata(decimal.MinValue)
+ );
+
+ public static readonly DependencyProperty MaxValueProperty = DependencyProperty.Register
+ (
+ nameof(MaxValue), typeof(decimal), typeof(NumericUpDown), new PropertyMetadata(decimal.MaxValue)
+ );
+
+ public event EventHandler ValueChanged;
+
+ private bool _directValueSet;
+
+ private void OnValueChanged()
+ {
+ if (!_directValueSet)
+ {
+ UpdateDisplayText();
+ }
+ }
+
+ public decimal Value
+ {
+ get => (decimal)GetValue(ValueProperty);
+ set
+ {
+ _directValueSet = true;
+ SetValue(ValueProperty, Clamp(value));
+ SetDisplayText();
+ ValueChanged?.Invoke(this, EventArgs.Empty);
+ _directValueSet = false;
+ }
+ }
+
+ private bool _updateDisplayText;
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public int DecimalPlaces
+ {
+ get => (int)GetValue(DecimalPlacesProperty);
+ set
+ {
+ SetValue(DecimalPlacesProperty, Math.Max(0, value));
+ _stepSize = -1;
+ }
+ }
+
+ private decimal _stepSize = -1;
+ public decimal StepSize
+ {
+ get
+ {
+ if (_stepSize == -1)
+ {
+ _stepSize = (decimal)Math.Pow(10, DecimalPlaces * -1);
+ }
+ return _stepSize;
+ }
+ }
+
+ public decimal MinValue
+ {
+ get => (decimal)GetValue(MinValueProperty);
+ set => SetValue(MinValueProperty, value);
+ }
+
+ public decimal MaxValue
+ {
+ get => (decimal)GetValue(MaxValueProperty);
+ set => SetValue(MaxValueProperty, value);
+ }
+
+ public NumericUpDown()
+ {
+ InitializeComponent();
+ _textBox.DataContext = this;
+ }
+
+ private RelayCommand _spinUpCommand;
+ public ICommand SpinUpCommand
+ {
+ get => _spinUpCommand ??= new RelayCommand(SpinUp, CanSpinUp);
+ }
+
+ private void SpinUp()
+ {
+ AmendValue(StepSize);
+ }
+
+ private bool CanSpinUp()
+ {
+ return Value < MaxValue;
+ }
+
+ private RelayCommand _spinDownCommand;
+ public ICommand SpinDownCommand
+ {
+ get => _spinDownCommand ??= new RelayCommand(SpinDown, CanSpinDown);
+ }
+
+ private void SpinDown()
+ {
+ AmendValue(-StepSize);
+ }
+
+ private bool CanSpinDown()
+ {
+ return Value > MinValue;
+ }
+
+ private void AmendValue(decimal adjustment)
+ {
+ _updateDisplayText = true;
+ Value += adjustment;
+ _updateDisplayText = false;
+ }
+
+ private decimal Clamp(decimal value)
+ {
+ value = Math.Round(value, DecimalPlaces);
+ return Math.Min(MaxValue, Math.Max(MinValue, value));
+ }
+
+ private void TextBox_Pasting(object sender, DataObjectPastingEventArgs e)
+ {
+ object data = e.DataObject.GetData(DataFormats.UnicodeText);
+ if (!IsDataClean(data.ToString()))
+ {
+ e.CancelCommand();
+ }
+ }
+
+ private void TextBox_TextInput(object sender, TextCompositionEventArgs e)
+ {
+ e.Handled = !IsDataClean(e.Text);
+ }
+
+ private static bool IsDataClean(string data)
+ {
+ return decimal.TryParse(data, out decimal _)
+ || data == CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator
+ || data == CultureInfo.CurrentCulture.NumberFormat.NumberGroupSeparator
+ || data == CultureInfo.CurrentCulture.NumberFormat.NegativeSign;
+ }
+
+ private void TextBox_PreviewKeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Up || e.Key == Key.Down)
+ {
+ decimal step = StepSize;
+ if (Keyboard.IsKeyDown(Key.RightCtrl) || Keyboard.IsKeyDown(Key.LeftCtrl))
+ {
+ step *= 5;
+ }
+ if (e.Key == Key.Down)
+ {
+ step *= -1;
+ }
+
+ AmendValue(step);
+ }
+ else if (e.Key == Key.Enter)
+ {
+ UpdateDisplayText();
+ }
+ }
+
+ private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ if (!_updateDisplayText
+ && sender is TextBox textBox
+ && decimal.TryParse(textBox.Text, out decimal val)
+ && val >= MinValue && val <= MaxValue)
+ {
+ Value = val;
+ }
+ }
+
+ private void TextBox_LostFocus(object sender, RoutedEventArgs e)
+ {
+ UpdateDisplayText();
+ }
+
+ private void UpdateDisplayText()
+ {
+ _updateDisplayText = true;
+ SetDisplayText();
+ _updateDisplayText = false;
+ }
+
+ private void SetDisplayText()
+ {
+ if (_updateDisplayText)
+ {
+ Text = Value.ToString("F" + DecimalPlaces.ToString(CultureInfo.CurrentCulture), CultureInfo.CurrentCulture);
+ }
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Controls/PropertyControl.xaml b/tools/config/TRX_ConfigToolLib/Controls/PropertyControl.xaml
new file mode 100644
index 0000000..464fd2b
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Controls/PropertyControl.xaml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/config/TRX_ConfigToolLib/Controls/PropertyControl.xaml.cs b/tools/config/TRX_ConfigToolLib/Controls/PropertyControl.xaml.cs
new file mode 100644
index 0000000..2cd7b4f
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Controls/PropertyControl.xaml.cs
@@ -0,0 +1,11 @@
+using System.Windows.Controls;
+
+namespace TRX_ConfigToolLib.Controls;
+
+public partial class PropertyControl : UserControl
+{
+ public PropertyControl()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Controls/TRXConfigWindow.xaml b/tools/config/TRX_ConfigToolLib/Controls/TRXConfigWindow.xaml
new file mode 100644
index 0000000..931c882
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Controls/TRXConfigWindow.xaml
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/config/TRX_ConfigToolLib/Controls/TRXConfigWindow.xaml.cs b/tools/config/TRX_ConfigToolLib/Controls/TRXConfigWindow.xaml.cs
new file mode 100644
index 0000000..84b07c8
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Controls/TRXConfigWindow.xaml.cs
@@ -0,0 +1,47 @@
+using System.ComponentModel;
+using System.Globalization;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Markup;
+using TRX_ConfigToolLib.Models;
+using TRX_ConfigToolLib.Utils;
+
+namespace TRX_ConfigToolLib;
+
+public partial class TRXConfigWindow : Window
+{
+ static TRXConfigWindow()
+ {
+ LanguageProperty.OverrideMetadata
+ (
+ typeof(FrameworkElement),
+ new FrameworkPropertyMetadata(XmlLanguage.GetLanguage(CultureInfo.CurrentCulture.Name))
+ );
+ }
+
+ public TRXConfigWindow()
+ {
+ InitializeComponent();
+ DataContext = new MainWindowViewModel();
+ }
+
+ private void Window_Closing(object sender, CancelEventArgs e)
+ {
+ (DataContext as MainWindowViewModel).Exit(e);
+ }
+
+ private void Window_Loaded(object sender, RoutedEventArgs e)
+ {
+ (DataContext as MainWindowViewModel).Load();
+ }
+
+ private void TabControl_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (e.AddedItems.Count > 0
+ && e.AddedItems[0] is CategoryViewModel
+ && VisualUtils.GetChild(sender as DependencyObject, typeof(ScrollViewer)) is ScrollViewer scroller)
+ {
+ scroller.ScrollToVerticalOffset((DataContext as MainWindowViewModel).SelectedCategory.ViewPosition);
+ }
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Models/AboutWindowViewModel.cs b/tools/config/TRX_ConfigToolLib/Models/AboutWindowViewModel.cs
new file mode 100644
index 0000000..4193b22
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Models/AboutWindowViewModel.cs
@@ -0,0 +1,11 @@
+using TRX_ConfigToolLib.Utils;
+
+namespace TRX_ConfigToolLib.Models;
+
+public class AboutWindowViewModel : BaseLanguageViewModel
+{
+ public static string ImageSource
+ {
+ get => AssemblyUtils.GetEmbeddedResourcePath(TRXConstants.Instance.AppImage);
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Models/BaseLanguageViewModel.cs b/tools/config/TRX_ConfigToolLib/Models/BaseLanguageViewModel.cs
new file mode 100644
index 0000000..cff2ea8
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Models/BaseLanguageViewModel.cs
@@ -0,0 +1,9 @@
+namespace TRX_ConfigToolLib.Models;
+
+public class BaseLanguageViewModel : BaseNotifyPropertyChanged
+{
+ public static Dictionary ViewText
+ {
+ get => Language.Instance.Controls;
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Models/CategoryViewModel.cs b/tools/config/TRX_ConfigToolLib/Models/CategoryViewModel.cs
new file mode 100644
index 0000000..66ebfcb
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Models/CategoryViewModel.cs
@@ -0,0 +1,32 @@
+using TRX_ConfigToolLib.Utils;
+
+namespace TRX_ConfigToolLib.Models;
+
+public class CategoryViewModel
+{
+ private static readonly string _defaultImage = "Graphics/graphic1.jpg";
+
+ private readonly Category _category;
+
+ public CategoryViewModel(Category category)
+ {
+ _category = category;
+ }
+
+ public string Title
+ {
+ get => _category.Title;
+ }
+
+ public string ImageSource
+ {
+ get => AssemblyUtils.GetEmbeddedResourcePath(_category.Image ?? _defaultImage);
+ }
+
+ public IEnumerable ItemsSource
+ {
+ get => _category.Properties;
+ }
+
+ public double ViewPosition { get; set; }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Models/Lang/Language.cs b/tools/config/TRX_ConfigToolLib/Models/Lang/Language.cs
new file mode 100644
index 0000000..2c44763
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Models/Lang/Language.cs
@@ -0,0 +1,49 @@
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using System.Globalization;
+using TRX_ConfigToolLib.Utils;
+
+namespace TRX_ConfigToolLib.Models;
+
+public class Language
+{
+ private static readonly string _langPathFormat = "Resources.Lang.{0}.json";
+ private static readonly string _defaultCulture = "en-US";
+
+ public static Language Instance { get; private set; }
+
+ public Dictionary Controls { get; set; }
+ public Dictionary> Enums { get; set; }
+ public Dictionary Categories { get; set; }
+ public Dictionary Properties { get; set; }
+
+ static Language()
+ {
+ CultureInfo defaultCulture = CultureInfo.GetCultureInfo(_defaultCulture);
+ JObject defaultData = ReadLanguage(defaultCulture.TwoLetterISOLanguageName);
+
+ if (CultureInfo.CurrentCulture != defaultCulture)
+ {
+ // Merge the main language first if it exists, and then the country specific if that exists.
+ // e.g. fr.json would load first, then fr-BE.json.
+ MergeLanguage(defaultData, CultureInfo.CurrentCulture.TwoLetterISOLanguageName);
+ MergeLanguage(defaultData, CultureInfo.CurrentCulture.Name);
+ }
+
+ Instance = JsonConvert.DeserializeObject(defaultData.ToString());
+ }
+
+ private static JObject ReadLanguage(string tag)
+ {
+ return JsonUtils.LoadEmbeddedResource(string.Format(_langPathFormat, tag));
+ }
+
+ private static void MergeLanguage(JObject data, string tag)
+ {
+ JObject cultureData = ReadLanguage(tag);
+ if (cultureData != null)
+ {
+ data.Merge(cultureData);
+ }
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Models/Lang/PropertyText.cs b/tools/config/TRX_ConfigToolLib/Models/Lang/PropertyText.cs
new file mode 100644
index 0000000..c11a006
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Models/Lang/PropertyText.cs
@@ -0,0 +1,7 @@
+namespace TRX_ConfigToolLib.Models;
+
+public class PropertyText
+{
+ public string Title { get; set; }
+ public string Description { get; set; }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Models/MainWindowViewModel.cs b/tools/config/TRX_ConfigToolLib/Models/MainWindowViewModel.cs
new file mode 100644
index 0000000..cc8a5a5
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Models/MainWindowViewModel.cs
@@ -0,0 +1,334 @@
+using Microsoft.Win32;
+using System.ComponentModel;
+using System.IO;
+using System.Windows;
+using System.Windows.Input;
+using TRX_ConfigToolLib.Controls;
+using TRX_ConfigToolLib.Utils;
+
+namespace TRX_ConfigToolLib.Models;
+
+public class MainWindowViewModel : BaseLanguageViewModel
+{
+ private readonly Configuration _configuration;
+
+ public IEnumerable Categories { get; private set; }
+
+ public MainWindowViewModel()
+ {
+ _configuration = new();
+
+ List categories = new();
+ foreach (Category category in _configuration.Categories.Where(c => c.Properties.Count > 0))
+ {
+ categories.Add(new(category));
+ category.Properties
+ .ForEach(p => p.PropertyChanged += EditorPropertyChanged);
+ }
+
+ Categories = categories;
+ SelectedCategory = Categories.FirstOrDefault();
+ }
+
+ private void EditorPropertyChanged(object sender, PropertyChangedEventArgs e)
+ {
+ IsEditorDirty = _configuration.IsDataDirty();
+ IsEditorDefault = _configuration.IsDataDefault();
+ }
+
+ public void Load()
+ {
+ Open(Path.GetFullPath(TRXConstants.Instance.ConfigPath));
+ }
+
+ private CategoryViewModel _selectedCategory;
+ public CategoryViewModel SelectedCategory
+ {
+ get => _selectedCategory;
+ set
+ {
+ _selectedCategory = value;
+ NotifyPropertyChanged();
+ }
+ }
+
+ private bool _isEditorDirty;
+ public bool IsEditorDirty
+ {
+ get => _isEditorDirty;
+ set
+ {
+ if (_isEditorDirty != value)
+ {
+ _isEditorDirty = value;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ private bool _isEditorDefault;
+ public bool IsEditorDefault
+ {
+ get => _isEditorDefault;
+ set
+ {
+ if (_isEditorDefault != value)
+ {
+ _isEditorDefault = value;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ private string _selectedFile;
+ public string SelectedFile
+ {
+ get => _selectedFile;
+ set
+ {
+ if (_selectedFile != value)
+ {
+ _selectedFile = value;
+ NotifyPropertyChanged();
+ NotifyPropertyChanged(nameof(IsEditorActive));
+ }
+ }
+ }
+
+ public bool IsEditorActive
+ {
+ get => SelectedFile != null;
+ }
+
+ private RelayCommand _openCommand;
+ public ICommand OpenCommand
+ {
+ get => _openCommand ??= new RelayCommand(Open);
+ }
+
+ private void Open()
+ {
+ if (!ConfirmEditorSaveState())
+ {
+ return;
+ }
+
+ OpenFileDialog dialog = new()
+ {
+ Filter = ViewText["file_dialog_filter"] + TRXConstants.Instance.ConfigFilterExtension
+ };
+ if (IsEditorActive)
+ {
+ dialog.InitialDirectory = Path.GetDirectoryName(SelectedFile);
+ }
+ if (dialog.ShowDialog() ?? false)
+ {
+ Open(dialog.FileName);
+ }
+ }
+
+ private void Open(string filePath)
+ {
+ try
+ {
+ _configuration.Read(filePath);
+ SelectedFile = filePath;
+ IsEditorDirty = false;
+ IsEditorDefault = _configuration.IsDataDefault();
+ }
+ catch (Exception e)
+ {
+ MessageBoxUtils.ShowError(e.ToString(), ViewText["window_title_main"]);
+ }
+ }
+
+ private RelayCommand _reloadCommand;
+ public ICommand ReloadCommand
+ {
+ get => _reloadCommand ??= new RelayCommand(Reload, CanReload);
+ }
+
+ private void Reload()
+ {
+ if (ConfirmEditorReloadState())
+ {
+ Open(SelectedFile);
+ }
+ }
+
+ private bool CanReload()
+ {
+ return IsEditorActive;
+ }
+
+ private RelayCommand _saveCommand;
+ public ICommand SaveCommand
+ {
+ get => _saveCommand ??= new RelayCommand(Save, CanSave);
+ }
+
+ private void Save()
+ {
+ Save(SelectedFile);
+ }
+
+ private void Save(string filePath)
+ {
+ try
+ {
+ _configuration.Write(filePath);
+ SelectedFile = filePath;
+ IsEditorDirty = false;
+ }
+ catch (Exception e)
+ {
+ MessageBoxUtils.ShowError(e.ToString(), ViewText["window_title_main"]);
+ }
+ }
+
+ private bool CanSave()
+ {
+ return IsEditorDirty;
+ }
+
+ private RelayCommand _saveAsCommand;
+ public ICommand SaveAsCommand
+ {
+ get => _saveAsCommand ??= new RelayCommand(SaveAs, CanSaveAs);
+ }
+
+ private void SaveAs()
+ {
+ SaveFileDialog dialog = new()
+ {
+ Filter = ViewText["file_dialog_filter"] + TRXConstants.Instance.ConfigFilterExtension,
+ InitialDirectory = Path.GetDirectoryName(SelectedFile)
+ };
+ if (dialog.ShowDialog() ?? false)
+ {
+ Save(dialog.FileName);
+ }
+ }
+
+ private bool CanSaveAs()
+ {
+ return IsEditorActive;
+ }
+
+ private RelayCommand _launchGameCommand;
+ public ICommand LaunchGameCommand
+ {
+ get => _launchGameCommand ??= new RelayCommand(() => LaunchGame());
+ }
+
+ public static bool CanLaunchGold
+ {
+ get => TRXConstants.Instance.GoldSupported;
+ }
+
+ private RelayCommand _launchGoldCommand;
+ public ICommand LaunchGoldCommand
+ {
+ get => _launchGoldCommand ??= new RelayCommand(() => LaunchGame(TRXConstants.Instance.GoldArgs));
+ }
+
+ private void LaunchGame(string arguments = null)
+ {
+ if (!ConfirmEditorSaveState())
+ {
+ return;
+ }
+
+ try
+ {
+ ProcessUtils.Start(Path.GetFullPath(TRXConstants.Instance.ExecutableName), arguments);
+ }
+ catch (Exception e)
+ {
+ MessageBoxUtils.ShowError(e.ToString(), ViewText["window_title_main"]);
+ }
+ }
+
+ private RelayCommand _exitCommand;
+ public ICommand ExitCommand
+ {
+ get => _exitCommand ??= new RelayCommand(Exit);
+ }
+
+ private void Exit(Window window)
+ {
+ if (ConfirmEditorSaveState())
+ {
+ IsEditorDirty = false;
+ window.Close();
+ }
+ }
+
+ public void Exit(CancelEventArgs e)
+ {
+ if (!ConfirmEditorSaveState())
+ {
+ e.Cancel = true;
+ }
+ }
+
+ public bool ConfirmEditorSaveState()
+ {
+ if (IsEditorDirty)
+ {
+ switch (MessageBoxUtils.ShowYesNoCancel(ViewText["msgbox_unsaved_changes"], ViewText["window_title_main"]))
+ {
+ case MessageBoxResult.Yes:
+ Save();
+ break;
+ case MessageBoxResult.Cancel:
+ return false;
+ }
+ }
+ return true;
+ }
+
+ public bool ConfirmEditorReloadState()
+ {
+ return !IsEditorDirty
+ || MessageBoxUtils.ShowYesNo(ViewText["msgbox_unsaved_changes_reload"], ViewText["window_title_main"]);
+ }
+
+ private RelayCommand _restoreDefaultsCommand;
+ public ICommand RestoreDefaultsCommand
+ {
+ get => _restoreDefaultsCommand ??= new RelayCommand(RestoreDefaults, CanRestoreDefaults);
+ }
+
+ private void RestoreDefaults()
+ {
+ _configuration.RestoreDefaults();
+ }
+
+ private bool CanRestoreDefaults()
+ {
+ return IsEditorActive && !IsEditorDefault;
+ }
+
+ private RelayCommand _gitHubCommand;
+ public ICommand GitHubCommand
+ {
+ get => _gitHubCommand ??= new RelayCommand(GoToGitHub);
+ }
+
+ private void GoToGitHub()
+ {
+ ProcessUtils.Start(TRXConstants.Instance.GitHubURL);
+ }
+
+ private RelayCommand _aboutCommand;
+ public ICommand AboutCommand
+ {
+ get => _aboutCommand ??= new RelayCommand(ShowAboutDialog);
+ }
+
+ private void ShowAboutDialog()
+ {
+ new AboutWindow().ShowDialog();
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Models/Specification/BaseProperty.cs b/tools/config/TRX_ConfigToolLib/Models/Specification/BaseProperty.cs
new file mode 100644
index 0000000..9b5adc5
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Models/Specification/BaseProperty.cs
@@ -0,0 +1,26 @@
+namespace TRX_ConfigToolLib.Models;
+
+public abstract class BaseProperty : BaseNotifyPropertyChanged
+{
+ public string Field { get; set; }
+
+ public string Title
+ {
+ get => Language.Instance.Properties[Field].Title;
+ }
+
+ public string Description
+ {
+ get => Language.Instance.Properties[Field].Description;
+ }
+
+ public abstract object ExportValue();
+ public abstract void LoadValue(string value);
+ public abstract void SetToDefault();
+ public abstract bool IsDefault { get; }
+
+ public virtual void Initialise(Specification specification)
+ {
+ SetToDefault();
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Models/Specification/Category.cs b/tools/config/TRX_ConfigToolLib/Models/Specification/Category.cs
new file mode 100644
index 0000000..317cb50
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Models/Specification/Category.cs
@@ -0,0 +1,12 @@
+namespace TRX_ConfigToolLib.Models;
+
+public class Category
+{
+ public string ID { get; set; }
+ public string Image { get; set; }
+ public List Properties { get; set; }
+ public string Title
+ {
+ get => Language.Instance.Categories[ID];
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Models/Specification/Configuration.cs b/tools/config/TRX_ConfigToolLib/Models/Specification/Configuration.cs
new file mode 100644
index 0000000..b5e4887
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Models/Specification/Configuration.cs
@@ -0,0 +1,150 @@
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using System.IO;
+using TRX_ConfigToolLib.Utils;
+
+namespace TRX_ConfigToolLib.Models;
+
+public class Configuration
+{
+ private static readonly string _specificationPath = "Resources.specification.json";
+ private static readonly JsonSerializerSettings _serializerSettings = new()
+ {
+ Converters = new NumericConverter[] { new() },
+ Formatting = Formatting.Indented,
+ };
+
+ private readonly Specification _specification;
+ private JObject _activeData, _externalData;
+
+ public IReadOnlyList Categories
+ {
+ get => _specification.CategorisedProperties;
+ }
+
+ public IReadOnlyList Properties
+ {
+ get => _specification.Properties;
+ }
+
+ public Configuration()
+ {
+ using Stream stream = AssemblyUtils.GetResourceStream(_specificationPath, false);
+ using StreamReader reader = new(stream);
+ _specification = new Specification(reader.ReadToEnd());
+ RestoreDefaults();
+ }
+
+ public void RestoreDefaults()
+ {
+ foreach (BaseProperty property in Properties)
+ {
+ property.SetToDefault();
+ }
+ }
+
+ public bool IsDataDirty()
+ {
+ if (_activeData != null)
+ {
+ JObject convertedData = GetConvertedData();
+ if (convertedData.Count != _activeData.Count)
+ {
+ return true;
+ }
+
+ foreach (var (key, value) in convertedData)
+ {
+ if (!_activeData.ContainsKey(key) || !_activeData[key].Equals(value))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ public bool IsDataDefault()
+ {
+ return Properties.All(p => p.IsDefault);
+ }
+
+ public void Read(string jsonPath)
+ {
+ JObject externalData = File.Exists(jsonPath)
+ ? JObject.Parse(File.ReadAllText(jsonPath))
+ : new();
+ JObject activeData = new();
+
+ foreach (BaseProperty property in Properties)
+ {
+ if (externalData.ContainsKey(property.Field))
+ {
+ property.LoadValue(externalData[property.Field].ToString());
+ externalData.Remove(property.Field);
+ }
+ else
+ {
+ property.SetToDefault();
+ }
+
+ activeData[property.Field] = JToken.FromObject(property.ExportValue());
+ }
+
+ _activeData = activeData;
+ _externalData = externalData;
+ }
+
+ public void Write(string jsonPath)
+ {
+ JObject data = GetConvertedData();
+ JObject newActiveData = new(data);
+
+ // If the file already exists, re-read any external data from it. Otherwise if writing
+ // to a new file, whatever external data was captured in Read will be restored.
+ if (File.Exists(jsonPath))
+ {
+ JObject existingData = GetExistingExternalData(jsonPath);
+ if (existingData != null)
+ {
+ _externalData = existingData;
+ }
+ }
+ data.Merge(_externalData);
+
+ File.WriteAllText(jsonPath, JsonConvert.SerializeObject(data, _serializerSettings));
+
+ _activeData = newActiveData;
+ }
+
+ private JObject GetConvertedData()
+ {
+ JObject data = new();
+ foreach (BaseProperty property in Properties)
+ {
+ data[property.Field] = JToken.FromObject(property.ExportValue());
+ }
+ return data;
+ }
+
+ private JObject GetExistingExternalData(string jsonPath)
+ {
+ try
+ {
+ JObject data = JObject.Parse(File.ReadAllText(jsonPath));
+ foreach (BaseProperty property in Properties)
+ {
+ if (data.ContainsKey(property.Field))
+ {
+ data.Remove(property.Field);
+ }
+ }
+ return data;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Models/Specification/DataType.cs b/tools/config/TRX_ConfigToolLib/Models/Specification/DataType.cs
new file mode 100644
index 0000000..3766f58
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Models/Specification/DataType.cs
@@ -0,0 +1,8 @@
+namespace TRX_ConfigToolLib.Models;
+
+public enum DataType
+{
+ Bool,
+ Enum,
+ Numeric,
+}
diff --git a/tools/config/TRX_ConfigToolLib/Models/Specification/EnumOption.cs b/tools/config/TRX_ConfigToolLib/Models/Specification/EnumOption.cs
new file mode 100644
index 0000000..5b8ed11
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Models/Specification/EnumOption.cs
@@ -0,0 +1,11 @@
+namespace TRX_ConfigToolLib.Models;
+
+public class EnumOption
+{
+ public string EnumName { get; set; }
+ public string ID { get; set; }
+ public string Title
+ {
+ get => Language.Instance.Enums[EnumName][ID];
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Models/Specification/Specification.cs b/tools/config/TRX_ConfigToolLib/Models/Specification/Specification.cs
new file mode 100644
index 0000000..43da7c4
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Models/Specification/Specification.cs
@@ -0,0 +1,42 @@
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using TRX_ConfigToolLib.Utils;
+
+namespace TRX_ConfigToolLib.Models;
+
+public class Specification
+{
+ public Dictionary> Enums { get; private set; }
+ public List CategorisedProperties { get; private set; }
+ public List Properties { get; private set; }
+
+ public Specification(string sourceData)
+ {
+ JObject data = JObject.Parse(sourceData);
+ JObject enumData = data.ContainsKey(nameof(Enums))
+ ? data[nameof(Enums)].ToObject()
+ : new();
+ Enums = new();
+
+ foreach (var (key, value) in enumData)
+ {
+ List enumValues = value.ToObject>();
+ Enums[key] = enumValues.Select(val => new EnumOption
+ {
+ EnumName = key,
+ ID = val
+ }).ToList();
+ }
+
+ string categoryData = data[nameof(CategorisedProperties)].ToString();
+ PropertyConverter converter = new();
+ CategorisedProperties = JsonConvert.DeserializeObject>(categoryData, converter);
+ Properties = new();
+
+ foreach (BaseProperty property in CategorisedProperties.SelectMany(c => c.Properties))
+ {
+ property.Initialise(this);
+ Properties.Add(property);
+ }
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Models/Specification/Types/BoolProperty.cs b/tools/config/TRX_ConfigToolLib/Models/Specification/Types/BoolProperty.cs
new file mode 100644
index 0000000..32ed4b4
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Models/Specification/Types/BoolProperty.cs
@@ -0,0 +1,44 @@
+namespace TRX_ConfigToolLib.Models;
+
+public class BoolProperty : BaseProperty
+{
+ private bool _value;
+
+ public bool Value
+ {
+ get => _value;
+ set
+ {
+ if (_value != value)
+ {
+ _value = value;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ public bool DefaultValue { get; set; }
+
+ public override bool IsDefault
+ {
+ get => Value == DefaultValue;
+ }
+
+ public override object ExportValue()
+ {
+ return Value;
+ }
+
+ public override void LoadValue(string value)
+ {
+ if (bool.TryParse(value, out bool val))
+ {
+ Value = val;
+ }
+ }
+
+ public override void SetToDefault()
+ {
+ Value = DefaultValue;
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Models/Specification/Types/EnumProperty.cs b/tools/config/TRX_ConfigToolLib/Models/Specification/Types/EnumProperty.cs
new file mode 100644
index 0000000..4da2832
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Models/Specification/Types/EnumProperty.cs
@@ -0,0 +1,54 @@
+namespace TRX_ConfigToolLib.Models;
+
+public class EnumProperty : BaseProperty
+{
+ public string EnumKey { get; set; }
+
+ private EnumOption _value;
+
+ public EnumOption Value
+ {
+ get => _value;
+ set
+ {
+ if (_value != value)
+ {
+ _value = value;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ public string DefaultValue { get; set; }
+
+ public override bool IsDefault
+ {
+ get => Value.ID == DefaultValue;
+ }
+
+ public List Options { get; set; }
+
+ public override object ExportValue()
+ {
+ return Value.ID;
+ }
+
+ public override void LoadValue(string value)
+ {
+ Value = Options.Find(o => o.ID == value);
+ }
+
+ public override void SetToDefault()
+ {
+ LoadValue(DefaultValue);
+ }
+
+ public override void Initialise(Specification specification)
+ {
+ if (specification.Enums.ContainsKey(EnumKey))
+ {
+ Options = specification.Enums[EnumKey];
+ }
+ base.Initialise(specification);
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Models/Specification/Types/NumericProperty.cs b/tools/config/TRX_ConfigToolLib/Models/Specification/Types/NumericProperty.cs
new file mode 100644
index 0000000..37422a0
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Models/Specification/Types/NumericProperty.cs
@@ -0,0 +1,54 @@
+namespace TRX_ConfigToolLib.Models;
+
+public class NumericProperty : BaseProperty
+{
+ private decimal _value;
+
+ public decimal Value
+ {
+ get => _value;
+ set
+ {
+ decimal clampedValue = Clamp(value);
+ if (_value != clampedValue)
+ {
+ _value = clampedValue;
+ NotifyPropertyChanged();
+ }
+ }
+ }
+
+ public decimal DefaultValue { get; set; }
+
+ public override bool IsDefault
+ {
+ get => Value == DefaultValue;
+ }
+
+ public decimal MinimumValue { get; set; } = decimal.MinValue;
+ public decimal MaximumValue { get; set; } = decimal.MaxValue;
+ public int DecimalPlaces { get; set; }
+
+ public override object ExportValue()
+ {
+ return Value;
+ }
+
+ public override void LoadValue(string value)
+ {
+ if (decimal.TryParse(value, out decimal d))
+ {
+ Value = d;
+ }
+ }
+
+ public override void SetToDefault()
+ {
+ Value = DefaultValue;
+ }
+
+ private decimal Clamp(decimal value)
+ {
+ return Math.Round(Math.Min(MaximumValue, Math.Max(MinimumValue, value)), DecimalPlaces);
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Models/TRXConstants.cs b/tools/config/TRX_ConfigToolLib/Models/TRXConstants.cs
new file mode 100644
index 0000000..d407fde
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Models/TRXConstants.cs
@@ -0,0 +1,23 @@
+using TRX_ConfigToolLib.Utils;
+
+namespace TRX_ConfigToolLib.Models;
+
+public class TRXConstants
+{
+ private static readonly string _constConfigPath = "Resources.const.json";
+
+ public static TRXConstants Instance { get; private set; }
+
+ static TRXConstants()
+ {
+ Instance = JsonUtils.LoadEmbeddedResource(_constConfigPath).ToObject();
+ }
+
+ public string AppImage { get; set; }
+ public string ConfigFilterExtension { get; set; }
+ public string ConfigPath { get; set; }
+ public string ExecutableName { get; set; }
+ public bool GoldSupported { get; set; }
+ public string GoldArgs { get; set; }
+ public string GitHubURL { get; set; }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Resources/Lang/en.json b/tools/config/TRX_ConfigToolLib/Resources/Lang/en.json
new file mode 100644
index 0000000..8170425
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Resources/Lang/en.json
@@ -0,0 +1,37 @@
+{
+ "Controls": {
+ "menu_file": "_File",
+ "command_open": "_Open",
+ "command_reload": "_Reload",
+ "command_save": "_Save",
+ "command_save_as": "Save _As...",
+ "command_launch_game": "_Launch Game",
+ "command_exit": "E_xit",
+ "menu_tools": "_Tools",
+ "command_restore": "_Restore Defaults",
+ "menu_help": "_Help",
+ "command_github": "_GitHub",
+ "command_about": "_About",
+ "checkbox_enabled": "Enabled",
+ "spinner_msg_invalid_number": "The input string is not a valid number",
+ "spinner_msg_comparison_failed": "The input value must be between {0} and {1}",
+ "spinner_increase": "Increase value",
+ "spinner_decrease": "Decrease value",
+ "label_no_file": "No file selected",
+ "label_saved": "Saved",
+ "label_unsaved": "Unsaved",
+ "command_close": "_Close",
+ "msgbox_unsaved_changes": "Do you want to save the changes you have made?",
+ "msgbox_unsaved_changes_reload": "Are you sure you want to reload and lose the changes you have made?",
+ "link_github": "Go to GitHub"
+ },
+ "Categories": {
+ "controls": "Controls",
+ "gameplay_fixes": "Gameplay fixes",
+ "gameplay_modifications": "Gameplay modifications",
+ "gameplay_options": "Gameplay options",
+ "graphics": "Graphics",
+ "sound": "Sound",
+ "ui": "UI"
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Resources/Lang/es.json b/tools/config/TRX_ConfigToolLib/Resources/Lang/es.json
new file mode 100644
index 0000000..5cc0f86
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Resources/Lang/es.json
@@ -0,0 +1,37 @@
+{
+ "Controls": {
+ "menu_file": "_Archivo",
+ "command_open": "_Abrir",
+ "command_reload": "_Recargar",
+ "command_save": "_Guardar",
+ "command_save_as": "Guardar _como...",
+ "command_launch_game": "_Iniciar juego",
+ "command_exit": "Salir",
+ "menu_tools": "_Herramientas",
+ "command_restore": "_Restaurar los valores predeterminados",
+ "menu_help": "_Ayuda",
+ "command_github": "_GitHub",
+ "command_about": "_Acerca de",
+ "checkbox_enabled": "Habilitado",
+ "spinner_msg_invalid_number": "La cadena de entrada no es un número válido",
+ "spinner_msg_comparison_failed": "El valor de entrada debe estar entre {0} y {1}",
+ "spinner_increase": "Aumentar valor",
+ "spinner_decrease": "Reducir valor",
+ "label_no_file": "Ningún archivo seleccionado",
+ "label_saved": "Guardado",
+ "label_unsaved": "No guardado",
+ "command_close": "_Cerrar",
+ "msgbox_unsaved_changes": "¿Deseas guardar los cambios que has realizado?",
+ "msgbox_unsaved_changes_reload": "¿Seguro que deseas recargar y perder los cambios realizados?",
+ "link_github": "Ir a GitHub"
+ },
+ "Categories": {
+ "controls": "Controles",
+ "gameplay_fixes": "Correcciones de jugabilidad",
+ "gameplay_modifications": "Modificaciones de jugabilidad",
+ "gameplay_options": "Opciones de jugabilidad",
+ "graphics": "Gráficos",
+ "sound": "Audio",
+ "ui": "Interfaz"
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Resources/Lang/fr.json b/tools/config/TRX_ConfigToolLib/Resources/Lang/fr.json
new file mode 100644
index 0000000..d7149c2
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Resources/Lang/fr.json
@@ -0,0 +1,37 @@
+{
+ "Controls": {
+ "menu_file": "_Fichier",
+ "command_open": "_Ouvrir",
+ "command_reload": "_Recharger",
+ "command_save": "_Sauvegarder",
+ "command_save_as": "Enregistrer sous...",
+ "command_launch_game": "_Lancer le jeu",
+ "command_exit": "_Quitter",
+ "menu_tools": "_Outils",
+ "command_restore": "_Restaurer par défaut",
+ "menu_help": "_Aide",
+ "command_github": "_GitHub",
+ "command_about": "_A propos",
+ "checkbox_enabled": "Activé",
+ "spinner_msg_invalid_number": "La valeur n'est pas un nombre valide",
+ "spinner_msg_comparison_failed": "La valeur doit être entre {0} et {1}",
+ "spinner_increase": "Augmenter la valeur",
+ "spinner_decrease": "Baisser la valeur",
+ "label_no_file": "Pas de fichier selectionné",
+ "label_saved": "Sauvegardé",
+ "label_unsaved": "Non sauvegardé",
+ "command_close": "_Fermer",
+ "msgbox_unsaved_changes": "Voulez-vous appliquer les changements ?",
+ "msgbox_unsaved_changes_reload": "Êtes-vous sûr de vouloir recharger et perdre les modifications que vous avez apporté ?",
+ "link_github": "Aller sur GitHub"
+ },
+ "Categories": {
+ "controls": "Contrôles",
+ "gameplay_fixes": "Corrections de gameplay",
+ "gameplay_modifications": "Modification de gameplay",
+ "gameplay_options": "Options de gameplay",
+ "graphics": "Graphismes",
+ "sound": "Son",
+ "ui": "Interface (UI)"
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Resources/Lang/it.json b/tools/config/TRX_ConfigToolLib/Resources/Lang/it.json
new file mode 100644
index 0000000..16e03ca
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Resources/Lang/it.json
@@ -0,0 +1,37 @@
+{
+ "Controls": {
+ "menu_file": "_File",
+ "command_open": "_Apri",
+ "command_reload": "_Ricarica",
+ "command_save": "_Salva",
+ "command_save_as": "Salva con nome...",
+ "command_launch_game": "_Lancia il gioco",
+ "command_exit": "_Esci",
+ "menu_tools": "_Strumenti",
+ "command_restore": "_Ripristina Predefiniti",
+ "menu_help": "_Aiuto",
+ "command_github": "_GitHub",
+ "command_about": "_Informazioni su",
+ "checkbox_enabled": "Abilitato",
+ "spinner_msg_invalid_number": "Il valore immesso non è un numero valido",
+ "spinner_msg_comparison_failed": "Il valore immesso deve essere compreso tra {0} e {1}",
+ "spinner_increase": "Aumenta il valore",
+ "spinner_decrease": "Diminuisci il valore",
+ "label_no_file": "Nessun file selezionato",
+ "label_saved": "Salvato",
+ "label_unsaved": "Non salvato",
+ "command_close": "_Chiudi",
+ "msgbox_unsaved_changes": "Vuoi salvare le modifiche apportate?",
+ "msgbox_unsaved_changes_reload": "Sei sicuro di voler ricaricare e perdere le modifiche apportate?",
+ "link_github": "Visita il sito GitHub"
+ },
+ "Categories": {
+ "controls": "Controlli",
+ "gameplay_fixes": "Correzioni del gioco",
+ "gameplay_modifications": "Modifiche al gioco",
+ "gameplay_options": "Opzioni di gioco",
+ "graphics": "Grafica",
+ "sound": "Suono",
+ "ui": "Interfaccia utente"
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Resources/arrow-down.png b/tools/config/TRX_ConfigToolLib/Resources/arrow-down.png
new file mode 100644
index 0000000..bb9a6f4
Binary files /dev/null and b/tools/config/TRX_ConfigToolLib/Resources/arrow-down.png differ
diff --git a/tools/config/TRX_ConfigToolLib/Resources/arrow-up.png b/tools/config/TRX_ConfigToolLib/Resources/arrow-up.png
new file mode 100644
index 0000000..0d725a1
Binary files /dev/null and b/tools/config/TRX_ConfigToolLib/Resources/arrow-up.png differ
diff --git a/tools/config/TRX_ConfigToolLib/Resources/const.json b/tools/config/TRX_ConfigToolLib/Resources/const.json
new file mode 100644
index 0000000..4ef39bb
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Resources/const.json
@@ -0,0 +1,4 @@
+{
+ "ConfigFilterExtension": "|*.json5",
+ "GoldArgs": "-gold"
+}
diff --git a/tools/config/TRX_ConfigToolLib/Resources/styles.xaml b/tools/config/TRX_ConfigToolLib/Resources/styles.xaml
new file mode 100644
index 0000000..e9acd9e
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Resources/styles.xaml
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/config/TRX_ConfigToolLib/TRX_ConfigToolLib.csproj b/tools/config/TRX_ConfigToolLib/TRX_ConfigToolLib.csproj
new file mode 100644
index 0000000..7a02a37
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/TRX_ConfigToolLib.csproj
@@ -0,0 +1,35 @@
+
+
+ net6.0-windows
+ disable
+ true
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ MSBuild:Compile
+
+
+
diff --git a/tools/config/TRX_ConfigToolLib/Utils/AssemblyUtils.cs b/tools/config/TRX_ConfigToolLib/Utils/AssemblyUtils.cs
new file mode 100644
index 0000000..fc7d0bc
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Utils/AssemblyUtils.cs
@@ -0,0 +1,35 @@
+using System.IO;
+using System.Reflection;
+
+namespace TRX_ConfigToolLib.Utils;
+
+public static class AssemblyUtils
+{
+ public static readonly string _resourcePathFormat = "pack://application:,,,/{0};component/Resources/{1}";
+
+ private static Assembly GetReferencedAssembly(bool local)
+ {
+ return local ? Assembly.GetExecutingAssembly() : Assembly.GetEntryAssembly();
+ }
+
+ public static Stream GetResourceStream(string relativePath, bool local)
+ {
+ return GetReferencedAssembly(local).GetManifestResourceStream(GetAbsolutePath(relativePath, local));
+ }
+
+ public static bool ResourceExists(string relativePath, bool local)
+ {
+ return GetReferencedAssembly(local).GetManifestResourceNames()
+ .Contains(GetAbsolutePath(relativePath, local));
+ }
+
+ public static string GetAbsolutePath(string relativePath, bool local)
+ {
+ return $"{GetReferencedAssembly(local).GetName().Name}.{relativePath}";
+ }
+
+ public static string GetEmbeddedResourcePath(string resource)
+ {
+ return string.Format(_resourcePathFormat, Assembly.GetEntryAssembly().GetName().Name, resource);
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Utils/BaseNotifyPropertyChanged.cs b/tools/config/TRX_ConfigToolLib/Utils/BaseNotifyPropertyChanged.cs
new file mode 100644
index 0000000..3bdaa5c
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Utils/BaseNotifyPropertyChanged.cs
@@ -0,0 +1,14 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace TRX_ConfigToolLib;
+
+public abstract class BaseNotifyPropertyChanged : INotifyPropertyChanged
+{
+ public event PropertyChangedEventHandler PropertyChanged;
+
+ public void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Utils/BindingProxy.cs b/tools/config/TRX_ConfigToolLib/Utils/BindingProxy.cs
new file mode 100644
index 0000000..62bfbe3
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Utils/BindingProxy.cs
@@ -0,0 +1,22 @@
+using System.Windows;
+
+namespace TRX_ConfigToolLib.Utils;
+
+public class BindingProxy : Freezable
+{
+ public static readonly DependencyProperty DataProperty = DependencyProperty.Register
+ (
+ nameof(Data), typeof(object), typeof(BindingProxy), new UIPropertyMetadata(null)
+ );
+
+ public object Data
+ {
+ get => GetValue(DataProperty);
+ set => SetValue(DataProperty, value);
+ }
+
+ protected override Freezable CreateInstanceCore()
+ {
+ return new BindingProxy();
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Utils/Converters/BoolToVisibilityConverter.cs b/tools/config/TRX_ConfigToolLib/Utils/Converters/BoolToVisibilityConverter.cs
new file mode 100644
index 0000000..2bf9887
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Utils/Converters/BoolToVisibilityConverter.cs
@@ -0,0 +1,28 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+
+namespace TRX_ConfigToolLib.Utils;
+
+[ValueConversion(typeof(bool), typeof(Visibility))]
+public class BoolToVisibilityConverter : IValueConverter
+{
+ public BoolToVisibilityConverter()
+ {
+ FalseValue = Visibility.Hidden;
+ TrueValue = Visibility.Visible;
+ }
+
+ public Visibility FalseValue { get; set; }
+ public Visibility TrueValue { get; set; }
+
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return (bool)value ? TrueValue : FalseValue;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Utils/Converters/ConditionalMarkupConverter.cs b/tools/config/TRX_ConfigToolLib/Utils/Converters/ConditionalMarkupConverter.cs
new file mode 100644
index 0000000..2ec0952
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Utils/Converters/ConditionalMarkupConverter.cs
@@ -0,0 +1,26 @@
+using System.Globalization;
+using System.Windows.Data;
+using System.Windows.Markup;
+
+namespace TRX_ConfigToolLib.Utils;
+
+public class ConditionalMarkupConverter : MarkupExtension, IValueConverter
+{
+ public object FalseValue { get; set; }
+ public object TrueValue { get; set; }
+
+ public virtual object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return value is true ? TrueValue : FalseValue;
+ }
+
+ public virtual object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotSupportedException();
+ }
+
+ public override object ProvideValue(IServiceProvider serviceProvider)
+ {
+ return this;
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Utils/Converters/ConditionalViewTextConverter.cs b/tools/config/TRX_ConfigToolLib/Utils/Converters/ConditionalViewTextConverter.cs
new file mode 100644
index 0000000..f350b5c
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Utils/Converters/ConditionalViewTextConverter.cs
@@ -0,0 +1,12 @@
+using System.Globalization;
+using TRX_ConfigToolLib.Models;
+
+namespace TRX_ConfigToolLib.Utils;
+
+public class ConditionalViewTextConverter : ConditionalMarkupConverter
+{
+ public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return Language.Instance.Controls[base.Convert(value, targetType, parameter, culture).ToString()];
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Utils/Json/NumericConverter.cs b/tools/config/TRX_ConfigToolLib/Utils/Json/NumericConverter.cs
new file mode 100644
index 0000000..f0a2885
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Utils/Json/NumericConverter.cs
@@ -0,0 +1,17 @@
+using Newtonsoft.Json;
+using System.Globalization;
+
+namespace TRX_ConfigToolLib.Utils;
+
+public class NumericConverter : JsonConverter
+{
+ public override decimal ReadJson(JsonReader reader, Type objectType, decimal existingValue, bool hasExistingValue, JsonSerializer serializer)
+ {
+ throw new NotImplementedException();
+ }
+
+ public override void WriteJson(JsonWriter writer, decimal value, JsonSerializer serializer)
+ {
+ writer.WriteRawValue(value.ToString(CultureInfo.InvariantCulture));
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Utils/Json/PropertyConverter.cs b/tools/config/TRX_ConfigToolLib/Utils/Json/PropertyConverter.cs
new file mode 100644
index 0000000..53b87d0
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Utils/Json/PropertyConverter.cs
@@ -0,0 +1,41 @@
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using TRX_ConfigToolLib.Models;
+
+namespace TRX_ConfigToolLib.Utils;
+
+public class PropertyConverter : JsonConverter
+{
+ private static readonly JsonSerializerSettings _resolver = new()
+ {
+ ContractResolver = new PropertyResolver()
+ };
+
+ private const string _dataTypeProperty = "DataType";
+
+ public override bool CanConvert(Type typeToConvert)
+ {
+ return typeof(BaseProperty).IsAssignableFrom(typeToConvert);
+ }
+
+ public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
+ {
+ JObject jo = JObject.Load(reader);
+ if (!jo.ContainsKey(_dataTypeProperty))
+ {
+ throw new JsonException();
+ }
+
+ return Enum.Parse(jo[_dataTypeProperty].ToString()) switch
+ {
+ DataType.Bool => JsonConvert.DeserializeObject(jo.ToString(), _resolver),
+ DataType.Enum => JsonConvert.DeserializeObject(jo.ToString(), _resolver),
+ DataType.Numeric => JsonConvert.DeserializeObject(jo.ToString(), _resolver),
+ _ => throw new JsonException(),
+ };
+ }
+
+ public override bool CanWrite => false;
+
+ public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Utils/Json/PropertyResolver.cs b/tools/config/TRX_ConfigToolLib/Utils/Json/PropertyResolver.cs
new file mode 100644
index 0000000..038caca
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Utils/Json/PropertyResolver.cs
@@ -0,0 +1,15 @@
+using Newtonsoft.Json;
+using Newtonsoft.Json.Serialization;
+using TRX_ConfigToolLib.Models;
+
+namespace TRX_ConfigToolLib.Utils;
+
+public class PropertyResolver : DefaultContractResolver
+{
+ protected override JsonConverter ResolveContractConverter(Type objectType)
+ {
+ return typeof(BaseProperty).IsAssignableFrom(objectType)
+ ? null
+ : base.ResolveContractConverter(objectType);
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Utils/JsonUtils.cs b/tools/config/TRX_ConfigToolLib/Utils/JsonUtils.cs
new file mode 100644
index 0000000..b09a4c4
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Utils/JsonUtils.cs
@@ -0,0 +1,31 @@
+using Newtonsoft.Json.Linq;
+using System.IO;
+
+namespace TRX_ConfigToolLib.Utils;
+
+public static class JsonUtils
+{
+ public static JObject LoadEmbeddedResource(string path)
+ {
+ // Try to locate the data in this assembly first, then merge it
+ // with the same in the entry assembly if relevant.
+ JObject data = null;
+
+ if (AssemblyUtils.ResourceExists(path, true))
+ {
+ using Stream stream = AssemblyUtils.GetResourceStream(path, true);
+ using StreamReader reader = new(stream);
+ data = JObject.Parse(reader.ReadToEnd());
+ }
+
+ if (AssemblyUtils.ResourceExists(path, false))
+ {
+ data ??= new();
+ using Stream stream = AssemblyUtils.GetResourceStream(path, false);
+ using StreamReader reader = new(stream);
+ data.Merge(JObject.Parse(reader.ReadToEnd()));
+ }
+
+ return data;
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Utils/MessageBoxUtils.cs b/tools/config/TRX_ConfigToolLib/Utils/MessageBoxUtils.cs
new file mode 100644
index 0000000..96b89a8
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Utils/MessageBoxUtils.cs
@@ -0,0 +1,21 @@
+using System.Windows;
+
+namespace TRX_ConfigToolLib.Utils;
+
+public static class MessageBoxUtils
+{
+ public static MessageBoxResult ShowYesNoCancel(string message, string caption)
+ {
+ return MessageBox.Show(message, caption, MessageBoxButton.YesNoCancel, MessageBoxImage.Question);
+ }
+
+ public static bool ShowYesNo(string message, string caption)
+ {
+ return MessageBox.Show(message, caption, MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes;
+ }
+
+ public static void ShowError(string message, string caption)
+ {
+ MessageBox.Show(message, caption, MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Utils/NumericValidationRule.cs b/tools/config/TRX_ConfigToolLib/Utils/NumericValidationRule.cs
new file mode 100644
index 0000000..57c115e
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Utils/NumericValidationRule.cs
@@ -0,0 +1,72 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Controls;
+
+namespace TRX_ConfigToolLib.Utils;
+
+public class NumericValidationRule : ValidationRule
+{
+ public NumericValidation ValidationRule { get; set; }
+
+ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
+ {
+ if (!decimal.TryParse(value.ToString(), out decimal val))
+ {
+ return new ValidationResult(false, ValidationRule.InvalidNumberMessage);
+ }
+
+ if (val < ValidationRule.MinValue || val > ValidationRule.MaxValue)
+ {
+ return new ValidationResult(false, string.Format(ValidationRule.ComparisonFailedMessage, ValidationRule.MinValue, ValidationRule.MaxValue));
+ }
+
+ return ValidationResult.ValidResult;
+ }
+}
+
+public class NumericValidation : DependencyObject
+{
+ public static readonly DependencyProperty MinValueProperty = DependencyProperty.RegisterAttached
+ (
+ nameof(MinValue), typeof(decimal), typeof(NumericValidation), new PropertyMetadata(0M)
+ );
+
+ public static readonly DependencyProperty MaxValueProperty = DependencyProperty.RegisterAttached
+ (
+ nameof(MaxValue), typeof(decimal), typeof(NumericValidation), new PropertyMetadata(0M)
+ );
+
+ public static readonly DependencyProperty InvalidNumberMessageProperty = DependencyProperty.RegisterAttached
+ (
+ nameof(InvalidNumberMessage), typeof(string), typeof(NumericValidation), new PropertyMetadata(string.Empty)
+ );
+
+ public static readonly DependencyProperty ComparisonFailedMessageProperty = DependencyProperty.RegisterAttached
+ (
+ nameof(ComparisonFailedMessage), typeof(string), typeof(NumericValidation), new PropertyMetadata(string.Empty)
+ );
+
+ public decimal MinValue
+ {
+ get => (decimal)GetValue(MinValueProperty);
+ set => SetValue(MinValueProperty, value);
+ }
+
+ public decimal MaxValue
+ {
+ get => (decimal)GetValue(MaxValueProperty);
+ set => SetValue(MaxValueProperty, value);
+ }
+
+ public string InvalidNumberMessage
+ {
+ get => (string)GetValue(InvalidNumberMessageProperty);
+ set => SetValue(MaxValueProperty, value);
+ }
+
+ public string ComparisonFailedMessage
+ {
+ get => (string)GetValue(ComparisonFailedMessageProperty);
+ set => SetValue(ComparisonFailedMessageProperty, value);
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Utils/ProcessUtils.cs b/tools/config/TRX_ConfigToolLib/Utils/ProcessUtils.cs
new file mode 100644
index 0000000..b59410e
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Utils/ProcessUtils.cs
@@ -0,0 +1,20 @@
+using System.Diagnostics;
+using System.IO;
+
+namespace TRX_ConfigToolLib.Utils;
+
+public static class ProcessUtils
+{
+ public static void Start(string fileName, string arguments = null)
+ {
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ UseShellExecute = true,
+ WorkingDirectory = new Uri(fileName).IsFile
+ ? Path.GetDirectoryName(fileName)
+ : null
+ });
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Utils/RelayCommand.cs b/tools/config/TRX_ConfigToolLib/Utils/RelayCommand.cs
new file mode 100644
index 0000000..ee2cb86
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Utils/RelayCommand.cs
@@ -0,0 +1,125 @@
+using System.Windows;
+using System.Windows.Input;
+
+namespace TRX_ConfigToolLib.Utils;
+
+public class RelayCommand : ICommand
+{
+ public RelayCommand(Action execute, Func canExecute)
+ {
+ _execute = execute;
+ _canExecute = canExecute;
+ }
+
+ public RelayCommand(Action execute)
+ {
+ _execute = execute;
+ _canExecute = null;
+ }
+
+ public event EventHandler CanExecuteChanged
+ {
+ add
+ {
+ CommandManager.RequerySuggested += value;
+ _canExecuteChanged += value;
+ }
+ remove
+ {
+ CommandManager.RequerySuggested -= value;
+ _canExecuteChanged -= value;
+ }
+ }
+
+ public bool CanExecute(object parameter)
+ {
+ return _canExecute == null || _canExecute();
+ }
+
+ public void Execute(object parameter)
+ {
+ _execute();
+ }
+
+ public void RaiseCanExecuteChanged()
+ {
+ _canExecuteChanged.Invoke(this, EventArgs.Empty);
+ }
+
+ private readonly Func _canExecute;
+ private readonly Action _execute;
+
+ private EventHandler _canExecuteChanged;
+}
+
+public class RelayCommand : ICommand
+{
+ public RelayCommand(Action execute, Func canExecute)
+ {
+ _execute = execute;
+ _canExecute = canExecute;
+ }
+
+ public RelayCommand(Action execute)
+ {
+ _execute = execute;
+ _canExecute = null;
+ }
+
+ public event EventHandler CanExecuteChanged
+ {
+ add
+ {
+ CommandManager.RequerySuggested += value;
+ _canExecuteChanged += value;
+ }
+ remove
+ {
+ CommandManager.RequerySuggested -= value;
+ _canExecuteChanged -= value;
+ }
+ }
+
+ public bool CanExecute(object parameter)
+ {
+ return _canExecute == null || _canExecute((T)parameter);
+ }
+
+ public void Execute(object parameter)
+ {
+ _execute((T)parameter);
+ }
+
+ public void RaiseCanExecuteChanged()
+ {
+ _canExecuteChanged.Invoke(this, EventArgs.Empty);
+ }
+
+ private readonly Func _canExecute;
+ private readonly Action _execute;
+
+ private EventHandler _canExecuteChanged;
+}
+
+public class RelayKeyBinding : KeyBinding
+{
+ public static readonly DependencyProperty CommandBindingProperty = DependencyProperty.Register
+ (
+ nameof(CommandBinding),
+ typeof(ICommand),
+ typeof(RelayKeyBinding),
+ new FrameworkPropertyMetadata(OnCommandBindingChanged)
+ );
+
+ public ICommand CommandBinding
+ {
+ get { return (ICommand)GetValue(CommandBindingProperty); }
+ set { SetValue(CommandBindingProperty, value); }
+ }
+
+ private static void OnCommandBindingChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ RelayKeyBinding keyBinding = (RelayKeyBinding)d;
+ keyBinding.Command = (ICommand)e.NewValue;
+ }
+}
diff --git a/tools/config/TRX_ConfigToolLib/Utils/VisualUtils.cs b/tools/config/TRX_ConfigToolLib/Utils/VisualUtils.cs
new file mode 100644
index 0000000..7496043
--- /dev/null
+++ b/tools/config/TRX_ConfigToolLib/Utils/VisualUtils.cs
@@ -0,0 +1,28 @@
+using System.Windows;
+using System.Windows.Media;
+
+namespace TRX_ConfigToolLib.Utils;
+
+public static class VisualUtils
+{
+ public static Visual GetChild(DependencyObject root, Type type)
+ {
+ for (int i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
+ {
+ Visual child = (Visual)VisualTreeHelper.GetChild(root, i);
+
+ if (child.GetType() == type)
+ {
+ return child;
+ }
+
+ Visual grandChild = GetChild(child, type);
+ if (grandChild != null)
+ {
+ return grandChild;
+ }
+ }
+
+ return null;
+ }
+}