using Microsoft.UI.Xaml.Media;
using SpaceEngineersBlueprintEditor.Utilities.Localization;
using System.Collections;
using System.Globalization;
using XFEExtension.NetCore.WinUIHelper.Interface.Services;
using XFEExtension.NetCore.WinUIHelper.Utilities;
using XFEExtension.NetCore.XFETransform;
namespace SpaceEngineersBlueprintEditor.Model;
/// <summary>
/// A property node used by both blueprint property trees.
/// The original member name, translated display name and value setter are kept separate
/// so presentation never interferes with reflection or serialization.
/// </summary>
public partial class BlueprintPropertyViewData
{
public object? Value { get; set; }
public string ValueString => FormatValue(Value);
public object? CustomData { get; set; }
public Type? Type { get; set; }
/// <summary>The exact CLR field/property name. Never translate this value.</summary>
public string? Name { get; set; }
/// <summary>An optional label for collection entries and definition objects.</summary>
public string? DisplayNameOverride { get; set; }
public string DisplayName =>
DisplayNameOverride ?? BlueprintPropertyNameLocalizer.GetDisplayName(Name);
public string FriendlyTypeName => GetFriendlyTypeName(Type);
public string NameTypeString => string.IsNullOrEmpty(FriendlyTypeName)
? DisplayName
: $"{DisplayName} · {FriendlyTypeName}";
public string[] EnumValues => EffectiveType?.IsEnum == true
? Enum.GetNames(EffectiveType)
: [];
public BlueprintPropertyViewData? Parent { get; set; }
public ImageSource? CubeImage { get; set; }
/// <summary>
/// The setter created while reflecting this member. It deliberately does not rely on
/// <see cref="Name"/>, because collection entries and translated labels are not CLR members.
/// </summary>
internal Action<object?>? ValueSetter { get; set; }
internal Action? ValueChanged { get; set; }
public bool CanWrite => ValueSetter is not null;
public bool HasError { get; set; }
public string? ErrorMessage { get; set; }
public bool IsAnalyzed { get; set; }
public List<BlueprintPropertyViewData> Children { get; } = [];
public Type? EffectiveType => Type is null ? null : Nullable.GetUnderlyingType(Type) ?? Type;
public bool IsNullable => Type is not null &&
(!Type.IsValueType || Nullable.GetUnderlyingType(Type) is not null);
public bool IsEnumerable => Type is not null &&
Type != typeof(string) &&
typeof(IEnumerable).IsAssignableFrom(Type);
public bool IsMultiEnum => EffectiveType?.IsEnum == true &&
EffectiveType.IsDefined(typeof(FlagsAttribute), false);
public bool IsBasicType
{
get
{
if (EffectiveType is not { } type)
return false;
return XFEConverter.IsBasicType(type) ||
type.IsEnum ||
type == typeof(decimal) ||
type == typeof(DateTime) ||
type == typeof(DateTimeOffset) ||
type == typeof(TimeSpan) ||
type == typeof(Guid);
}
}
public bool CanExpand => !IsBasicType && Type is not null && Value is not null && !HasError;
public object? BestControl
{
get
{
if (!IsBasicType)
return null;
if (!CanWrite)
return ReadOnlyControl;
if (IsMultiEnum)
return MultiEnumControl;
if (EffectiveType?.IsEnum == true)
return EnumControl;
if (EffectiveType == typeof(bool))
return BoolControl;
return TextControl;
}
}
public TextBlock ReadOnlyControl
{
get
{
var text = HasError ? ErrorMessage : ValueString;
var textBlock = new TextBlock
{
Text = text,
TextTrimming = TextTrimming.CharacterEllipsis,
IsTextSelectionEnabled = true,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Stretch
};
if (HasError)
textBlock.Foreground = new SolidColorBrush(Microsoft.UI.Colors.OrangeRed);
ToolTipService.SetToolTip(textBlock, text);
return textBlock;
}
}
public TextBox TextControl
{
get
{
var textBox = new TextBox
{
Text = ValueString,
HorizontalAlignment = HorizontalAlignment.Stretch,
MinWidth = 140
};
ToolTipService.SetToolTip(textBox, ValueString);
textBox.LostFocus += (_, _) => CommitText(textBox);
return textBox;
}
}
public ComboBox EnumControl
{
get
{
var values = IsNullable
? new[] { string.Empty }.Concat(EnumValues).ToArray()
: EnumValues;
var comboBox = new ComboBox
{
ItemsSource = values,
SelectedItem = Value?.ToString() ?? string.Empty,
HorizontalAlignment = HorizontalAlignment.Stretch,
MinWidth = 140
};
comboBox.SelectionChanged += (_, args) =>
{
if (args.AddedItems.FirstOrDefault() is not string value || EffectiveType is null)
return;
if (string.IsNullOrEmpty(value) && IsNullable)
SetValue(null);
else
SetValue(Enum.Parse(EffectiveType, value, true));
};
return comboBox;
}
}
public SplitButton MultiEnumControl
{
get
{
var enumType = EffectiveType!;
var currentBits = ToUInt64(Value);
var stackPanel = new StackPanel { Spacing = 4 };
foreach (var enumValue in Enum.GetValues(enumType).Cast<object>()
.Where(IsSingleFlagValue))
{
var bits = ToUInt64(enumValue);
stackPanel.Children.Add(new CheckBox
{
Content = enumValue.ToString(),
Tag = bits,
IsChecked = bits == 0 ? currentBits == 0 : (currentBits & bits) == bits
});
}
var splitButton = new SplitButton
{
Content = ValueString,
HorizontalAlignment = HorizontalAlignment.Stretch,
MinWidth = 140,
Flyout = new Flyout { Content = stackPanel }
};
splitButton.Flyout.Closed += (_, _) =>
{
ulong selectedBits = 0;
foreach (var checkBox in stackPanel.Children.OfType<CheckBox>())
{
if (checkBox.IsChecked == true && checkBox.Tag is ulong bits && bits != 0)
selectedBits |= bits;
}
var targetValue = Enum.ToObject(enumType, selectedBits);
if (SetValue(targetValue))
splitButton.Content = targetValue.ToString();
};
return splitButton;
}
}
public ComboBox BoolControl
{
get
{
var values = IsNullable
? new[] { string.Empty, bool.TrueString, bool.FalseString }
: new[] { bool.TrueString, bool.FalseString };
var comboBox = new ComboBox
{
ItemsSource = values,
SelectedItem = Value?.ToString() ?? string.Empty,
HorizontalAlignment = HorizontalAlignment.Stretch,
MinWidth = 140
};
comboBox.SelectionChanged += (_, args) =>
{
if (args.AddedItems.FirstOrDefault() is not string value)
return;
if (string.IsNullOrEmpty(value) && IsNullable)
SetValue(null);
else if (bool.TryParse(value, out var parsed))
SetValue(parsed);
};
return comboBox;
}
}
public bool MatchesSearch(string? searchText)
{
if (string.IsNullOrWhiteSpace(searchText))
return true;
var searchableText = string.Join(
'\n',
Name,
DisplayName,
BlueprintPropertyNameLocalizer.GetChineseName(Name),
FriendlyTypeName,
ValueString,
CustomData as string,
ErrorMessage);
return searchText
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.All(term => searchableText.Contains(term, StringComparison.OrdinalIgnoreCase));
}
public bool SetValue(object? targetValue)
{
if (ValueSetter is null)
return false;
try
{
ValueSetter(targetValue);
Value = targetValue;
Parent?.PropagateBoxedValueType();
ValueChanged?.Invoke();
return true;
}
catch (Exception ex)
{
ShowSetValueError(targetValue, ex.Message);
return false;
}
}
private void CommitText(TextBox textBox)
{
if (textBox.Text == ValueString)
return;
var enteredText = textBox.Text;
if (TryConvertText(enteredText, out var targetValue, out var error))
{
if (SetValue(targetValue))
ToolTipService.SetToolTip(textBox, ValueString);
else
textBox.Text = ValueString;
}
else
{
textBox.Text = ValueString;
ShowSetValueError(enteredText, error);
}
}
private bool TryConvertText(string text, out object? value, out string error)
{
value = null;
error = string.Empty;
if (EffectiveType is not { } type)
{
error = "Unknown property type.";
return false;
}
if (type == typeof(string))
{
value = text;
return true;
}
if (string.IsNullOrWhiteSpace(text) && IsNullable)
return true;
try
{
if (type == typeof(char))
{
if (text.Length != 1)
throw new FormatException("A character value must contain exactly one character.");
value = text[0];
}
else if (type == typeof(Guid))
{
value = Guid.Parse(text);
}
else if (type == typeof(TimeSpan))
{
value = TimeSpan.Parse(text, CultureInfo.InvariantCulture);
}
else if (type == typeof(DateTime))
{
value = DateTime.Parse(text, CultureInfo.CurrentCulture, DateTimeStyles.RoundtripKind);
}
else if (type == typeof(DateTimeOffset))
{
value = DateTimeOffset.Parse(text, CultureInfo.CurrentCulture, DateTimeStyles.RoundtripKind);
}
else if (type.IsEnum)
{
value = Enum.Parse(type, text, true);
}
else
{
// Convert directly to the target type. In particular, never route Int64,
// UInt64 or Decimal through double, which would silently lose blueprint IDs.
try
{
value = Convert.ChangeType(text, type, CultureInfo.InvariantCulture);
}
catch (FormatException)
{
value = Convert.ChangeType(text, type, CultureInfo.CurrentCulture);
}
}
return true;
}
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException or ArgumentException)
{
error = ex.Message;
return false;
}
}
private void PropagateBoxedValueType()
{
if (Type?.IsValueType != true || ValueSetter is null)
return;
ValueSetter(Value);
Parent?.PropagateBoxedValueType();
}
private void ShowSetValueError(object? targetValue, string detail)
{
ServiceManager.GetGlobalService<IMessageService>()?.ShowMessage(
$"{"Error_CantSetValue_CantSetProperty".GetLocalized()}{Name}({FriendlyTypeName})" +
$"{"Error_CantSetValue_ValueTo".GetLocalized()}{targetValue}: {detail}",
"Error".GetLocalized(),
InfoBarSeverity.Error);
}
private static bool IsSingleFlagValue(object value)
{
var bits = ToUInt64(value);
return bits == 0 || (bits & (bits - 1)) == 0;
}
private static ulong ToUInt64(object? value)
{
if (value is null)
return 0;
try
{
return Convert.ToUInt64(value, CultureInfo.InvariantCulture);
}
catch (OverflowException)
{
return unchecked((ulong)Convert.ToInt64(value, CultureInfo.InvariantCulture));
}
}
private static string FormatValue(object? value) => value switch
{
null => string.Empty,
DateTime dateTime => dateTime.ToString("O", CultureInfo.InvariantCulture),
DateTimeOffset dateTimeOffset => dateTimeOffset.ToString("O", CultureInfo.InvariantCulture),
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty,
_ => value.ToString() ?? string.Empty
};
private static string GetFriendlyTypeName(Type? type)
{
if (type is null)
return string.Empty;
var nullableType = Nullable.GetUnderlyingType(type);
if (nullableType is not null)
return $"{GetFriendlyTypeName(nullableType)}?";
if (!type.IsGenericType)
return type.Name;
var genericName = type.Name.Split('`')[0];
return $"{genericName}<{string.Join(", ", type.GetGenericArguments().Select(GetFriendlyTypeName))}>";
}
}
using Microsoft.UI.Xaml.Media;
using SpaceEngineersBlueprintEditor.Utilities.Localization;
using System.Collections;
using System.Globalization;
using XFEExtension.NetCore.WinUIHelper.Interface.Services;
using XFEExtension.NetCore.WinUIHelper.Utilities;
using XFEExtension.NetCore.XFETransform;
namespace SpaceEngineersBlueprintEditor.Model;
/// <summary>
/// A property node used by both blueprint property trees.
/// The original member name, translated display name and value setter are kept separate
/// so presentation never interferes with reflection or serialization.
/// </summary>
public partial class BlueprintPropertyViewData
{
public object? Value { get; set; }
public string ValueString => FormatValue(Value);
public object? CustomData { get; set; }
public Type? Type { get; set; }
/// <summary>The exact CLR field/property name. Never translate this value.</summary>
public string? Name { get; set; }
/// <summary>An optional label for collection entries and definition objects.</summary>
public string? DisplayNameOverride { get; set; }
public string DisplayName =>
DisplayNameOverride ?? BlueprintPropertyNameLocalizer.GetDisplayName(Name);
public string FriendlyTypeName => GetFriendlyTypeName(Type);
public string NameTypeString => string.IsNullOrEmpty(FriendlyTypeName)
? DisplayName
: $"{DisplayName} · {FriendlyTypeName}";
public string[] EnumValues => EffectiveType?.IsEnum == true
? Enum.GetNames(EffectiveType)
: [];
public BlueprintPropertyViewData? Parent { get; set; }
public ImageSource? CubeImage { get; set; }
/// <summary>
/// The setter created while reflecting this member. It deliberately does not rely on
/// <see cref="Name"/>, because collection entries and translated labels are not CLR members.
/// </summary>
internal Action<object?>? ValueSetter { get; set; }
internal Action? ValueChanged { get; set; }
public bool CanWrite => ValueSetter is not null;
public bool HasError { get; set; }
public string? ErrorMessage { get; set; }
public bool IsAnalyzed { get; set; }
public List<BlueprintPropertyViewData> Children { get; } = [];
public Type? EffectiveType => Type is null ? null : Nullable.GetUnderlyingType(Type) ?? Type;
public bool IsNullable => Type is not null &&
(!Type.IsValueType || Nullable.GetUnderlyingType(Type) is not null);
public bool IsEnumerable => Type is not null &&
Type != typeof(string) &&
typeof(IEnumerable).IsAssignableFrom(Type);
public bool IsMultiEnum => EffectiveType?.IsEnum == true &&
EffectiveType.IsDefined(typeof(FlagsAttribute), false);
public bool IsBasicType
{
get
{
if (EffectiveType is not { } type)
return false;
return XFEConverter.IsBasicType(type) ||
type.IsEnum ||
type == typeof(decimal) ||
type == typeof(DateTime) ||
type == typeof(DateTimeOffset) ||
type == typeof(TimeSpan) ||
type == typeof(Guid);
}
}
public bool CanExpand => !IsBasicType && Type is not null && Value is not null && !HasError;
public object? BestControl
{
get
{
if (!IsBasicType)
return null;
if (!CanWrite)
return ReadOnlyControl;
if (IsMultiEnum)
return MultiEnumControl;
if (EffectiveType?.IsEnum == true)
return EnumControl;
if (EffectiveType == typeof(bool))
return BoolControl;
return TextControl;
}
}
public TextBlock ReadOnlyControl
{
get
{
var text = HasError ? ErrorMessage : ValueString;
var textBlock = new TextBlock
{
Text = text,
TextTrimming = TextTrimming.CharacterEllipsis,
IsTextSelectionEnabled = true,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Stretch
};
if (HasError)
textBlock.Foreground = new SolidColorBrush(Microsoft.UI.Colors.OrangeRed);
ToolTipService.SetToolTip(textBlock, text);
return textBlock;
}
}
public TextBox TextControl
{
get
{
var textBox = new TextBox
{
Text = ValueString,
HorizontalAlignment = HorizontalAlignment.Stretch,
MinWidth = 140
};
ToolTipService.SetToolTip(textBox, ValueString);
textBox.LostFocus += (_, _) => CommitText(textBox);
return textBox;
}
}
public ComboBox EnumControl
{
get
{
var values = IsNullable
? new[] { string.Empty }.Concat(EnumValues).ToArray()
: EnumValues;
var comboBox = new ComboBox
{
ItemsSource = values,
SelectedItem = Value?.ToString() ?? string.Empty,
HorizontalAlignment = HorizontalAlignment.Stretch,
MinWidth = 140
};
comboBox.SelectionChanged += (_, args) =>
{
if (args.AddedItems.FirstOrDefault() is not string value || EffectiveType is null)
return;
if (string.IsNullOrEmpty(value) && IsNullable)
SetValue(null);
else
SetValue(Enum.Parse(EffectiveType, value, true));
};
return comboBox;
}
}
public SplitButton MultiEnumControl
{
get
{
var enumType = EffectiveType!;
var currentBits = ToUInt64(Value);
var stackPanel = new StackPanel { Spacing = 4 };
foreach (var enumValue in Enum.GetValues(enumType).Cast<object>()
.Where(IsSingleFlagValue))
{
var bits = ToUInt64(enumValue);
stackPanel.Children.Add(new CheckBox
{
Content = enumValue.ToString(),
Tag = bits,
IsChecked = bits == 0 ? currentBits == 0 : (currentBits & bits) == bits
});
}
var splitButton = new SplitButton
{
Content = ValueString,
HorizontalAlignment = HorizontalAlignment.Stretch,
MinWidth = 140,
Flyout = new Flyout { Content = stackPanel }
};
splitButton.Flyout.Closed += (_, _) =>
{
ulong selectedBits = 0;
foreach (var checkBox in stackPanel.Children.OfType<CheckBox>())
{
if (checkBox.IsChecked == true && checkBox.Tag is ulong bits && bits != 0)
selectedBits |= bits;
}
var targetValue = Enum.ToObject(enumType, selectedBits);
if (SetValue(targetValue))
splitButton.Content = targetValue.ToString();
};
return splitButton;
}
}
public ComboBox BoolControl
{
get
{
var values = IsNullable
? new[] { string.Empty, bool.TrueString, bool.FalseString }
: new[] { bool.TrueString, bool.FalseString };
var comboBox = new ComboBox
{
ItemsSource = values,
SelectedItem = Value?.ToString() ?? string.Empty,
HorizontalAlignment = HorizontalAlignment.Stretch,
MinWidth = 140
};
comboBox.SelectionChanged += (_, args) =>
{
if (args.AddedItems.FirstOrDefault() is not string value)
return;
if (string.IsNullOrEmpty(value) && IsNullable)
SetValue(null);
else if (bool.TryParse(value, out var parsed))
SetValue(parsed);
};
return comboBox;
}
}
public bool MatchesSearch(string? searchText)
{
if (string.IsNullOrWhiteSpace(searchText))
return true;
var searchableText = string.Join(
'\n',
Name,
DisplayName,
BlueprintPropertyNameLocalizer.GetChineseName(Name),
FriendlyTypeName,
ValueString,
CustomData as string,
ErrorMessage);
return searchText
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.All(term => searchableText.Contains(term, StringComparison.OrdinalIgnoreCase));
}
public bool SetValue(object? targetValue)
{
if (ValueSetter is null)
return false;
try
{
ValueSetter(targetValue);
Value = targetValue;
Parent?.PropagateBoxedValueType();
ValueChanged?.Invoke();
return true;
}
catch (Exception ex)
{
ShowSetValueError(targetValue, ex.Message);
return false;
}
}
private void CommitText(TextBox textBox)
{
if (textBox.Text == ValueString)
return;
var enteredText = textBox.Text;
if (TryConvertText(enteredText, out var targetValue, out var error))
{
if (SetValue(targetValue))
ToolTipService.SetToolTip(textBox, ValueString);
else
textBox.Text = ValueString;
}
else
{
textBox.Text = ValueString;
ShowSetValueError(enteredText, error);
}
}
private bool TryConvertText(string text, out object? value, out string error)
{
value = null;
error = string.Empty;
if (EffectiveType is not { } type)
{
error = "Unknown property type.";
return false;
}
if (type == typeof(string))
{
value = text;
return true;
}
if (string.IsNullOrWhiteSpace(text) && IsNullable)
return true;
try
{
if (type == typeof(char))
{
if (text.Length != 1)
throw new FormatException("A character value must contain exactly one character.");
value = text[0];
}
else if (type == typeof(Guid))
{
value = Guid.Parse(text);
}
else if (type == typeof(TimeSpan))
{
value = TimeSpan.Parse(text, CultureInfo.InvariantCulture);
}
else if (type == typeof(DateTime))
{
value = DateTime.Parse(text, CultureInfo.CurrentCulture, DateTimeStyles.RoundtripKind);
}
else if (type == typeof(DateTimeOffset))
{
value = DateTimeOffset.Parse(text, CultureInfo.CurrentCulture, DateTimeStyles.RoundtripKind);
}
else if (type.IsEnum)
{
value = Enum.Parse(type, text, true);
}
else
{
// Convert directly to the target type. In particular, never route Int64,
// UInt64 or Decimal through double, which would silently lose blueprint IDs.
try
{
value = Convert.ChangeType(text, type, CultureInfo.InvariantCulture);
}
catch (FormatException)
{
value = Convert.ChangeType(text, type, CultureInfo.CurrentCulture);
}
}
return true;
}
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException or ArgumentException)
{
error = ex.Message;
return false;
}
}
private void PropagateBoxedValueType()
{
if (Type?.IsValueType != true || ValueSetter is null)
return;
ValueSetter(Value);
Parent?.PropagateBoxedValueType();
}
private void ShowSetValueError(object? targetValue, string detail)
{
ServiceManager.GetGlobalService<IMessageService>()?.ShowMessage(
$"{"Error_CantSetValue_CantSetProperty".GetLocalized()}{Name}({FriendlyTypeName})" +
$"{"Error_CantSetValue_ValueTo".GetLocalized()}{targetValue}: {detail}",
"Error".GetLocalized(),
InfoBarSeverity.Error);
}
private static bool IsSingleFlagValue(object value)
{
var bits = ToUInt64(value);
return bits == 0 || (bits & (bits - 1)) == 0;
}
private static ulong ToUInt64(object? value)
{
if (value is null)
return 0;
try
{
return Convert.ToUInt64(value, CultureInfo.InvariantCulture);
}
catch (OverflowException)
{
return unchecked((ulong)Convert.ToInt64(value, CultureInfo.InvariantCulture));
}
}
private static string FormatValue(object? value) => value switch
{
null => string.Empty,
DateTime dateTime => dateTime.ToString("O", CultureInfo.InvariantCulture),
DateTimeOffset dateTimeOffset => dateTimeOffset.ToString("O", CultureInfo.InvariantCulture),
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty,
_ => value.ToString() ?? string.Empty
};
private static string GetFriendlyTypeName(Type? type)
{
if (type is null)
return string.Empty;
var nullableType = Nullable.GetUnderlyingType(type);
if (nullableType is not null)
return $"{GetFriendlyTypeName(nullableType)}?";
if (!type.IsGenericType)
return type.Name;
var genericName = type.Name.Split('`')[0];
return $"{genericName}<{string.Join(", ", type.GetGenericArguments().Select(GetFriendlyTypeName))}>";
}
}