From 3776151dd7f79662b4881be40208b14240c02eaa Mon Sep 17 00:00:00 2001 From: pr8x Date: Thu, 16 Feb 2023 16:10:22 +0100 Subject: [PATCH 01/11] Dedicated propert editors for DevTools --- .../Diagnostics/Controls/BrushEditor.cs | 90 +++++ .../Controls/PropertyValueEditor.cs | 325 ++++++++++++++++++ .../ViewModels/AvaloniaPropertyViewModel.cs | 8 +- .../ViewModels/ClrPropertyViewModel.cs | 8 +- .../ViewModels/PropertyViewModel.cs | 67 +--- .../Diagnostics/Views/ControlDetailsView.xaml | 9 +- .../Diagnostics/Views/MainWindow.xaml | 1 + 7 files changed, 437 insertions(+), 71 deletions(-) create mode 100644 src/Avalonia.Diagnostics/Diagnostics/Controls/BrushEditor.cs create mode 100644 src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditor.cs diff --git a/src/Avalonia.Diagnostics/Diagnostics/Controls/BrushEditor.cs b/src/Avalonia.Diagnostics/Diagnostics/Controls/BrushEditor.cs new file mode 100644 index 0000000000..b7579ed31b --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/Controls/BrushEditor.cs @@ -0,0 +1,90 @@ +using System.Globalization; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Media.Immutable; + +namespace Avalonia.Diagnostics.Controls +{ + internal sealed class BrushEditor : Control + { + /// + /// Defines the property. + /// + public static readonly DirectProperty BrushProperty = + AvaloniaProperty.RegisterDirect( + nameof(Brush), o => o.Brush, (o, v) => o.Brush = v); + + private IBrush? _brush; + + public IBrush? Brush + { + get => _brush; + set => SetAndRaise(BrushProperty, ref _brush, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == BrushProperty) + { + switch (Brush) + { + case ISolidColorBrush scb: + { + var colorView = new ColorView { Color = scb.Color }; + + colorView.ColorChanged += (_, e) => Brush = new ImmutableSolidColorBrush(e.NewColor); + + FlyoutBase.SetAttachedFlyout(this, new Flyout { Content = colorView }); + ToolTip.SetTip(this, $"{scb.Color} ({Brush.GetType().Name})"); + + break; + } + + default: + + FlyoutBase.SetAttachedFlyout(this, null); + ToolTip.SetTip(this, Brush?.GetType().Name ?? "(null)"); + + break; + } + + InvalidateVisual(); + } + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + FlyoutBase.ShowAttachedFlyout(this); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + if (Brush != null) + { + context.FillRectangle(Brush, Bounds); + } + else + { + context.FillRectangle(Brushes.Black, Bounds); + + var ft = new FormattedText("(null)", + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + Typeface.Default, + 10, + Brushes.White); + + context.DrawText(ft, + new Point(Bounds.Width / 2 - ft.Width / 2, Bounds.Height / 2 - ft.Height / 2)); + } + } + } +} diff --git a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditor.cs b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditor.cs new file mode 100644 index 0000000000..74a54dd702 --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditor.cs @@ -0,0 +1,325 @@ +using System; +using System.ComponentModel; +using System.Globalization; +using System.Reflection; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Reactive; + +namespace Avalonia.Diagnostics.Controls +{ + internal class PropertyValueEditor : Decorator + { + /// + /// Defines the property. + /// + public static readonly DirectProperty ValueProperty = + AvaloniaProperty.RegisterDirect( + nameof(Value), o => o.Value, (o, v) => o.Value = v); + + /// + /// Defines the property. + /// + public static readonly DirectProperty ValueTypeProperty = + AvaloniaProperty.RegisterDirect( + nameof(ValueType), o => o.ValueType, (o, v) => o.ValueType = v); + + /// + /// Defines the property. + /// + public static readonly DirectProperty IsReadonlyProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsReadonly), o => o.IsReadonly, (o, v) => o.IsReadonly = v); + + private readonly CompositeDisposable _cleanup = new(); + + private bool _isReadonly; + private bool _needsUpdate; + private object? _value; + private Type? _valueType; + + public bool IsReadonly + { + get => _isReadonly; + set => SetAndRaise(IsReadonlyProperty, ref _isReadonly, value); + } + + public object? Value + { + get => _value; + set => SetAndRaise(ValueProperty, ref _value, value); + } + + public Type? ValueType + { + get => _valueType; + set => SetAndRaise(ValueTypeProperty, ref _valueType, value); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + + _cleanup.Clear(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ValueTypeProperty) + { + _cleanup.Clear(); + + _needsUpdate = true; + } + + if (change.Property == ValueProperty && _needsUpdate) + { + _needsUpdate = false; + + Child = UpdateControl(); + } + } + + //Unfortunately we cannot use TwoWay bindings as they update the source with the target value + //This causes the source property value to be overwritten. Ideally we there would be some kind of + //"InitialBindingDirection" or something to control whether the first value is from source or target. + private static void TwoWayBindingFromSource( + AvaloniaObject source, + AvaloniaProperty sourceProperty, + AvaloniaObject target, + AvaloniaProperty targetProperty, + IValueConverter? converter, + Type targetType, + CompositeDisposable disposable) + { + bool isUpdating = false; + + source + .GetObservable(sourceProperty) + .Subscribe(value => + { + if (isUpdating) return; + + try + { + isUpdating = true; + + target[targetProperty] = converter != null ? + converter.Convert(value, typeof(object), null, CultureInfo.CurrentCulture) : + value; + } + finally + { + isUpdating = false; + } + }) + .DisposeWith(disposable); + + target + .GetObservable(targetProperty) + .Skip(1) + .Subscribe(value => + { + if (isUpdating) return; + + try + { + isUpdating = true; + + source[sourceProperty] = converter != null ? + converter.ConvertBack(value, targetType, null, CultureInfo.CurrentCulture) : + value; + } + finally + { + isUpdating = false; + } + }) + .DisposeWith(disposable); + } + + private Control? UpdateControl() + { + if (ValueType is null) return null; + + TControl CreateControl(AvaloniaProperty valueProperty, + IValueConverter? converter = null, + Action? init = null) + where TControl : Control, new() + { + var control = new TControl(); + + init?.Invoke(control); + + TwoWayBindingFromSource( + this, + ValueProperty, + control, + valueProperty, + converter, + ValueType, + _cleanup); + + control.Bind( + IsEnabledProperty, + new Binding(nameof(IsReadonly)) { Source = this, Converter = BoolConverters.Not }) + .DisposeWith(_cleanup); + + return control; + } + + bool isObjectType = ValueType == typeof(object); + + if (ValueType == typeof(bool)) + return CreateControl(ToggleButton.IsCheckedProperty); + + //TODO: Infinity, NaN not working with NumericUpDown + //if (ValueType.IsPrimitive) + // return CreateControl(NumericUpDown.ValueProperty, new ValueToDecimalConverter()); + + if (ValueType == typeof(Color)) + return CreateControl(ColorView.ColorProperty); + + if (!isObjectType && ValueType.IsAssignableFrom(typeof(IBrush))) + return CreateControl(BrushEditor.BrushProperty); + + if (!isObjectType && ValueType.IsAssignableFrom(typeof(IImage))) + return CreateControl(Image.SourceProperty, init: img => + { + img.Stretch = Stretch.Uniform; + img.HorizontalAlignment = HorizontalAlignment.Stretch; + + img.PointerPressed += (_, _) => + new Window + { + Content = new Image + { + Source = img.Source + } + }.Show(); + }); + + if (ValueType.IsEnum) + return CreateControl( + SelectingItemsControl.SelectedItemProperty, init: c => + { + c.Items = Enum.GetValues(ValueType); + }); + + var tb = CreateControl( + TextBox.TextProperty, + new TextToValueConverter(), + t => + { + t.Watermark = "(null)"; + }); + + tb.IsEnabled &= !isObjectType && + StringConversionHelper.CanConvertFromString(ValueType); + + return tb; + } + + private static class StringConversionHelper + { + private const BindingFlags PublicStatic = BindingFlags.Public | BindingFlags.Static; + private static readonly Type[] StringParameter = { typeof(string) }; + + private static readonly Type[] + StringFormatProviderParameters = { typeof(string), typeof(IFormatProvider) }; + + public static bool CanConvertFromString(Type type) + { + var converter = TypeDescriptor.GetConverter(type); + + if (converter.CanConvertFrom(typeof(string))) return true; + + return GetParseMethod(type, out _) != null; + } + + public static MethodInfo? GetParseMethod(Type type, out bool hasFormat) + { + var m = type.GetMethod("Parse", PublicStatic, null, StringFormatProviderParameters, null); + + if (m != null) + { + hasFormat = true; + + return m; + } + + hasFormat = false; + + return type.GetMethod("Parse", PublicStatic, null, StringParameter, null); + } + } + + private sealed class ValueToDecimalConverter : IValueConverter + { + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return System.Convert.ToDecimal(value); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return System.Convert.ChangeType(value, targetType); + } + } + + private sealed class TextToValueConverter : IValueConverter + { + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is null) + return null; + + var converter = TypeDescriptor.GetConverter(value); + + //CollectionConverter does not deliver any important information. It just displays "(Collection)". + if (!converter.CanConvertTo(typeof(string)) || + converter.GetType() == typeof(CollectionConverter)) + return value.ToString(); + + return converter.ConvertToString(value); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not string s) + return null; + + try + { + var converter = TypeDescriptor.GetConverter(targetType); + + return converter.CanConvertFrom(typeof(string)) ? + converter.ConvertFrom(null, CultureInfo.InvariantCulture, s) : + InvokeParse(s, targetType); + } + catch + { + return BindingOperations.DoNothing; + } + } + + private static object? InvokeParse(string s, Type targetType) + { + var m = StringConversionHelper.GetParseMethod(targetType, out bool hasFormat); + + if (m == null) throw new InvalidOperationException(); + + return m.Invoke(null, + hasFormat ? + new object[] { s, CultureInfo.InvariantCulture } : + new object[] { s }); + } + } + } +} diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/AvaloniaPropertyViewModel.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/AvaloniaPropertyViewModel.cs index 0e412a2fa5..2412ea5325 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/AvaloniaPropertyViewModel.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/AvaloniaPropertyViewModel.cs @@ -35,15 +35,14 @@ namespace Avalonia.Diagnostics.ViewModels public override string Priority => _priority; public override Type AssignedType => _assignedType; - public override string? Value + public override object? Value { - get => ConvertToString(_value); + get => _value; set { try { - var convertedValue = ConvertFromString(value, Property.PropertyType); - _target.SetValue(Property, convertedValue); + _target.SetValue(Property, value); Update(); } catch { } @@ -54,6 +53,7 @@ namespace Avalonia.Diagnostics.ViewModels public override Type? DeclaringType { get; } public override Type PropertyType => _propertyType; + public override bool IsReadonly => Property.IsReadOnly; // [MemberNotNull(nameof(_type), nameof(_group), nameof(_priority))] public override void Update() diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ClrPropertyViewModel.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ClrPropertyViewModel.cs index 895ff41f7b..b7ee1459f7 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ClrPropertyViewModel.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ClrPropertyViewModel.cs @@ -40,16 +40,16 @@ namespace Avalonia.Diagnostics.ViewModels public override Type AssignedType => _assignedType; public override Type PropertyType => _propertyType; + public override bool IsReadonly => !Property.CanWrite; - public override string? Value + public override object? Value { - get => ConvertToString(_value); + get => _value; set { try { - var convertedValue = ConvertFromString(value, Property.PropertyType); - Property.SetValue(_target, convertedValue); + Property.SetValue(_target, value); Update(); } catch { } diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/PropertyViewModel.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/PropertyViewModel.cs index a7faf35769..aa2682e376 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/PropertyViewModel.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/PropertyViewModel.cs @@ -7,78 +7,21 @@ namespace Avalonia.Diagnostics.ViewModels { internal abstract class PropertyViewModel : ViewModelBase { - private const BindingFlags PublicStatic = BindingFlags.Public | BindingFlags.Static; - private static readonly Type[] StringParameter = { typeof(string) }; - private static readonly Type[] StringIFormatProviderParameters = { typeof(string), typeof(IFormatProvider) }; - public abstract object Key { get; } public abstract string Name { get; } public abstract string Group { get; } public abstract Type AssignedType { get; } public abstract Type? DeclaringType { get; } - public abstract string? Value { get; set; } + public abstract object? Value { get; set; } public abstract string Priority { get; } public abstract bool? IsAttached { get; } public abstract void Update(); public abstract Type PropertyType { get; } - public string Type => PropertyType == AssignedType - ? PropertyType.GetTypeName() - : $"{PropertyType.GetTypeName()} {{{AssignedType.GetTypeName()}}}"; - - - protected static string? ConvertToString(object? value) - { - if (value is null) - { - return "(null)"; - } - - var converter = TypeDescriptor.GetConverter(value); - - //CollectionConverter does not deliver any important information. It just displays "(Collection)". - if (!converter.CanConvertTo(typeof(string)) || - converter.GetType() == typeof(CollectionConverter)) - { - return value.ToString() ?? "(null)"; - } - - return converter.ConvertToString(value); - } - - private static object? InvokeParse(string s, Type targetType) - { - var method = targetType.GetMethod("Parse", PublicStatic, null, StringIFormatProviderParameters, null); - - if (method != null) - { - return method.Invoke(null, new object[] { s, CultureInfo.InvariantCulture }); - } - - method = targetType.GetMethod("Parse", PublicStatic, null, StringParameter, null); - - if (method != null) - { - return method.Invoke(null, new object[] { s }); - } - - throw new InvalidCastException("Unable to convert value."); - } - - protected static object? ConvertFromString(string? s, Type targetType) - { - if (s is null) - { - return null; - } - - var converter = TypeDescriptor.GetConverter(targetType); - if (converter.CanConvertFrom(typeof(string))) - { - return converter.ConvertFrom(null, CultureInfo.InvariantCulture, s); - } + public string Type => PropertyType == AssignedType ? + PropertyType.GetTypeName() : + $"{PropertyType.GetTypeName()} {{{AssignedType.GetTypeName()}}}"; - return InvokeParse(s, targetType); - } + public abstract bool IsReadonly { get; } } } diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml b/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml index 2a69798c6c..51421a7097 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml @@ -60,7 +60,14 @@ DoubleTapped="PropertiesGrid_OnDoubleTapped"> - + + + + + + From 2402fe07e34ad591297551e80ccad2bc45c39abc Mon Sep 17 00:00:00 2001 From: pr8x Date: Thu, 16 Feb 2023 16:59:15 +0100 Subject: [PATCH 02/11] NumericUpDown for ints --- .../Diagnostics/Controls/PropertyValueEditor.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditor.cs b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditor.cs index 74a54dd702..ac90f01fad 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditor.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditor.cs @@ -180,8 +180,14 @@ namespace Avalonia.Diagnostics.Controls return CreateControl(ToggleButton.IsCheckedProperty); //TODO: Infinity, NaN not working with NumericUpDown - //if (ValueType.IsPrimitive) - // return CreateControl(NumericUpDown.ValueProperty, new ValueToDecimalConverter()); + if (ValueType.IsPrimitive && ValueType != typeof(float) && ValueType != typeof(double)) + return CreateControl(NumericUpDown.ValueProperty, new ValueToDecimalConverter(), init: + n => + { + n.Increment = 1; + n.NumberFormat = new NumberFormatInfo { NumberDecimalDigits = 0 }; + n.ParsingNumberStyle = NumberStyles.Integer; + }); if (ValueType == typeof(Color)) return CreateControl(ColorView.ColorProperty); From 252f02b21e393dc41eea4d0a17b6d433d186ec0a Mon Sep 17 00:00:00 2001 From: pr8x Date: Thu, 16 Feb 2023 17:54:37 +0100 Subject: [PATCH 03/11] Fix edior not loading directyl when value is null --- .../Controls/PropertyValueEditor.cs | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditor.cs b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditor.cs index ac90f01fad..a045fb09a1 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditor.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditor.cs @@ -51,7 +51,16 @@ namespace Avalonia.Diagnostics.Controls public object? Value { get => _value; - set => SetAndRaise(ValueProperty, ref _value, value); + set + { + if (_needsUpdate) + { + _needsUpdate = false; + Child = UpdateControl(); + } + + SetAndRaise(ValueProperty, ref _value, value); + } } public Type? ValueType @@ -77,13 +86,6 @@ namespace Avalonia.Diagnostics.Controls _needsUpdate = true; } - - if (change.Property == ValueProperty && _needsUpdate) - { - _needsUpdate = false; - - Child = UpdateControl(); - } } //Unfortunately we cannot use TwoWay bindings as they update the source with the target value @@ -202,13 +204,7 @@ namespace Avalonia.Diagnostics.Controls img.HorizontalAlignment = HorizontalAlignment.Stretch; img.PointerPressed += (_, _) => - new Window - { - Content = new Image - { - Source = img.Source - } - }.Show(); + new Window { Content = new Image { Source = img.Source } }.Show(); }); if (ValueType.IsEnum) From 35ba4ab4a7f43c26223d0857c53ea623991588ba Mon Sep 17 00:00:00 2001 From: pr8x Date: Thu, 16 Feb 2023 19:32:04 +0100 Subject: [PATCH 04/11] Make it a custom UserControl --- .../Controls/PropertyValueEditor.cs | 327 ------------------ .../Controls/PropertyValueEditorView.cs | 243 +++++++++++++ .../Diagnostics/Views/ControlDetailsView.xaml | 5 +- 3 files changed, 244 insertions(+), 331 deletions(-) delete mode 100644 src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditor.cs create mode 100644 src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs diff --git a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditor.cs b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditor.cs deleted file mode 100644 index a045fb09a1..0000000000 --- a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditor.cs +++ /dev/null @@ -1,327 +0,0 @@ -using System; -using System.ComponentModel; -using System.Globalization; -using System.Reflection; -using Avalonia.Controls; -using Avalonia.Controls.Primitives; -using Avalonia.Data; -using Avalonia.Data.Converters; -using Avalonia.Layout; -using Avalonia.Media; -using Avalonia.Reactive; - -namespace Avalonia.Diagnostics.Controls -{ - internal class PropertyValueEditor : Decorator - { - /// - /// Defines the property. - /// - public static readonly DirectProperty ValueProperty = - AvaloniaProperty.RegisterDirect( - nameof(Value), o => o.Value, (o, v) => o.Value = v); - - /// - /// Defines the property. - /// - public static readonly DirectProperty ValueTypeProperty = - AvaloniaProperty.RegisterDirect( - nameof(ValueType), o => o.ValueType, (o, v) => o.ValueType = v); - - /// - /// Defines the property. - /// - public static readonly DirectProperty IsReadonlyProperty = - AvaloniaProperty.RegisterDirect( - nameof(IsReadonly), o => o.IsReadonly, (o, v) => o.IsReadonly = v); - - private readonly CompositeDisposable _cleanup = new(); - - private bool _isReadonly; - private bool _needsUpdate; - private object? _value; - private Type? _valueType; - - public bool IsReadonly - { - get => _isReadonly; - set => SetAndRaise(IsReadonlyProperty, ref _isReadonly, value); - } - - public object? Value - { - get => _value; - set - { - if (_needsUpdate) - { - _needsUpdate = false; - Child = UpdateControl(); - } - - SetAndRaise(ValueProperty, ref _value, value); - } - } - - public Type? ValueType - { - get => _valueType; - set => SetAndRaise(ValueTypeProperty, ref _valueType, value); - } - - protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) - { - base.OnDetachedFromVisualTree(e); - - _cleanup.Clear(); - } - - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) - { - base.OnPropertyChanged(change); - - if (change.Property == ValueTypeProperty) - { - _cleanup.Clear(); - - _needsUpdate = true; - } - } - - //Unfortunately we cannot use TwoWay bindings as they update the source with the target value - //This causes the source property value to be overwritten. Ideally we there would be some kind of - //"InitialBindingDirection" or something to control whether the first value is from source or target. - private static void TwoWayBindingFromSource( - AvaloniaObject source, - AvaloniaProperty sourceProperty, - AvaloniaObject target, - AvaloniaProperty targetProperty, - IValueConverter? converter, - Type targetType, - CompositeDisposable disposable) - { - bool isUpdating = false; - - source - .GetObservable(sourceProperty) - .Subscribe(value => - { - if (isUpdating) return; - - try - { - isUpdating = true; - - target[targetProperty] = converter != null ? - converter.Convert(value, typeof(object), null, CultureInfo.CurrentCulture) : - value; - } - finally - { - isUpdating = false; - } - }) - .DisposeWith(disposable); - - target - .GetObservable(targetProperty) - .Skip(1) - .Subscribe(value => - { - if (isUpdating) return; - - try - { - isUpdating = true; - - source[sourceProperty] = converter != null ? - converter.ConvertBack(value, targetType, null, CultureInfo.CurrentCulture) : - value; - } - finally - { - isUpdating = false; - } - }) - .DisposeWith(disposable); - } - - private Control? UpdateControl() - { - if (ValueType is null) return null; - - TControl CreateControl(AvaloniaProperty valueProperty, - IValueConverter? converter = null, - Action? init = null) - where TControl : Control, new() - { - var control = new TControl(); - - init?.Invoke(control); - - TwoWayBindingFromSource( - this, - ValueProperty, - control, - valueProperty, - converter, - ValueType, - _cleanup); - - control.Bind( - IsEnabledProperty, - new Binding(nameof(IsReadonly)) { Source = this, Converter = BoolConverters.Not }) - .DisposeWith(_cleanup); - - return control; - } - - bool isObjectType = ValueType == typeof(object); - - if (ValueType == typeof(bool)) - return CreateControl(ToggleButton.IsCheckedProperty); - - //TODO: Infinity, NaN not working with NumericUpDown - if (ValueType.IsPrimitive && ValueType != typeof(float) && ValueType != typeof(double)) - return CreateControl(NumericUpDown.ValueProperty, new ValueToDecimalConverter(), init: - n => - { - n.Increment = 1; - n.NumberFormat = new NumberFormatInfo { NumberDecimalDigits = 0 }; - n.ParsingNumberStyle = NumberStyles.Integer; - }); - - if (ValueType == typeof(Color)) - return CreateControl(ColorView.ColorProperty); - - if (!isObjectType && ValueType.IsAssignableFrom(typeof(IBrush))) - return CreateControl(BrushEditor.BrushProperty); - - if (!isObjectType && ValueType.IsAssignableFrom(typeof(IImage))) - return CreateControl(Image.SourceProperty, init: img => - { - img.Stretch = Stretch.Uniform; - img.HorizontalAlignment = HorizontalAlignment.Stretch; - - img.PointerPressed += (_, _) => - new Window { Content = new Image { Source = img.Source } }.Show(); - }); - - if (ValueType.IsEnum) - return CreateControl( - SelectingItemsControl.SelectedItemProperty, init: c => - { - c.Items = Enum.GetValues(ValueType); - }); - - var tb = CreateControl( - TextBox.TextProperty, - new TextToValueConverter(), - t => - { - t.Watermark = "(null)"; - }); - - tb.IsEnabled &= !isObjectType && - StringConversionHelper.CanConvertFromString(ValueType); - - return tb; - } - - private static class StringConversionHelper - { - private const BindingFlags PublicStatic = BindingFlags.Public | BindingFlags.Static; - private static readonly Type[] StringParameter = { typeof(string) }; - - private static readonly Type[] - StringFormatProviderParameters = { typeof(string), typeof(IFormatProvider) }; - - public static bool CanConvertFromString(Type type) - { - var converter = TypeDescriptor.GetConverter(type); - - if (converter.CanConvertFrom(typeof(string))) return true; - - return GetParseMethod(type, out _) != null; - } - - public static MethodInfo? GetParseMethod(Type type, out bool hasFormat) - { - var m = type.GetMethod("Parse", PublicStatic, null, StringFormatProviderParameters, null); - - if (m != null) - { - hasFormat = true; - - return m; - } - - hasFormat = false; - - return type.GetMethod("Parse", PublicStatic, null, StringParameter, null); - } - } - - private sealed class ValueToDecimalConverter : IValueConverter - { - public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - return System.Convert.ToDecimal(value); - } - - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - return System.Convert.ChangeType(value, targetType); - } - } - - private sealed class TextToValueConverter : IValueConverter - { - public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is null) - return null; - - var converter = TypeDescriptor.GetConverter(value); - - //CollectionConverter does not deliver any important information. It just displays "(Collection)". - if (!converter.CanConvertTo(typeof(string)) || - converter.GetType() == typeof(CollectionConverter)) - return value.ToString(); - - return converter.ConvertToString(value); - } - - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is not string s) - return null; - - try - { - var converter = TypeDescriptor.GetConverter(targetType); - - return converter.CanConvertFrom(typeof(string)) ? - converter.ConvertFrom(null, CultureInfo.InvariantCulture, s) : - InvokeParse(s, targetType); - } - catch - { - return BindingOperations.DoNothing; - } - } - - private static object? InvokeParse(string s, Type targetType) - { - var m = StringConversionHelper.GetParseMethod(targetType, out bool hasFormat); - - if (m == null) throw new InvalidOperationException(); - - return m.Invoke(null, - hasFormat ? - new object[] { s, CultureInfo.InvariantCulture } : - new object[] { s }); - } - } - } -} diff --git a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs new file mode 100644 index 0000000000..0bc237ab85 --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs @@ -0,0 +1,243 @@ +using System; +using System.ComponentModel; +using System.Globalization; +using System.Reflection; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Diagnostics.ViewModels; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Reactive; + +namespace Avalonia.Diagnostics.Controls +{ + internal class PropertyValueEditorView : UserControl + { + private readonly CompositeDisposable _cleanup = new(); + private PropertyViewModel? Property => (PropertyViewModel?)DataContext; + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + + Content = UpdateControl(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + + _cleanup.Clear(); + } + + private Control? UpdateControl() + { + _cleanup.Clear(); + + if (Property?.PropertyType is not { } propertyType) return null; + + TControl CreateControl(AvaloniaProperty valueProperty, + IValueConverter? converter = null, + Action? init = null) + where TControl : Control, new() + { + var control = new TControl(); + + init?.Invoke(control); + + control.Bind(valueProperty, + new Binding(nameof(Property.Value), BindingMode.TwoWay) + { + Source = Property, + Converter = converter ?? new ValueConverter() + }).DisposeWith(_cleanup); + + control.IsEnabled = !Property.IsReadonly; + + return control; + } + + bool isObjectType = propertyType == typeof(object); + + if (propertyType == typeof(bool)) + return CreateControl(ToggleButton.IsCheckedProperty); + + //TODO: Infinity, NaN not working with NumericUpDown + if (propertyType.IsPrimitive && propertyType != typeof(float) && propertyType != typeof(double)) + return CreateControl( + NumericUpDown.ValueProperty, + new ValueToDecimalConverter(), + init: n => + { + n.Increment = 1; + n.NumberFormat = new NumberFormatInfo { NumberDecimalDigits = 0 }; + n.ParsingNumberStyle = NumberStyles.Integer; + }); + + if (propertyType == typeof(Color)) + return CreateControl(ColorView.ColorProperty); + + if (!isObjectType && propertyType.IsAssignableFrom(typeof(IBrush))) + return CreateControl(BrushEditor.BrushProperty); + + if (!isObjectType && propertyType.IsAssignableFrom(typeof(IImage))) + return CreateControl(Image.SourceProperty, init: img => + { + img.Stretch = Stretch.Uniform; + img.HorizontalAlignment = HorizontalAlignment.Stretch; + + img.PointerPressed += (_, _) => + new Window { Content = new Image { Source = img.Source } }.Show(); + }); + + if (propertyType.IsEnum) + return CreateControl( + SelectingItemsControl.SelectedItemProperty, init: c => + { + c.Items = Enum.GetValues(propertyType); + }); + + var tb = CreateControl( + TextBox.TextProperty, + new TextToValueConverter(), + t => + { + t.Watermark = "(null)"; + }); + + tb.IsEnabled &= !isObjectType && + StringConversionHelper.CanConvertFromString(propertyType); + + return tb; + } + + //HACK: ValueConverter that skips first target update + private class ValueConverter : IValueConverter + { + private bool _firstUpdate = true; + + protected virtual object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value; + } + + protected virtual object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value; + } + + object? IValueConverter.Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return Convert(value, targetType, parameter, culture); + } + + object? IValueConverter.ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (_firstUpdate) + { + _firstUpdate = false; + + return BindingOperations.DoNothing; + } + + return ConvertBack(value, targetType, parameter, culture); + } + } + + private static class StringConversionHelper + { + private const BindingFlags PublicStatic = BindingFlags.Public | BindingFlags.Static; + private static readonly Type[] StringParameter = { typeof(string) }; + private static readonly Type[] StringFormatProviderParameters = { typeof(string), typeof(IFormatProvider) }; + + public static bool CanConvertFromString(Type type) + { + var converter = TypeDescriptor.GetConverter(type); + + if (converter.CanConvertFrom(typeof(string))) return true; + + return GetParseMethod(type, out _) != null; + } + + public static MethodInfo? GetParseMethod(Type type, out bool hasFormat) + { + var m = type.GetMethod("Parse", PublicStatic, null, StringFormatProviderParameters, null); + + if (m != null) + { + hasFormat = true; + + return m; + } + + hasFormat = false; + + return type.GetMethod("Parse", PublicStatic, null, StringParameter, null); + } + } + + private sealed class ValueToDecimalConverter : ValueConverter + { + protected override object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return System.Convert.ToDecimal(value); + } + + protected override object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return System.Convert.ChangeType(value, targetType); + } + } + + private sealed class TextToValueConverter : ValueConverter + { + protected override object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is null) + return null; + + var converter = TypeDescriptor.GetConverter(value); + + //CollectionConverter does not deliver any important information. It just displays "(Collection)". + if (!converter.CanConvertTo(typeof(string)) || + converter.GetType() == typeof(CollectionConverter)) + return value.ToString(); + + return converter.ConvertToString(value); + } + + protected override object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not string s) + return null; + + try + { + var converter = TypeDescriptor.GetConverter(targetType); + + return converter.CanConvertFrom(typeof(string)) ? + converter.ConvertFrom(null, CultureInfo.InvariantCulture, s) : + InvokeParse(s, targetType); + } + catch + { + return BindingOperations.DoNothing; + } + } + + private static object? InvokeParse(string s, Type targetType) + { + var m = StringConversionHelper.GetParseMethod(targetType, out bool hasFormat); + + if (m == null) throw new InvalidOperationException(); + + return m.Invoke(null, + hasFormat ? + new object[] { s, CultureInfo.InvariantCulture } : + new object[] { s }); + } + } + } +} diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml b/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml index 51421a7097..612626f91e 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml @@ -62,10 +62,7 @@ - + Date: Fri, 17 Feb 2023 10:56:08 +0100 Subject: [PATCH 05/11] Improved image preview --- .../Controls/PropertyValueEditorView.cs | 65 +++++++++++++++---- .../ViewModels/ReactiveExtensions.cs | 57 ++++++++++++++++ 2 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 src/Avalonia.Diagnostics/Diagnostics/ViewModels/ReactiveExtensions.cs diff --git a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs index 0bc237ab85..78df63e41f 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs @@ -4,17 +4,22 @@ using System.Globalization; using System.Reflection; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Shapes; using Avalonia.Data; using Avalonia.Data.Converters; using Avalonia.Diagnostics.ViewModels; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Reactive; +using Image = Avalonia.Controls.Image; namespace Avalonia.Diagnostics.Controls { internal class PropertyValueEditorView : UserControl { + private static Geometry ImageGeometry = Geometry.Parse( + "M12.25 6C8.79822 6 6 8.79822 6 12.25V35.75C6 37.1059 6.43174 38.3609 7.16525 39.3851L21.5252 25.0251C22.8921 23.6583 25.1081 23.6583 26.475 25.0251L40.8348 39.385C41.5683 38.3608 42 37.1058 42 35.75V12.25C42 8.79822 39.2018 6 35.75 6H12.25ZM34.5 17.5C34.5 19.7091 32.7091 21.5 30.5 21.5C28.2909 21.5 26.5 19.7091 26.5 17.5C26.5 15.2909 28.2909 13.5 30.5 13.5C32.7091 13.5 34.5 15.2909 34.5 17.5ZM39.0024 41.0881L24.7072 26.7929C24.3167 26.4024 23.6835 26.4024 23.293 26.7929L8.99769 41.0882C9.94516 41.6667 11.0587 42 12.25 42H35.75C36.9414 42 38.0549 41.6666 39.0024 41.0881Z"); + private readonly CompositeDisposable _cleanup = new(); private PropertyViewModel? Property => (PropertyViewModel?)DataContext; @@ -50,8 +55,7 @@ namespace Avalonia.Diagnostics.Controls control.Bind(valueProperty, new Binding(nameof(Property.Value), BindingMode.TwoWay) { - Source = Property, - Converter = converter ?? new ValueConverter() + Source = Property, Converter = converter ?? new ValueConverter() }).DisposeWith(_cleanup); control.IsEnabled = !Property.IsReadonly; @@ -83,14 +87,50 @@ namespace Avalonia.Diagnostics.Controls return CreateControl(BrushEditor.BrushProperty); if (!isObjectType && propertyType.IsAssignableFrom(typeof(IImage))) - return CreateControl(Image.SourceProperty, init: img => + { + var valueObservable = Property.GetObservable(x => x.Value); + + var tbl = new TextBlock { VerticalAlignment = VerticalAlignment.Center }; + + tbl.Bind(TextBlock.TextProperty, + valueObservable.Select( + value => value is IImage image ? $"{image.Size.Width} x {image.Size.Height}" : "(null)")) + .DisposeWith(_cleanup); + + var sp = new StackPanel { - img.Stretch = Stretch.Uniform; - img.HorizontalAlignment = HorizontalAlignment.Stretch; + Background = Brushes.Transparent, + Orientation = Orientation.Horizontal, + Spacing = 2, + Children = + { + new Path + { + Data = ImageGeometry, + Fill = Brushes.Gray, + Width = 12, + Height = 12, + Stretch = Stretch.Uniform, + VerticalAlignment = VerticalAlignment.Center + }, + tbl + } + }; + + var previewImage = new Image + { + Width = 300, + Height = 300 + }; - img.PointerPressed += (_, _) => - new Window { Content = new Image { Source = img.Source } }.Show(); - }); + previewImage + .Bind(Image.SourceProperty, valueObservable) + .DisposeWith(_cleanup); + + ToolTip.SetTip(sp, previewImage); + + return sp; + } if (propertyType.IsEnum) return CreateControl( @@ -123,7 +163,8 @@ namespace Avalonia.Diagnostics.Controls return value; } - protected virtual object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + protected virtual object? ConvertBack(object? value, Type targetType, object? parameter, + CultureInfo culture) { return value; } @@ -185,7 +226,8 @@ namespace Avalonia.Diagnostics.Controls return System.Convert.ToDecimal(value); } - protected override object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + protected override object? ConvertBack(object? value, Type targetType, object? parameter, + CultureInfo culture) { return System.Convert.ChangeType(value, targetType); } @@ -208,7 +250,8 @@ namespace Avalonia.Diagnostics.Controls return converter.ConvertToString(value); } - protected override object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + protected override object? ConvertBack(object? value, Type targetType, object? parameter, + CultureInfo culture) { if (value is not string s) return null; diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ReactiveExtensions.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ReactiveExtensions.cs new file mode 100644 index 0000000000..9425989096 --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ReactiveExtensions.cs @@ -0,0 +1,57 @@ +using System; +using System.ComponentModel; +using System.Linq.Expressions; +using System.Reflection; +using Avalonia.Reactive; + +namespace Avalonia.Diagnostics.ViewModels +{ + internal static class ReactiveExtensions + { + public static IObservable GetObservable( + this TOwner vm, + Expression> property, + bool fireImmediately = true) + where TOwner : INotifyPropertyChanged + { + return Observable.Create(o => + { + var propertyInfo = GetPropertyInfo(property); + + void Fire() + { + o.OnNext((TValue) propertyInfo.GetValue(vm)!); + } + + void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == propertyInfo.Name) + { + Fire(); + } + } + + if (fireImmediately) + { + Fire(); + } + + vm.PropertyChanged += OnPropertyChanged; + + return Disposable.Create(() => vm.PropertyChanged -= OnPropertyChanged); + }); + } + + private static PropertyInfo GetPropertyInfo(this Expression> property) + { + if (property.Body is UnaryExpression unaryExpression) + { + return (PropertyInfo)((MemberExpression)unaryExpression.Operand).Member; + } + + var memExpr = (MemberExpression)property.Body; + + return (PropertyInfo)memExpr.Member; + } + } +} From 0c6387a0fcc35d2134f4266e71d1622703c07b77 Mon Sep 17 00:00:00 2001 From: pr8x Date: Fri, 17 Feb 2023 13:43:51 +0100 Subject: [PATCH 06/11] Add support for geometry --- .../Controls/PropertyValueEditorView.cs | 94 +++++++++++++------ 1 file changed, 63 insertions(+), 31 deletions(-) diff --git a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs index 78df63e41f..071f4628a7 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs @@ -11,15 +11,17 @@ using Avalonia.Diagnostics.ViewModels; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Reactive; -using Image = Avalonia.Controls.Image; namespace Avalonia.Diagnostics.Controls { internal class PropertyValueEditorView : UserControl { - private static Geometry ImageGeometry = Geometry.Parse( + private static readonly Geometry ImageIcon = Geometry.Parse( "M12.25 6C8.79822 6 6 8.79822 6 12.25V35.75C6 37.1059 6.43174 38.3609 7.16525 39.3851L21.5252 25.0251C22.8921 23.6583 25.1081 23.6583 26.475 25.0251L40.8348 39.385C41.5683 38.3608 42 37.1058 42 35.75V12.25C42 8.79822 39.2018 6 35.75 6H12.25ZM34.5 17.5C34.5 19.7091 32.7091 21.5 30.5 21.5C28.2909 21.5 26.5 19.7091 26.5 17.5C26.5 15.2909 28.2909 13.5 30.5 13.5C32.7091 13.5 34.5 15.2909 34.5 17.5ZM39.0024 41.0881L24.7072 26.7929C24.3167 26.4024 23.6835 26.4024 23.293 26.7929L8.99769 41.0882C9.94516 41.6667 11.0587 42 12.25 42H35.75C36.9414 42 38.0549 41.6666 39.0024 41.0881Z"); + private static readonly Geometry GeometryIcon = Geometry.Parse( + "M23.25 15.5H30.8529C29.8865 8.99258 24.2763 4 17.5 4C10.0442 4 4 10.0442 4 17.5C4 24.2763 8.99258 29.8865 15.5 30.8529V23.25C15.5 18.9698 18.9698 15.5 23.25 15.5ZM23.25 18C20.3505 18 18 20.3505 18 23.25V38.75C18 41.6495 20.3505 44 23.25 44H38.75C41.6495 44 44 41.6495 44 38.75V23.25C44 20.3505 41.6495 18 38.75 18H23.25Z"); + private readonly CompositeDisposable _cleanup = new(); private PropertyViewModel? Property => (PropertyViewModel?)DataContext; @@ -37,6 +39,12 @@ namespace Avalonia.Diagnostics.Controls _cleanup.Clear(); } + private static bool ImplementsInterface(Type type) + { + var interfaceType = typeof(TInterface); + return type == interfaceType || type.GetInterface(interfaceType.FullName!) != null; + } + private Control? UpdateControl() { _cleanup.Clear(); @@ -63,8 +71,6 @@ namespace Avalonia.Diagnostics.Controls return control; } - bool isObjectType = propertyType == typeof(object); - if (propertyType == typeof(bool)) return CreateControl(ToggleButton.IsCheckedProperty); @@ -80,21 +86,27 @@ namespace Avalonia.Diagnostics.Controls n.ParsingNumberStyle = NumberStyles.Integer; }); - if (propertyType == typeof(Color)) - return CreateControl(ColorView.ColorProperty); + if (propertyType == typeof(Color)) return CreateControl(ColorView.ColorProperty); - if (!isObjectType && propertyType.IsAssignableFrom(typeof(IBrush))) + if (ImplementsInterface(propertyType)) return CreateControl(BrushEditor.BrushProperty); - if (!isObjectType && propertyType.IsAssignableFrom(typeof(IImage))) + var isImage = ImplementsInterface(propertyType); + var isGeometry = propertyType == typeof(Geometry); + + if (isImage || isGeometry) { var valueObservable = Property.GetObservable(x => x.Value); - var tbl = new TextBlock { VerticalAlignment = VerticalAlignment.Center }; tbl.Bind(TextBlock.TextProperty, valueObservable.Select( - value => value is IImage image ? $"{image.Size.Width} x {image.Size.Height}" : "(null)")) + value => value switch + { + IImage img => $"{img.Size.Width} x {img.Size.Height}", + Geometry geom => $"{geom.Bounds.Width} x {geom.Bounds.Height}", + _ => "(null)" + })) .DisposeWith(_cleanup); var sp = new StackPanel @@ -106,7 +118,7 @@ namespace Avalonia.Diagnostics.Controls { new Path { - Data = ImageGeometry, + Data = isImage ? ImageIcon : GeometryIcon, Fill = Brushes.Gray, Width = 12, Height = 12, @@ -117,17 +129,37 @@ namespace Avalonia.Diagnostics.Controls } }; - var previewImage = new Image + if (isImage) { - Width = 300, - Height = 300 - }; + var previewImage = new Image { Width = 300, Height = 300 }; - previewImage - .Bind(Image.SourceProperty, valueObservable) - .DisposeWith(_cleanup); + previewImage + .Bind(Image.SourceProperty, valueObservable) + .DisposeWith(_cleanup); + + ToolTip.SetTip(sp, previewImage); + } + else + { + var previewShape = new Path + { + Stretch = Stretch.Uniform, + Fill = Brushes.White, + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center + }; - ToolTip.SetTip(sp, previewImage); + previewShape + .Bind(Path.DataProperty, valueObservable) + .DisposeWith(_cleanup); + + ToolTip.SetTip(sp, new Border + { + Child = previewShape, + Width = 300, + Height = 300 + }); + } return sp; } @@ -147,7 +179,7 @@ namespace Avalonia.Diagnostics.Controls t.Watermark = "(null)"; }); - tb.IsEnabled &= !isObjectType && + tb.IsEnabled &= propertyType != typeof(object) && StringConversionHelper.CanConvertFromString(propertyType); return tb; @@ -158,17 +190,6 @@ namespace Avalonia.Diagnostics.Controls { private bool _firstUpdate = true; - protected virtual object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - return value; - } - - protected virtual object? ConvertBack(object? value, Type targetType, object? parameter, - CultureInfo culture) - { - return value; - } - object? IValueConverter.Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { return Convert(value, targetType, parameter, culture); @@ -185,6 +206,17 @@ namespace Avalonia.Diagnostics.Controls return ConvertBack(value, targetType, parameter, culture); } + + protected virtual object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value; + } + + protected virtual object? ConvertBack(object? value, Type targetType, object? parameter, + CultureInfo culture) + { + return value; + } } private static class StringConversionHelper From 7d3dd1c933954028860d79382c79a8a7a17d04bc Mon Sep 17 00:00:00 2001 From: pr8x Date: Fri, 17 Feb 2023 14:34:25 +0100 Subject: [PATCH 07/11] FIx an issuse with wrong targetType in converter --- .../Controls/PropertyValueEditorView.cs | 82 ++++++++++--------- 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs index 071f4628a7..fefe642f51 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs @@ -63,7 +63,9 @@ namespace Avalonia.Diagnostics.Controls control.Bind(valueProperty, new Binding(nameof(Property.Value), BindingMode.TwoWay) { - Source = Property, Converter = converter ?? new ValueConverter() + Source = Property, + Converter = converter ?? new ValueConverter(), + ConverterParameter = propertyType }).DisposeWith(_cleanup); control.IsEnabled = !Property.IsReadonly; @@ -153,12 +155,7 @@ namespace Avalonia.Diagnostics.Controls .Bind(Path.DataProperty, valueObservable) .DisposeWith(_cleanup); - ToolTip.SetTip(sp, new Border - { - Child = previewShape, - Width = 300, - Height = 300 - }); + ToolTip.SetTip(sp, new Border { Child = previewShape, Width = 300, Height = 300 }); } return sp; @@ -186,6 +183,8 @@ namespace Avalonia.Diagnostics.Controls } //HACK: ValueConverter that skips first target update + //TODO: Would be nice to have some kind of "InitialBindingValue" option on TwoWay bindings to control + //if the first value comes from the source or target private class ValueConverter : IValueConverter { private bool _firstUpdate = true; @@ -204,7 +203,8 @@ namespace Avalonia.Diagnostics.Controls return BindingOperations.DoNothing; } - return ConvertBack(value, targetType, parameter, culture); + //Note: targetType provided by Converter is simply "object" + return ConvertBack(value, (Type)parameter!, parameter, culture); } protected virtual object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) @@ -234,7 +234,41 @@ namespace Avalonia.Diagnostics.Controls return GetParseMethod(type, out _) != null; } - public static MethodInfo? GetParseMethod(Type type, out bool hasFormat) + public static string? ToString(object o) + { + var converter = TypeDescriptor.GetConverter(o); + + //CollectionConverter does not deliver any important information. It just displays "(Collection)". + if (!converter.CanConvertTo(typeof(string)) || + converter.GetType() == typeof(CollectionConverter)) + return o.ToString(); + + return converter.ConvertToString(o); + } + + public static object? FromString(string str, Type type) + { + var converter = TypeDescriptor.GetConverter(type); + + return converter.CanConvertFrom(typeof(string)) ? + converter.ConvertFrom(null, CultureInfo.InvariantCulture, str) : + InvokeParse(str, type); + } + + private static object? InvokeParse(string s, Type targetType) + { + var m = GetParseMethod(targetType, out bool hasFormat); + + if (m == null) + throw new InvalidOperationException(); + + return m.Invoke(null, + hasFormat ? + new object[] { s, CultureInfo.InvariantCulture } : + new object[] { s }); + } + + private static MethodInfo? GetParseMethod(Type type, out bool hasFormat) { var m = type.GetMethod("Parse", PublicStatic, null, StringFormatProviderParameters, null); @@ -269,17 +303,7 @@ namespace Avalonia.Diagnostics.Controls { protected override object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { - if (value is null) - return null; - - var converter = TypeDescriptor.GetConverter(value); - - //CollectionConverter does not deliver any important information. It just displays "(Collection)". - if (!converter.CanConvertTo(typeof(string)) || - converter.GetType() == typeof(CollectionConverter)) - return value.ToString(); - - return converter.ConvertToString(value); + return value is null ? null : StringConversionHelper.ToString(value); } protected override object? ConvertBack(object? value, Type targetType, object? parameter, @@ -290,29 +314,13 @@ namespace Avalonia.Diagnostics.Controls try { - var converter = TypeDescriptor.GetConverter(targetType); - - return converter.CanConvertFrom(typeof(string)) ? - converter.ConvertFrom(null, CultureInfo.InvariantCulture, s) : - InvokeParse(s, targetType); + return StringConversionHelper.FromString(s, targetType); } catch { return BindingOperations.DoNothing; } } - - private static object? InvokeParse(string s, Type targetType) - { - var m = StringConversionHelper.GetParseMethod(targetType, out bool hasFormat); - - if (m == null) throw new InvalidOperationException(); - - return m.Invoke(null, - hasFormat ? - new object[] { s, CultureInfo.InvariantCulture } : - new object[] { s }); - } } } } From 1fa4ec9ac051f153436b6a838054cd335675e411 Mon Sep 17 00:00:00 2001 From: pr8x Date: Mon, 20 Feb 2023 15:23:23 +0100 Subject: [PATCH 08/11] New color editor --- .../Controls/PropertyValueEditorView.cs | 55 ++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs index fefe642f51..313c197a52 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs @@ -8,7 +8,9 @@ using Avalonia.Controls.Shapes; using Avalonia.Data; using Avalonia.Data.Converters; using Avalonia.Diagnostics.ViewModels; +using Avalonia.Input; using Avalonia.Layout; +using Avalonia.Markup.Xaml.Converters; using Avalonia.Media; using Avalonia.Reactive; @@ -22,6 +24,8 @@ namespace Avalonia.Diagnostics.Controls private static readonly Geometry GeometryIcon = Geometry.Parse( "M23.25 15.5H30.8529C29.8865 8.99258 24.2763 4 17.5 4C10.0442 4 4 10.0442 4 17.5C4 24.2763 8.99258 29.8865 15.5 30.8529V23.25C15.5 18.9698 18.9698 15.5 23.25 15.5ZM23.25 18C20.3505 18 18 20.3505 18 23.25V38.75C18 41.6495 20.3505 44 23.25 44H38.75C41.6495 44 44 41.6495 44 38.75V23.25C44 20.3505 41.6495 18 38.75 18H23.25Z"); + private static readonly ColorToBrushConverter Color2Brush = new(); + private readonly CompositeDisposable _cleanup = new(); private PropertyViewModel? Property => (PropertyViewModel?)DataContext; @@ -88,7 +92,54 @@ namespace Avalonia.Diagnostics.Controls n.ParsingNumberStyle = NumberStyles.Integer; }); - if (propertyType == typeof(Color)) return CreateControl(ColorView.ColorProperty); + if (propertyType == typeof(Color)) + { + var el = new Ellipse + { + Width = 12, + Height = 12, + VerticalAlignment = VerticalAlignment.Center, + Cursor = new Cursor(StandardCursorType.Hand) + }; + + el.Bind( + Shape.FillProperty, + new Binding(nameof(Property.Value)) { Source = Property, Converter = Color2Brush }) + .DisposeWith(_cleanup); + + var tbl = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center + }; + + tbl.Bind( + TextBlock.TextProperty, + new Binding(nameof(Property.Value)) { Source = Property }) + .DisposeWith(_cleanup); + + var sp = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 2, + Children = { el, tbl } + }; + + var cv = new ColorView(); + + cv.Bind( + ColorView.ColorProperty, + new Binding(nameof(Property.Value), BindingMode.TwoWay) + { + Source = Property, Converter = Color2Brush + }) + .DisposeWith(_cleanup); + + FlyoutBase.SetAttachedFlyout(sp, new Flyout { Content = cv }); + + sp.PointerPressed += (_, _) => FlyoutBase.ShowAttachedFlyout(sp); + + return sp; + } if (ImplementsInterface(propertyType)) return CreateControl(BrushEditor.BrushProperty); @@ -133,7 +184,7 @@ namespace Avalonia.Diagnostics.Controls if (isImage) { - var previewImage = new Image { Width = 300, Height = 300 }; + var previewImage = new Image { Stretch = Stretch.Uniform, Width = 300, Height = 300 }; previewImage .Bind(Image.SourceProperty, valueObservable) From b9ca5dcdc4dd731bc3b6b7d421997add31ba0bd7 Mon Sep 17 00:00:00 2001 From: pr8x Date: Mon, 20 Feb 2023 15:29:45 +0100 Subject: [PATCH 09/11] Cursor on stackpanel --- .../Diagnostics/Controls/PropertyValueEditorView.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs index 313c197a52..e85d3cc88d 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/Controls/PropertyValueEditorView.cs @@ -98,8 +98,7 @@ namespace Avalonia.Diagnostics.Controls { Width = 12, Height = 12, - VerticalAlignment = VerticalAlignment.Center, - Cursor = new Cursor(StandardCursorType.Hand) + VerticalAlignment = VerticalAlignment.Center }; el.Bind( @@ -121,7 +120,9 @@ namespace Avalonia.Diagnostics.Controls { Orientation = Orientation.Horizontal, Spacing = 2, - Children = { el, tbl } + Children = { el, tbl }, + Background = Brushes.Transparent, + Cursor = new Cursor(StandardCursorType.Hand) }; var cv = new ColorView(); From 2c492a59e74757e2b36657e937084ae83088ddb1 Mon Sep 17 00:00:00 2001 From: pr8x Date: Mon, 20 Feb 2023 16:31:23 +0100 Subject: [PATCH 10/11] Add validation to textbox input & only commit value on Focus loss or Enter --- .../Diagnostics/Controls/CommitTextBox.cs | 89 +++++++++++++++++++ .../Diagnostics/Views/ControlDetailsView.xaml | 4 +- .../PropertyValueEditorView.cs | 58 +++++++----- 3 files changed, 128 insertions(+), 23 deletions(-) create mode 100644 src/Avalonia.Diagnostics/Diagnostics/Controls/CommitTextBox.cs rename src/Avalonia.Diagnostics/Diagnostics/{Controls => Views}/PropertyValueEditorView.cs (89%) diff --git a/src/Avalonia.Diagnostics/Diagnostics/Controls/CommitTextBox.cs b/src/Avalonia.Diagnostics/Diagnostics/Controls/CommitTextBox.cs new file mode 100644 index 0000000000..7870febd0a --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/Controls/CommitTextBox.cs @@ -0,0 +1,89 @@ +using System; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Styling; + +namespace Avalonia.Diagnostics.Controls +{ + //TODO: UpdateSourceTrigger & Binding.ValidationRules could help removing the need for this control. + internal sealed class CommitTextBox : TextBox, IStyleable + { + Type IStyleable.StyleKey => typeof(TextBox); + + /// + /// Defines the property. + /// + public static readonly DirectProperty CommittedTextProperty = + AvaloniaProperty.RegisterDirect( + nameof(CommittedText), o => o.CommittedText, (o, v) => o.CommittedText = v); + + private string? _committedText; + + public string? CommittedText + { + get => _committedText; + set => SetAndRaise(CommittedTextProperty, ref _committedText, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == CommittedTextProperty) + { + Text = CommittedText; + } + } + + protected override void OnKeyUp(KeyEventArgs e) + { + base.OnKeyUp(e); + + switch (e.Key) + { + case Key.Enter: + + TryCommit(); + + e.Handled = true; + + break; + + case Key.Escape: + + Cancel(); + + e.Handled = true; + + break; + } + } + + protected override void OnLostFocus(RoutedEventArgs e) + { + base.OnLostFocus(e); + + TryCommit(); + } + + private void Cancel() + { + Text = CommittedText; + DataValidationErrors.ClearErrors(this); + } + + private void TryCommit() + { + if (!DataValidationErrors.GetHasErrors(this)) + { + CommittedText = Text; + } + else + { + Text = CommittedText; + DataValidationErrors.ClearErrors(this); + } + } + } +} diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml b/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml index 612626f91e..97f195c91b 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml @@ -61,8 +61,8 @@ - - + + (AvaloniaProperty valueProperty, IValueConverter? converter = null, @@ -94,22 +97,14 @@ namespace Avalonia.Diagnostics.Controls if (propertyType == typeof(Color)) { - var el = new Ellipse - { - Width = 12, - Height = 12, - VerticalAlignment = VerticalAlignment.Center - }; + var el = new Ellipse { Width = 12, Height = 12, VerticalAlignment = VerticalAlignment.Center }; el.Bind( Shape.FillProperty, new Binding(nameof(Property.Value)) { Source = Property, Converter = Color2Brush }) .DisposeWith(_cleanup); - var tbl = new TextBlock - { - VerticalAlignment = VerticalAlignment.Center - }; + var tbl = new TextBlock { VerticalAlignment = VerticalAlignment.Center }; tbl.Bind( TextBlock.TextProperty, @@ -128,11 +123,11 @@ namespace Avalonia.Diagnostics.Controls var cv = new ColorView(); cv.Bind( - ColorView.ColorProperty, - new Binding(nameof(Property.Value), BindingMode.TwoWay) - { - Source = Property, Converter = Color2Brush - }) + ColorView.ColorProperty, + new Binding(nameof(Property.Value), BindingMode.TwoWay) + { + Source = Property, Converter = Color2Brush + }) .DisposeWith(_cleanup); FlyoutBase.SetAttachedFlyout(sp, new Flyout { Content = cv }); @@ -220,8 +215,8 @@ namespace Avalonia.Diagnostics.Controls c.Items = Enum.GetValues(propertyType); }); - var tb = CreateControl( - TextBox.TextProperty, + var tb = CreateControl( + CommitTextBox.CommittedTextProperty, new TextToValueConverter(), t => { @@ -231,6 +226,26 @@ namespace Avalonia.Diagnostics.Controls tb.IsEnabled &= propertyType != typeof(object) && StringConversionHelper.CanConvertFromString(propertyType); + if (tb.IsEnabled) + { + tb.GetObservable(TextBox.TextProperty).Subscribe(t => + { + try + { + if (t != null) + { + StringConversionHelper.FromString(t, propertyType); + } + + DataValidationErrors.ClearErrors(tb); + } + catch (Exception ex) + { + DataValidationErrors.SetError(tb, ex.GetBaseException()); + } + }).DisposeWith(_cleanup); + } + return tb; } @@ -281,7 +296,8 @@ namespace Avalonia.Diagnostics.Controls { var converter = TypeDescriptor.GetConverter(type); - if (converter.CanConvertFrom(typeof(string))) return true; + if (converter.CanConvertFrom(typeof(string))) + return true; return GetParseMethod(type, out _) != null; } @@ -309,7 +325,7 @@ namespace Avalonia.Diagnostics.Controls private static object? InvokeParse(string s, Type targetType) { - var m = GetParseMethod(targetType, out bool hasFormat); + var m = GetParseMethod(targetType, out var hasFormat); if (m == null) throw new InvalidOperationException(); From 1fc1c26cb782ff544612e07ab59655aae880fc63 Mon Sep 17 00:00:00 2001 From: pr8x Date: Tue, 28 Feb 2023 12:38:29 +0100 Subject: [PATCH 11/11] Set IsReadOnly instead, use ConvertToInvariantString, Fix TextBox foreground on selected row --- .../Diagnostics/Views/MainView.xaml.cs | 5 ++- .../Views/PropertyValueEditorView.cs | 32 ++++++++++++------- .../Controls/TextBox.xaml | 1 + 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml.cs b/src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml.cs index 7980aa215b..df5188b29b 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml.cs @@ -1,7 +1,6 @@ using Avalonia.Controls; using Avalonia.Diagnostics.ViewModels; using Avalonia.Input; -using Avalonia.Interactivity; using Avalonia.Markup.Xaml; using Avalonia.Threading; @@ -18,7 +17,7 @@ namespace Avalonia.Diagnostics.Views public MainView() { InitializeComponent(); - AddHandler(KeyDownEvent, PreviewKeyDown, RoutingStrategies.Tunnel); + AddHandler(KeyUpEvent, PreviewKeyUp); _console = this.GetControl("console"); _consoleSplitter = this.GetControl("consoleSplitter"); _rootGrid = this.GetControl("rootGrid"); @@ -58,7 +57,7 @@ namespace Avalonia.Diagnostics.Views AvaloniaXamlLoader.Load(this); } - private void PreviewKeyDown(object? sender, KeyEventArgs e) + private void PreviewKeyUp(object? sender, KeyEventArgs e) { if (e.Key == Key.Escape) { diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/PropertyValueEditorView.cs b/src/Avalonia.Diagnostics/Diagnostics/Views/PropertyValueEditorView.cs index d20312a218..6e7729a350 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Views/PropertyValueEditorView.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/PropertyValueEditorView.cs @@ -10,7 +10,6 @@ using Avalonia.Data.Converters; using Avalonia.Diagnostics.Controls; using Avalonia.Diagnostics.ViewModels; using Avalonia.Input; -using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Markup.Xaml.Converters; using Avalonia.Media; @@ -48,7 +47,7 @@ namespace Avalonia.Diagnostics.Views private static bool ImplementsInterface(Type type) { var interfaceType = typeof(TInterface); - return type == interfaceType || type.GetInterface(interfaceType.FullName!) != null; + return type == interfaceType || interfaceType.IsAssignableFrom(type); } private Control? UpdateControl() @@ -60,7 +59,8 @@ namespace Avalonia.Diagnostics.Views TControl CreateControl(AvaloniaProperty valueProperty, IValueConverter? converter = null, - Action? init = null) + Action? init = null, + AvaloniaProperty? readonlyProperty = null) where TControl : Control, new() { var control = new TControl(); @@ -75,7 +75,14 @@ namespace Avalonia.Diagnostics.Views ConverterParameter = propertyType }).DisposeWith(_cleanup); - control.IsEnabled = !Property.IsReadonly; + if (readonlyProperty != null) + { + control[readonlyProperty] = Property.IsReadonly; + } + else + { + control.IsEnabled = !Property.IsReadonly; + } return control; } @@ -93,7 +100,8 @@ namespace Avalonia.Diagnostics.Views n.Increment = 1; n.NumberFormat = new NumberFormatInfo { NumberDecimalDigits = 0 }; n.ParsingNumberStyle = NumberStyles.Integer; - }); + }, + readonlyProperty: NumericUpDown.IsReadOnlyProperty); if (propertyType == typeof(Color)) { @@ -117,7 +125,8 @@ namespace Avalonia.Diagnostics.Views Spacing = 2, Children = { el, tbl }, Background = Brushes.Transparent, - Cursor = new Cursor(StandardCursorType.Hand) + Cursor = new Cursor(StandardCursorType.Hand), + IsEnabled = !Property.IsReadonly }; var cv = new ColorView(); @@ -221,12 +230,13 @@ namespace Avalonia.Diagnostics.Views t => { t.Watermark = "(null)"; - }); + }, + readonlyProperty: TextBox.IsReadOnlyProperty); - tb.IsEnabled &= propertyType != typeof(object) && - StringConversionHelper.CanConvertFromString(propertyType); + tb.IsReadOnly |= propertyType == typeof(object) || + !StringConversionHelper.CanConvertFromString(propertyType); - if (tb.IsEnabled) + if (!tb.IsReadOnly) { tb.GetObservable(TextBox.TextProperty).Subscribe(t => { @@ -311,7 +321,7 @@ namespace Avalonia.Diagnostics.Views converter.GetType() == typeof(CollectionConverter)) return o.ToString(); - return converter.ConvertToString(o); + return converter.ConvertToInvariantString(o); } public static object? FromString(string str, Type type) diff --git a/src/Avalonia.Themes.Simple/Controls/TextBox.xaml b/src/Avalonia.Themes.Simple/Controls/TextBox.xaml index 8428e3aae7..0c7095f2f5 100644 --- a/src/Avalonia.Themes.Simple/Controls/TextBox.xaml +++ b/src/Avalonia.Themes.Simple/Controls/TextBox.xaml @@ -92,6 +92,7 @@ +