diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index 1dda132a59..eef2c39b56 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -1,6 +1,18 @@ + + CP0002 + M:Avalonia.Data.Core.UntypedBindingExpressionBase.get_IsDataValidationEnabled + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Data.Core.UntypedBindingExpressionBase.Start + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 M:Avalonia.Input.PointerEventArgs.#ctor(Avalonia.Interactivity.RoutedEvent,System.Object,Avalonia.Input.IPointer,Avalonia.Visual,Avalonia.Point,System.UInt64,Avalonia.Input.PointerPointProperties,Avalonia.Input.KeyModifiers,System.Lazy{System.Collections.Generic.IReadOnlyList{Avalonia.Input.Raw.RawPointerPoint}}) @@ -55,6 +67,18 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Data.Core.UntypedBindingExpressionBase.get_IsDataValidationEnabled + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Data.Core.UntypedBindingExpressionBase.Start + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 M:Avalonia.Controls.Embedding.Offscreen.OffscreenTopLevelImplBase.SetFrameThemeVariant(Avalonia.Platform.PlatformThemeVariant) diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index 60ffc71c98..a34b61a1ce 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -605,9 +605,7 @@ namespace Avalonia /// internal BindingExpressionBase Bind(AvaloniaProperty property, BindingBase binding, object? anchor) { - if (binding.CreateInstance(this, property, anchor) is not UntypedBindingExpressionBase expression) - throw new NotSupportedException($"Binding returned unsupported {nameof(BindingExpressionBase)}."); - + var expression = binding.CreateInstance(this, property, anchor); return GetValueStore().AddBinding(property, expression); } diff --git a/src/Avalonia.Base/AvaloniaProperty.cs b/src/Avalonia.Base/AvaloniaProperty.cs index 8b7a56a9f6..98137de7ea 100644 --- a/src/Avalonia.Base/AvaloniaProperty.cs +++ b/src/Avalonia.Base/AvaloniaProperty.cs @@ -610,8 +610,8 @@ namespace Avalonia /// Routes an untyped SetDirectValueUnchecked call to a typed call. /// /// The object instance. - /// The value. - internal virtual void RouteSetDirectValueUnchecked(AvaloniaObject o, object? value) => + /// The value entry containing the value. + internal virtual void RouteSetDirectValueUnchecked(AvaloniaObject o, IValueEntry entry) => throw new NotSupportedException(); /// diff --git a/src/Avalonia.Base/Data/BindingExpressionBase.cs b/src/Avalonia.Base/Data/BindingExpressionBase.cs index 7e9ad9c370..471857ae40 100644 --- a/src/Avalonia.Base/Data/BindingExpressionBase.cs +++ b/src/Avalonia.Base/Data/BindingExpressionBase.cs @@ -1,15 +1,58 @@ using System; +using Avalonia.Data.Core; using Avalonia.PropertyStore; using Avalonia.Styling; namespace Avalonia.Data; -public abstract class BindingExpressionBase : IDisposable, ISetterInstance +/// +/// Represents the base class for binding expressions. +/// +/// +/// A binding expression represents an instantiation of a binding on an object. +/// +public abstract class BindingExpressionBase : IDisposable, ISetterInstance, IValueEntry { - private protected BindingExpressionBase() + private protected BindingExpressionBase(BindingPriority defaultPriority) { + Priority = DefaultPriority = defaultPriority; } + /// + /// Gets the priority of the binding expression. + /// + public BindingPriority Priority { get; private protected set; } + + /// + /// Gets the that the binding expression is targeting. + /// + public AvaloniaProperty? TargetProperty { get; private protected set; } + + /// + /// Gets the default priority of the binding expression. + /// + /// + /// This property describes the preferred priority of the binding expression; the priority + /// passed to the method may differ if the binding targets a direct + /// property, in which case the priority will be elevated to + /// . + /// + internal BindingPriority DefaultPriority { get; } + + /// + /// Gets a value indicating whether data validation is enabled for the binding expression. + /// + internal bool IsDataValidationEnabled { get; private protected set; } + + AvaloniaProperty IValueEntry.Property => TargetProperty ?? + throw new InvalidOperationException("The binding expression is not attached."); + + bool IValueEntry.HasValue() => HasValue(); + object? IValueEntry.GetValue() => GetUntypedValue(); + bool IValueEntry.GetDataValidationState(out BindingValueType state, out Exception? error) => + GetDataValidationState(out state, out error); + void IValueEntry.Unsubscribe() => Unsubscribe(); + public virtual void Dispose() { GC.SuppressFinalize(this); @@ -39,15 +82,43 @@ public abstract class BindingExpressionBase : IDisposable, ISetterInstance /// When overridden in a derived class, attaches the binding expression to a value store but /// does not start it. /// - /// The value store to attach to. + /// The binding expression sink to attach to. /// The immediate value frame to attach to, if any. /// The target object. /// The target property. /// The priority of the binding. internal abstract void Attach( - ValueStore valueStore, + IBindingExpressionSink sink, ImmediateValueFrame? frame, AvaloniaObject target, AvaloniaProperty targetProperty, BindingPriority priority); + + /// + /// Starts the binding expression. + /// + /// + /// Indicates whether the binding expression should produce an initial value. + /// + internal abstract void Start(bool produceValue); + + /// + /// Checks whether the binding expression has a value, starting it if necessary. + /// + private protected abstract bool HasValue(); + + /// + /// Gets the current value of the binding expression as a boxed object. + /// + private protected abstract object? GetUntypedValue(); + + /// + /// Gets the data validation state of the binding expression, if supported. + /// + private protected abstract bool GetDataValidationState(out BindingValueType state, out Exception? error); + + /// + /// Called when the binding expression is removed from the value store as a value entry. + /// + private protected abstract void Unsubscribe(); } diff --git a/src/Avalonia.Base/Data/CompiledBinding.cs b/src/Avalonia.Base/Data/CompiledBinding.cs index ca8ecc62ff..df4d3355c7 100644 --- a/src/Avalonia.Base/Data/CompiledBinding.cs +++ b/src/Avalonia.Base/Data/CompiledBinding.cs @@ -196,6 +196,109 @@ public class CompiledBinding : BindingBase AvaloniaObject target, AvaloniaProperty? targetProperty, object? anchor) + { + if (CanUseTypedBindingExpression(target, targetProperty, out var typed)) + { + return CreateTypedExpression(typed, target, targetProperty, anchor); + } + else + { + return CreateUntypedExpression(target, targetProperty, anchor); + } + } + + private bool CanUseTypedBindingExpression( + AvaloniaObject target, + AvaloniaProperty? targetProperty, + [NotNullWhen(true)] out TypedPropertyElement? element) + { + element = null; + + // We need a path with a single TypedPropertyElement + if (Path?.Elements.Count != 1 || Path.Elements[0] is not TypedPropertyElement typed) + return false; + + // We need a DataContext binding. + if (Source != AvaloniaProperty.UnsetValue) + return false; + + // It cannot have a Converter, Delay, FallbackValue. StringFormat, TargetNullValue or + // UpdateSourceTrigger != PropertyChanged. + if (Converter is not null || + Delay != 0 || + FallbackValue != AvaloniaProperty.UnsetValue || + StringFormat != null || + TargetNullValue != AvaloniaProperty.UnsetValue || + UpdateSourceTrigger is not (UpdateSourceTrigger.Default or UpdateSourceTrigger.PropertyChanged)) + { + return false; + } + + // The value must be directly assignable to the target property. + if (targetProperty is null || + !typed.Property.PropertyType.IsAssignableTo(targetProperty.PropertyType)) + { + return false; + } + + // TypedBindingExpression only supports StyledElement targets (it listens for + // StyledElement.DataContextProperty changes). Other AvaloniaObjects that expose a + // DataContext (e.g. Application, which is an IDataContextProvider but not a StyledElement) + // must use the untyped path. + if (target is not StyledElement) + return false; + + // DataContext bindings need to read their source value from the parent of the target + // instead of the DataContext; TypedBindingExpression does not support this (and it would + // probably not be worth doing so as DataContexts are usually reference types). + if (targetProperty == StyledElement.DataContextProperty) + return false; + + // TypedBindingExpression does not support data validation, so fall back to the untyped + // path when the target property enables it. + if (targetProperty.GetMetadata(target).EnableDataValidation == true) + return false; + + // For modes that write back to the source, the source property must be settable and the + // target value must be assignable back to the source property type. The untyped path + // handles read-only or wider-typed sources gracefully (failing silently), so fall back to + // it rather than throwing during write-back. + var (mode, _) = ResolveDefaultsFromMetadata(target, targetProperty); + if (mode is BindingMode.TwoWay or BindingMode.OneWayToSource) + { + if (!typed.Property.CanSet || + !targetProperty.PropertyType.IsAssignableTo(typed.Property.PropertyType)) + { + return false; + } + } + + element = typed; + return true; + } + + private BindingExpressionBase CreateTypedExpression( + TypedPropertyElement element, + AvaloniaObject target, + AvaloniaProperty? targetProperty, + object? anchor) + { + // The UpdateSourceTrigger has already been constrained to PropertyChanged by + // CanUseTypedBindingExpression, so only the mode needs to be resolved here. + var (mode, _) = ResolveDefaultsFromMetadata(target, targetProperty); + + return element.CreateExpression( + target, + targetProperty, + anchor, + mode, + Priority); + } + + private BindingExpression CreateUntypedExpression( + AvaloniaObject target, + AvaloniaProperty? targetProperty, + object? anchor) { var enableDataValidation = targetProperty?.GetMetadata(target).EnableDataValidation ?? false; var nodes = new List(); diff --git a/src/Avalonia.Base/Data/CompiledBindingPath.cs b/src/Avalonia.Base/Data/CompiledBindingPath.cs index 2b8b914122..6b8dc6358f 100644 --- a/src/Avalonia.Base/Data/CompiledBindingPath.cs +++ b/src/Avalonia.Base/Data/CompiledBindingPath.cs @@ -7,6 +7,7 @@ using Avalonia.Data.Core; using Avalonia.Data.Core.ExpressionNodes; using Avalonia.Data.Core.Parsers; using Avalonia.Data.Core.Plugins; +using Avalonia.Styling; namespace Avalonia.Data { @@ -93,7 +94,7 @@ namespace Avalonia.Data result.Add(new LogicalNotNode()); } - internal IEnumerable Elements => _elements; + internal IReadOnlyList Elements => _elements; /// public override string ToString() @@ -129,6 +130,20 @@ namespace Avalonia.Data return this; } + public CompiledBindingPathBuilder Property( + IPropertyInfo info, + Func, IPropertyInfo, IPropertyAccessor> accessorFactory, + bool acceptsNull) + where TSource : class + { + _elements.Add(new TypedPropertyElement( + info, + accessorFactory, + _elements.Count == 0, + acceptsNull)); + return this; + } + public CompiledBindingPathBuilder Method(RuntimeMethodHandle handle, RuntimeTypeHandle delegateType) { Method(handle, delegateType, acceptsNull: false); @@ -264,6 +279,48 @@ namespace Avalonia.Data => _isFirstElement ? Property.Name : $".{Property.Name}"; } + internal abstract class TypedPropertyElement : PropertyElement + { + protected TypedPropertyElement( + IPropertyInfo property, + Func, IPropertyInfo, IPropertyAccessor> accessorFactory, + bool isFirstElement, + bool acceptsNull) : base(property, accessorFactory, isFirstElement, acceptsNull) + { + } + + public abstract BindingExpressionBase CreateExpression( + AvaloniaObject target, + AvaloniaProperty? targetProperty, + object? anchor, + BindingMode mode, + BindingPriority priority); + } + + internal sealed class TypedPropertyElement : TypedPropertyElement + where TSource : class + { + public TypedPropertyElement( + IPropertyInfo property, + Func, IPropertyInfo, IPropertyAccessor> accessorFactory, + bool isFirstElement, + bool acceptsNull) + : base(property, accessorFactory, isFirstElement, acceptsNull) + { + } + + public override BindingExpressionBase CreateExpression( + AvaloniaObject target, + AvaloniaProperty? targetProperty, + object? anchor, + BindingMode mode, + BindingPriority priority) + { + var property = (IPropertyInfo)Property; + return new TypedBindingExpression(property, mode, priority); + } + } + internal class MethodAsDelegateElement : ICompiledBindingPathElement { public MethodAsDelegateElement( diff --git a/src/Avalonia.Base/Data/Core/ClrPropertyInfo`2.cs b/src/Avalonia.Base/Data/Core/ClrPropertyInfo`2.cs new file mode 100644 index 0000000000..f9e062ef69 --- /dev/null +++ b/src/Avalonia.Base/Data/Core/ClrPropertyInfo`2.cs @@ -0,0 +1,40 @@ +using System; + +namespace Avalonia.Data.Core; + +public class ClrPropertyInfo : IPropertyInfo + where TSource : class +{ + private readonly Func? _getter; + private readonly Action? _setter; + + public ClrPropertyInfo(string name, Func? getter, Action? setter) + { + _getter = getter; + _setter = setter; + PropertyType = typeof(TValue); + Name = name; + } + + public bool CanSet => _setter != null; + public bool CanGet => _getter != null; + public string Name { get; } + public Type PropertyType { get; } + + public TValue Get(TSource target) + { + if (_getter == null) + throw new NotSupportedException("Property " + Name + " doesn't have a getter"); + return _getter(target); + } + + public void Set(TSource target, TValue value) + { + if (_setter == null) + throw new NotSupportedException("Property " + Name + " doesn't have a setter"); + _setter(target, value); + } + + object? IPropertyInfo.Get(object target) => Get((TSource)target); + void IPropertyInfo.Set(object target, object? value) => Set((TSource)target, (TValue)value!); +} diff --git a/src/Avalonia.Base/Data/Core/IBindingExpressionSink.cs b/src/Avalonia.Base/Data/Core/IBindingExpressionSink.cs index a068536e1c..35ab76b643 100644 --- a/src/Avalonia.Base/Data/Core/IBindingExpressionSink.cs +++ b/src/Avalonia.Base/Data/Core/IBindingExpressionSink.cs @@ -8,27 +8,19 @@ internal interface IBindingExpressionSink /// /// The binding expression. /// - /// Indicates whether represents a new value produced by the binding. + /// Indicates whether the binding has produced a new value. /// /// - /// Indicates whether represents a new error produced by the binding. - /// - /// - /// The new binding value; if is true. - /// - /// - /// The new binding error; if is true. + /// Indicates whether the binding has produced a new error. /// void OnChanged( - UntypedBindingExpressionBase instance, + BindingExpressionBase instance, bool hasValueChanged, - bool hasErrorChanged, - object? value, - BindingError? error); + bool hasErrorChanged); /// - /// Called when an completes. + /// Called when a completes. /// /// The binding expression. - void OnCompleted(UntypedBindingExpressionBase instance); + void OnCompleted(BindingExpressionBase instance); } diff --git a/src/Avalonia.Base/Data/Core/IPropertyInfo`2.cs b/src/Avalonia.Base/Data/Core/IPropertyInfo`2.cs new file mode 100644 index 0000000000..90f1e12216 --- /dev/null +++ b/src/Avalonia.Base/Data/Core/IPropertyInfo`2.cs @@ -0,0 +1,12 @@ +using System; +using Avalonia.Metadata; + +namespace Avalonia.Data.Core; + +[NotClientImplementable] +public interface IPropertyInfo : IPropertyInfo + where TSource : class +{ + TValue Get(TSource target); + void Set(TSource target, TValue value); +} diff --git a/src/Avalonia.Base/Data/Core/MultiBindingExpression.cs b/src/Avalonia.Base/Data/Core/MultiBindingExpression.cs index 2e9cb0b9cf..2c8a0c73fb 100644 --- a/src/Avalonia.Base/Data/Core/MultiBindingExpression.cs +++ b/src/Avalonia.Base/Data/Core/MultiBindingExpression.cs @@ -4,6 +4,7 @@ using System.Collections.ObjectModel; using System.Diagnostics; using System.Globalization; using Avalonia.Data.Converters; +using Avalonia.PropertyStore; namespace Avalonia.Data.Core; @@ -69,7 +70,8 @@ internal class MultiBindingExpression : UntypedBindingExpressionBase, IBindingEx throw new NotSupportedException($"Unsupported BindingExpressionBase implementation '{expression}'."); _expressions[i] = e; - e.AttachAndStart(this, target, null, Priority); + e.Attach(this, null, target, null, Priority); + e.Start(produceValue: true); } } @@ -84,20 +86,21 @@ internal class MultiBindingExpression : UntypedBindingExpressionBase, IBindingEx } void IBindingExpressionSink.OnChanged( - UntypedBindingExpressionBase instance, + BindingExpressionBase instance, bool hasValueChanged, - bool hasErrorChanged, - object? value, - BindingError? error) + bool hasErrorChanged) { var i = Array.IndexOf(_expressions, instance); Debug.Assert(i != -1); - _values[i] = BindingNotification.ExtractValue(value); + var entry = (IValueEntry)instance; + _values[i] = entry.HasValue() ? + BindingNotification.ExtractValue(entry.GetValue()) : + AvaloniaProperty.UnsetValue; PublishValue(); } - void IBindingExpressionSink.OnCompleted(UntypedBindingExpressionBase instance) + void IBindingExpressionSink.OnCompleted(BindingExpressionBase instance) { // Nothing to do here. } diff --git a/src/Avalonia.Base/Data/Core/TypedBindingExpression.cs b/src/Avalonia.Base/Data/Core/TypedBindingExpression.cs new file mode 100644 index 0000000000..8295fb3d81 --- /dev/null +++ b/src/Avalonia.Base/Data/Core/TypedBindingExpression.cs @@ -0,0 +1,417 @@ +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using Avalonia.Logging; +using Avalonia.PropertyStore; +using Avalonia.Threading; +using Avalonia.Utilities; + +namespace Avalonia.Data.Core; + +/// +/// A binding expression which does not box. +/// +/// The type of the source object. +/// The type of the value. +/// +/// A typed binding expression has the following limitations: +/// +/// - It must be a DataContext binding +/// - It can only be used with a single source property, i.e. `{Binding Foo}` can be represented as +/// a typed binding but `{Binding Foo.Bar}` cannot. +/// - It cannot have a Converter, Delay, FallbackValue. StringFormat, TargetNullValue or +/// UpdateSourceTrigger != PropertyChanged. +/// - The value must be directly assignable to the target property, i.e. no type conversion is +/// performed. +/// - The target property must not enable data validation. +/// +internal class TypedBindingExpression : BindingExpressionBase, + IDescription, + IValueEntry, + IWeakEventSubscriber + where TSource : class +{ + private readonly IPropertyInfo _propertyInfo; + private readonly BindingMode _mode; + private bool _isRunning; + private bool _produceValue; + private bool _writingValueToTarget; + private IBindingExpressionSink? _sink; + private ImmediateValueFrame? _frame; + private WeakReference? _source; + private WeakReference? _target; + private Optional _sourceValue; + private Optional _targetValue; + private bool _shouldUpdateOneTimeBindingTarget; + + public TypedBindingExpression( + IPropertyInfo propertyInfo, + BindingMode mode, + BindingPriority defaultPriority) + : base(defaultPriority) + { + _propertyInfo = propertyInfo; + _mode = mode; + _shouldUpdateOneTimeBindingTarget = mode is BindingMode.OneTime; + } + + public string Description => _propertyInfo.Name; + + // The whole method body is factored out into AttachCore, which takes the value type as a + // Type parameter instead of reading it from TValue. Because AttachCore doesn't reference the + // generic parameters, its code is shared across all generic instantiations instead of being + // duplicated for each one, which is a meaningful NativeAOT size saving. + internal override void Attach( + IBindingExpressionSink sink, + ImmediateValueFrame? frame, + AvaloniaObject target, + AvaloniaProperty targetProperty, + BindingPriority priority) + => AttachCore(sink, frame, target, targetProperty, priority, typeof(TValue)); + + public override void Dispose() + { + if (_sink is null) + return; + + // Null the sink before stopping so that the unsubscribe doesn't push a final value to a + // value store that's about to clear this entry anyway. + var sink = _sink; + var frame = _frame; + _sink = null; + _frame = null; + + StopCore(); + + sink.OnCompleted(this); + frame?.OnEntryDisposed(this); + } + + internal override void Start(bool produceValue) + { + if (_isRunning) + return; + + _isRunning = true; + + try + { + _produceValue = produceValue; + StartCore(); + } + finally + { + _produceValue = true; + } + } + + private protected override bool GetDataValidationState(out BindingValueType state, out Exception? error) + { + // Data validation is not supported by the typed expression: bindings whose target + // property enables it are excluded in CompiledBinding.CanUseTypedBindingExpression and + // use the untyped BindingExpression instead. Could be implemented here as a follow-up. + state = BindingValueType.Value; + error = null; + return false; + } + + private protected override object? GetUntypedValue() + { + Start(produceValue: false); + if (!_sourceValue.HasValue) + throw new AvaloniaInternalException("The binding expression has no value."); + return Box(_sourceValue.Value); + } + + TValue IValueEntry.GetValue() + { + Start(produceValue: false); + if (!_sourceValue.HasValue) + throw new AvaloniaInternalException("The binding expression has no value."); + return _sourceValue.Value; + } + + private protected override bool HasValue() + { + Start(produceValue: false); + return _sourceValue.HasValue; + } + + private protected override void Unsubscribe() + { + // Reset _isRunning so the expression can be restarted (and re-subscribe to its source) if + // the value store reactivates this entry later. + StopCore(); + _isRunning = false; + } + + void IWeakEventSubscriber.OnEvent(object? sender, WeakEvent ev, PropertyChangedEventArgs e) + { + OnSourcePropertyChanged(sender, e); + } + + private void AttachCore( + IBindingExpressionSink sink, + ImmediateValueFrame? frame, + AvaloniaObject target, + AvaloniaProperty targetProperty, + BindingPriority priority, + Type valueType) + { + if (_sink is not null) + throw new InvalidOperationException("TypedBindingExpression was already attached."); + if (target is not StyledElement element) + throw new InvalidOperationException("TypedBindingExpression may only target StyledElements"); + if (TargetProperty is not null && TargetProperty != targetProperty) + throw new InvalidOperationException("TypedBindingExpression was already attached to a different property."); + + if (!valueType.IsAssignableTo(targetProperty.PropertyType)) + { + throw new InvalidOperationException( + $"TypedBindingExpression of type '{valueType}' cannot be bound " + + $"to a property of type '{targetProperty.PropertyType}'."); + } + + _sink = sink; + _frame = frame; + _target = new(element); + TargetProperty = targetProperty; + Priority = priority; + } + + private void StartCore() + { + if (TryGetTarget(out var target) && TargetProperty is not null) + { + target.PropertyChanged += OnTargetPropertyChanged; + UpdateSource(target.DataContext); + } + } + + private void StopCore() + { + if (TryGetTarget(out var target)) + { + target.PropertyChanged -= OnTargetPropertyChanged; + UpdateSource(null); + } + } + + private void UpdateSource(object? dataContext) + { + var source = dataContext as TSource; + + if (dataContext is not null && source is null) + { + Log($"Could not convert DataContext of type '{dataContext.GetType()}' " + + $"to '{typeof(TSource)}'."); + } + + if (TryGetSource(out var oldSource)) + { + if (oldSource is INotifyPropertyChanged oldInpc) + WeakEvents.ThreadSafePropertyChanged.Unsubscribe(oldInpc, this); + } + + _source = new(source); + _shouldUpdateOneTimeBindingTarget = true; + + if (source is INotifyPropertyChanged inpc) + WeakEvents.ThreadSafePropertyChanged.Subscribe(inpc, this); + + if (_mode is BindingMode.OneWayToSource) + { + if (TryGetTargetValue(out var value)) + WriteValueToSource(value!); + } + else + { + WriteSourceValueToTarget(source); + } + } + + private void WriteValueToSource(TValue value) + { + if (TargetProperty is not null && TryGetTarget(out var target)) + { + if (TryGetSource(out var source)) + _propertyInfo.Set(source, value); + } + } + + private void WriteSourceValueToTarget() + { + if (TryGetSource(out var source)) + WriteSourceValueToTarget(source); + } + + private void WriteSourceValueToTarget(TSource? source) + { + if (_mode is BindingMode.OneTime && !_shouldUpdateOneTimeBindingTarget) + return; + + var oldValue = _sourceValue; + + if (source is null) + { + _sourceValue = default; + } + else + { + try + { + _sourceValue = new(_propertyInfo.Get(source)); + } + catch (Exception e) + { + // Getter exceptions must not escape into the source's PropertyChanged event and + // crash the UI thread, so log the error and clear the value, as the untyped + // binding path does. + Log($"Error getting '{_propertyInfo.Name}': {e.Message}"); + _sourceValue = default; + } + } + + if (_produceValue && _mode is not BindingMode.OneWayToSource) + { + // An expression which has no value, and had no value before, must not notify: doing so + // would push the target property's default value, overriding values from styles or + // property inheritance. Otherwise always notify, even if the value is unchanged, as + // the target may hold an uncommitted value written by SetCurrentValue. + if (oldValue.HasValue || _sourceValue.HasValue) + PublishValue(); + if (_mode is BindingMode.OneTime) + _shouldUpdateOneTimeBindingTarget = false; + } + } + + private void PublishValue() + { + // The source's PropertyChanged event may be raised on any thread, but the target can only + // be updated on the UI thread. + if (Dispatcher.UIThread.CheckAccess()) + PublishCore(this); + else + Dispatcher.UIThread.Post(PublishCore, this); + + static void PublishCore(object? state) + { + var expression = (TypedBindingExpression)state!; + + // Flag that we're pushing the source value to the target so that the resulting target + // PropertyChanged isn't echoed straight back to the source in TwoWay mode. + expression._writingValueToTarget = true; + try + { + expression._sink?.OnChanged(expression, true, false); + } + finally + { + expression._writingValueToTarget = false; + } + } + } + + private void OnSourcePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + // A null or empty PropertyName means "all properties changed" per the + // INotifyPropertyChanged contract, so we must re-read the source value in that case too. + if (string.IsNullOrEmpty(e.PropertyName) || e.PropertyName == _propertyInfo.Name) + WriteSourceValueToTarget(); + } + + private void OnTargetPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == StyledElement.DataContextProperty) + { + UpdateSource(((StyledElement?)sender)?.DataContext); + } + else if (e.Property == TargetProperty) + { + _targetValue = ReadTargetValue(e); + + // Don't write back to the source if this change is the binding pushing the source + // value to the target; that would be a redundant round-trip. + if (_targetValue.HasValue && + !_writingValueToTarget && + _mode is BindingMode.TwoWay or BindingMode.OneWayToSource) + { + WriteValueToSource(_targetValue.Value); + } + } + } + + private static Optional ReadTargetValue(AvaloniaPropertyChangedEventArgs e) + { + // The binding value type only needs to be assignable to the target property type, so the + // target property can hold values which cannot be represented as a TValue; for example a + // string binding on an object-typed property whose value is set to an int. Such values + // are reported as absent rather than throwing. + if (e is AvaloniaPropertyChangedEventArgs typedArgs) + return typedArgs.NewValue.Value; + if (e.NewValue is TValue value) + return value; + if (e.NewValue is null && default(TValue) is null) + return new Optional(default!); + return default; + } + + /// + /// Converts a value to , using cached boxes for booleans so that reading + /// a boolean-valued binding into an object-typed target property does not allocate on every + /// read (#21065). + /// + private static object? Box(TValue value) + { + if (typeof(TValue) == typeof(bool)) + return BooleanBoxes.Box(Unsafe.As(ref value)); + return value; + } + + private void Log(string error, LogEventLevel level = LogEventLevel.Warning) + { + if (!Logger.TryGet(level, LogArea.Binding, out var log) || !TryGetTarget(out var target)) + return; + + log.Log( + target, + "An error occurred binding {Property} to {Expression}: {Message}", + (object?)TargetProperty ?? "(unknown)", + Description, + error); + } + + private bool TryGetSource([NotNullWhen(true)] out TSource? source) + { + if (_source?.TryGetTarget(out source) == true) + return true; + source = null; + return false; + } + + private bool TryGetTarget([NotNullWhen(true)] out StyledElement? target) + { + if (_target?.TryGetTarget(out target) == true) + return true; + target = null; + return false; + } + + private bool TryGetTargetValue(out TValue? value) + { + if (TargetProperty is not null && TryGetTarget(out var target)) + { + value = TargetProperty switch + { + StyledProperty s => target.GetValue(s), + DirectPropertyBase d => target.GetValue(d), + _ => (TValue)target.GetValue(TargetProperty)! + }; + return true; + } + + value = default; + return false; + } +} diff --git a/src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs b/src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs index 874c3c2e13..a6108fcb2e 100644 --- a/src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs +++ b/src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs @@ -18,11 +18,9 @@ namespace Avalonia.Data.Core; [PrivateApi] public abstract class UntypedBindingExpressionBase : BindingExpressionBase, IDisposable, - IDescription, - IValueEntry + IDescription { protected static readonly object UnchangedValue = new(); - private readonly bool _isDataValidationEnabled; private object? _defaultValue; private BindingError? _error; private ImmediateValueFrame? _frame; @@ -45,11 +43,11 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase, BindingPriority defaultPriority, AvaloniaProperty? targetProperty = null, bool isDataValidationEnabled = false) + : base(defaultPriority) { - Priority = defaultPriority; TargetProperty = targetProperty; TargetType = targetProperty?.PropertyType ?? typeof(object); - _isDataValidationEnabled = isDataValidationEnabled; + IsDataValidationEnabled = isDataValidationEnabled; } /// @@ -62,38 +60,17 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase, /// public BindingErrorType ErrorType => _error?.ErrorType ?? BindingErrorType.None; - /// - /// Gets a value indicating whether data validation is enabled for the binding expression. - /// - public bool IsDataValidationEnabled => _isDataValidationEnabled; - /// /// Gets a value indicating whether the binding expression is currently running. /// public bool IsRunning => _isRunning; - /// - /// Gets the priority of the binding expression. - /// - /// - /// Before being attached to a value store, this property describes the default priority of the - /// binding expression; this may change when the expression is attached to a value store. - /// - public BindingPriority Priority { get; private set; } - - /// - /// Gets the which the binding expression is targeting. - /// - public AvaloniaProperty? TargetProperty { get; private set; } - /// /// Gets the target type of the binding expression; that is, the type that values produced by /// the expression should be converted to. /// public Type TargetType { get; private set; } - AvaloniaProperty IValueEntry.Property => TargetProperty ?? throw new Exception(); - /// /// Terminates the binding. /// @@ -143,12 +120,7 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase, return result; } - /// - /// Starts the binding expression following a call to . - /// - public void Start() => Start(produceValue: true); - - bool IValueEntry.GetDataValidationState(out BindingValueType state, out Exception? error) + private protected override bool GetDataValidationState(out BindingValueType state, out Exception? error) { if (_error is not null) { @@ -169,46 +141,56 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase, return IsDataValidationEnabled; } - bool IValueEntry.HasValue() + private protected override bool HasValue() { Start(produceValue: false); return true; } - object? IValueEntry.GetValue() + private protected override object? GetUntypedValue() { Start(produceValue: false); return GetValueOrDefault(); } - void IValueEntry.Unsubscribe() => Stop(); + private protected override void Unsubscribe() => Stop(); internal override void Attach( - ValueStore valueStore, + IBindingExpressionSink sink, ImmediateValueFrame? frame, AvaloniaObject target, - AvaloniaProperty targetProperty, + AvaloniaProperty? targetProperty, BindingPriority priority) { - AttachCore(valueStore, frame, target, targetProperty, priority); + if (_sink is not null) + throw new InvalidOperationException("BindingExpression was already attached."); + if (TargetProperty is not null && TargetProperty != targetProperty) + throw new InvalidOperationException("BindingExpression was already attached to a different property."); + + _sink = sink; + _frame = frame; + _target = new(target); + TargetProperty = targetProperty; + TargetType = targetProperty?.PropertyType ?? typeof(object); + Priority = priority; } - /// - /// Initializes the binding expression with the specified subscriber and target property and - /// starts it. - /// - /// The subscriber. - /// The target object. - /// The target property. - /// The priority of the binding. - internal void AttachAndStart( - IBindingExpressionSink subscriber, - AvaloniaObject target, - AvaloniaProperty? targetProperty, - BindingPriority priority) + internal override void Start(bool produceValue) { - AttachCore(subscriber, null, target, targetProperty, priority); - Start(produceValue: true); + if (_isRunning) + return; + + _isRunning = true; + + try + { + _produceValue = produceValue; + StartCore(); + } + finally + { + _produceValue = true; + } } /// @@ -256,27 +238,6 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase, /// internal virtual bool WriteValueToSource(object? value) => false; - private void AttachCore( - IBindingExpressionSink sink, - ImmediateValueFrame? frame, - AvaloniaObject target, - AvaloniaProperty? targetProperty, - BindingPriority priority) - { - if (_sink is not null) - throw new InvalidOperationException("BindingExpression was already attached."); - if (TargetProperty is not null && TargetProperty != targetProperty) - throw new InvalidOperationException("BindingExpression was already attached to a different property."); - - _sink = sink; - _frame = frame; - _target = new(target); - TargetProperty = targetProperty; - TargetType = targetProperty?.PropertyType ?? typeof(object); - Priority = priority; - } - - /// /// Converts a value using a value converter, logging a warning if necessary. /// @@ -437,7 +398,7 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase, if (Dispatcher.UIThread.CheckAccess()) { - _sink.OnChanged(this, hasValueChanged, hasErrorChanged, GetValueOrDefault(), _error); + _sink.OnChanged(this, hasValueChanged, hasErrorChanged); } else { @@ -446,9 +407,7 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase, var sink = _sink; var vc = hasValueChanged; var ec = hasErrorChanged; - var v = GetValueOrDefault(); - var e = _error; - Dispatcher.UIThread.Post(() => sink.OnChanged(this, vc, ec, v, e)); + Dispatcher.UIThread.Post(() => sink.OnChanged(this, vc, ec)); } } @@ -465,30 +424,6 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase, return TryGetTarget(out target); } - /// - /// Starts the binding expression by calling . - /// - /// - /// Indicates whether the binding expression should produce an initial value. - /// - protected void Start(bool produceValue) - { - if (_isRunning) - return; - - _isRunning = true; - - try - { - _produceValue = produceValue; - StartCore(); - } - finally - { - _produceValue = true; - } - } - /// /// When overridden in a derived class, starts the binding expression. /// @@ -562,13 +497,15 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase, public ObservableSink(UntypedBindingExpressionBase expression) => _expression = expression; void IBindingExpressionSink.OnChanged( - UntypedBindingExpressionBase instance, + BindingExpressionBase instance, bool hasValueChanged, - bool hasErrorChanged, - object? value, - BindingError? error) + bool hasErrorChanged) { - if (instance.IsDataValidationEnabled || error is not null) + var expression = (UntypedBindingExpressionBase)instance; + var error = expression._error; + var value = expression.GetValueOrDefault(); + + if (expression.IsDataValidationEnabled || error is not null) { BindingNotification notification; @@ -587,7 +524,7 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase, } } - void IBindingExpressionSink.OnCompleted(UntypedBindingExpressionBase instance) => PublishCompleted(); + void IBindingExpressionSink.OnCompleted(BindingExpressionBase instance) => PublishCompleted(); void IObserver.OnCompleted() { } void IObserver.OnError(Exception error) { } diff --git a/src/Avalonia.Base/DirectPropertyBase.cs b/src/Avalonia.Base/DirectPropertyBase.cs index 09f8cbdc7b..0e8b12b9c2 100644 --- a/src/Avalonia.Base/DirectPropertyBase.cs +++ b/src/Avalonia.Base/DirectPropertyBase.cs @@ -177,10 +177,11 @@ namespace Avalonia return null; } - internal override void RouteSetDirectValueUnchecked(AvaloniaObject o, object? value) + internal override void RouteSetDirectValueUnchecked(AvaloniaObject o, IValueEntry entry) { - var bindingValue = BindingValue.FromUntypedStrict(value); - o.SetDirectValueUnchecked(this, bindingValue); + var value = GetValue(entry, o); + var bindingValue = new BindingValue(value); + o.SetDirectValueUnchecked(this, bindingValue); } internal override void RouteSetCurrentValue(AvaloniaObject o, object? value) @@ -201,5 +202,8 @@ namespace Avalonia { return o.Bind(this, source); } + + private TValue GetValue(IValueEntry entry, AvaloniaObject o) + => IValueEntry.TryGetValue(entry, out var value) ? value : GetMetadata(o).UnsetValue; } } diff --git a/src/Avalonia.Base/PropertyStore/EffectiveValue.cs b/src/Avalonia.Base/PropertyStore/EffectiveValue.cs index 7421b183d4..398ffba442 100644 --- a/src/Avalonia.Base/PropertyStore/EffectiveValue.cs +++ b/src/Avalonia.Base/PropertyStore/EffectiveValue.cs @@ -140,7 +140,7 @@ namespace Avalonia.PropertyStore BindingPriority priority); /// - /// Sets the value and base value for a LocalValue priority, raising + /// Sets the value and base value for a LocalValue priority, raising /// where necessary. /// /// The associated value store. @@ -151,6 +151,18 @@ namespace Avalonia.PropertyStore AvaloniaProperty property, object? value); + /// + /// Sets the value and base value for a LocalValue priority, raising + /// where necessary. + /// + /// The associated value store. + /// The property being changed. + /// The value entry containing the value. + public abstract void SetLocalValueAndRaise( + ValueStore owner, + AvaloniaProperty property, + IValueEntry entry); + /// /// Raises in response to an inherited value /// change. diff --git a/src/Avalonia.Base/PropertyStore/EffectiveValue`1.cs b/src/Avalonia.Base/PropertyStore/EffectiveValue`1.cs index 15ca3f8a8a..37fe44c246 100644 --- a/src/Avalonia.Base/PropertyStore/EffectiveValue`1.cs +++ b/src/Avalonia.Base/PropertyStore/EffectiveValue`1.cs @@ -83,6 +83,15 @@ namespace Avalonia.PropertyStore SetAndRaiseCore(owner, property, value, BindingPriority.LocalValue); } + public override void SetLocalValueAndRaise( + ValueStore owner, + AvaloniaProperty property, + IValueEntry entry) + { + var value = GetValue(entry); + SetLocalValueAndRaise(owner, (StyledProperty)property, value!); + } + public void SetCurrentValueAndRaise( ValueStore owner, StyledProperty property, @@ -193,13 +202,8 @@ namespace Avalonia.PropertyStore protected override object? GetBoxedValue() => Value; - private static T GetValue(IValueEntry entry) - { - if (entry is IValueEntry typed) - return typed.GetValue(); - else - return (T)entry.GetValue()!; - } + private T GetValue(IValueEntry entry) + => IValueEntry.TryGetValue(entry, out var value) ? value : _metadata.DefaultValue; private void SetAndRaiseCore( ValueStore owner, diff --git a/src/Avalonia.Base/PropertyStore/IValueEntry.cs b/src/Avalonia.Base/PropertyStore/IValueEntry.cs index 7ac7e83276..97b7427097 100644 --- a/src/Avalonia.Base/PropertyStore/IValueEntry.cs +++ b/src/Avalonia.Base/PropertyStore/IValueEntry.cs @@ -40,5 +40,24 @@ namespace Avalonia.PropertyStore /// Called when the value entry is removed from the value store. /// void Unsubscribe(); + + /// + /// Reads the value of an entry as , preferring the unboxed + /// path when available. + /// + /// The entry. + /// The value, if the entry has one. + /// True if the entry has a value, otherwise false. + static bool TryGetValue(IValueEntry entry, out T value) + { + if (entry.HasValue()) + { + value = entry is IValueEntry typed ? typed.GetValue() : (T)entry.GetValue()!; + return true; + } + + value = default!; + return false; + } } } diff --git a/src/Avalonia.Base/PropertyStore/ImmediateValueFrame.cs b/src/Avalonia.Base/PropertyStore/ImmediateValueFrame.cs index 600be54372..826cb420a1 100644 --- a/src/Avalonia.Base/PropertyStore/ImmediateValueFrame.cs +++ b/src/Avalonia.Base/PropertyStore/ImmediateValueFrame.cs @@ -15,7 +15,7 @@ namespace Avalonia.PropertyStore { } - public IValueEntry AddBinding(UntypedBindingExpressionBase source) + public IValueEntry AddBinding(IValueEntry source) { Add(source); return source; diff --git a/src/Avalonia.Base/PropertyStore/ValueStore.cs b/src/Avalonia.Base/PropertyStore/ValueStore.cs index 1af99366e7..7ef1d1d550 100644 --- a/src/Avalonia.Base/PropertyStore/ValueStore.cs +++ b/src/Avalonia.Base/PropertyStore/ValueStore.cs @@ -47,15 +47,17 @@ namespace Avalonia.PropertyStore public BindingExpressionBase AddBinding( AvaloniaProperty property, - UntypedBindingExpressionBase source) + BindingExpressionBase source) { - var priority = source.Priority; + var priority = source.DefaultPriority; + if (priority == BindingPriority.LocalValue || property.IsDirect) { DisposeExistingLocalValueBinding(property); _localValueBindings ??= new(); _localValueBindings[property.Id] = source; - source.AttachAndStart(this, Owner, property, BindingPriority.LocalValue); + source.Attach(this, null, Owner, property, BindingPriority.LocalValue); + source.Start(produceValue: true); return source; } else @@ -67,7 +69,7 @@ namespace Avalonia.PropertyStore frame.AddBinding(source); if (effective is null || priority <= effective.Priority) - source.Start(); + source.Start(produceValue: true); return source; } @@ -273,6 +275,20 @@ namespace Avalonia.PropertyStore } } + public void SetLocalValue(AvaloniaProperty property, IValueEntry entry) + { + if (TryGetEffectiveValue(property, out var existing)) + { + existing.SetLocalValueAndRaise(this, property, entry); + } + else + { + var effectiveValue = property.CreateEffectiveValue(Owner); + AddEffectiveValue(property, effectiveValue); + effectiveValue.SetLocalValueAndRaise(this, property, entry); + } + } + public object? GetValue(AvaloniaProperty property) { if (_effectiveValues.TryGetValue(property, out var v)) @@ -787,21 +803,20 @@ namespace Avalonia.PropertyStore } void IBindingExpressionSink.OnChanged( - UntypedBindingExpressionBase instance, + BindingExpressionBase instance, bool hasValueChanged, - bool hasErrorChanged, - object? value, - BindingError? error) + bool hasErrorChanged) { Dispatcher.UIThread.VerifyAccess(); Debug.Assert(instance.TargetProperty is not null); var property = instance.TargetProperty; + IValueEntry entry = instance; if (property.IsDirect) { if (hasValueChanged) - property.RouteSetDirectValueUnchecked(Owner, value); + property.RouteSetDirectValueUnchecked(Owner, entry); } else { @@ -811,32 +826,26 @@ namespace Avalonia.PropertyStore { if (priority == BindingPriority.LocalValue) { - if (value != AvaloniaProperty.UnsetValue) - SetLocalValue(property, value); - else if (property == StyledElement.DataContextProperty) - SetLocalValue(property, null); - else - ClearValue(property); + SetLocalValue(property, entry); } else { if (TryGetEffectiveValue(property, out var existing)) { if (priority <= existing.BasePriority) - ReevaluateEffectiveValue(property, existing, changedValueEntry: instance); + ReevaluateEffectiveValue(property, existing, changedValueEntry: entry); } else { - AddEffectiveValueAndRaise(property, instance, priority); + AddEffectiveValueAndRaise(property, entry, priority); } } } } - if (hasErrorChanged && instance.IsDataValidationEnabled) + if (hasErrorChanged && entry.GetDataValidationState(out var errorType, out var error)) { - var e = error?.ErrorType.ToBindingValueType() ?? BindingValueType.Value; - Owner.OnUpdateDataValidation(property, e, error?.Exception); + Owner.OnUpdateDataValidation(property, errorType, error); } } @@ -844,7 +853,7 @@ namespace Avalonia.PropertyStore /// Called by a binding expression when the binding produces completes. /// /// The binding expression. - void IBindingExpressionSink.OnCompleted(UntypedBindingExpressionBase instance) + void IBindingExpressionSink.OnCompleted(BindingExpressionBase instance) { Dispatcher.UIThread.VerifyAccess(); Debug.Assert(instance.TargetProperty is not null); diff --git a/src/Avalonia.Base/Utilities/BooleanBoxes.cs b/src/Avalonia.Base/Utilities/BooleanBoxes.cs new file mode 100644 index 0000000000..b992e7d520 --- /dev/null +++ b/src/Avalonia.Base/Utilities/BooleanBoxes.cs @@ -0,0 +1,17 @@ +using System.Runtime.CompilerServices; + +namespace Avalonia.Utilities +{ + /// + /// Holds cached boxes for the two boolean values, so that converting a boolean to + /// does not allocate (#21065). + /// + internal static class BooleanBoxes + { + public static readonly object True = true; + public static readonly object False = false; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static object Box(bool value) => value ? True : False; + } +} diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs index 0eff5da962..17014c0e92 100644 --- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs @@ -50,6 +50,8 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers public IXamlMethod INameScopeComplete { get; } public IXamlType IPropertyInfo { get; } public IXamlType ClrPropertyInfo { get; } + public IXamlType IPropertyInfoT { get; } + public IXamlType ClrPropertyInfoT { get; } public IXamlType IPropertyAccessor { get; } public IXamlType PropertyInfoAccessorFactory { get; } public IXamlType CompiledBinding { get; } @@ -248,6 +250,8 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers false, AvaloniaProperty, XamlIlTypes.Object, BindingPriority); IPropertyInfo = typeSystem.GetType("Avalonia.Data.Core.IPropertyInfo"); ClrPropertyInfo = typeSystem.GetType("Avalonia.Data.Core.ClrPropertyInfo"); + IPropertyInfoT = typeSystem.GetType("Avalonia.Data.Core.IPropertyInfo`2"); + ClrPropertyInfoT = typeSystem.GetType("Avalonia.Data.Core.ClrPropertyInfo`2"); IPropertyAccessor = typeSystem.GetType("Avalonia.Data.Core.Plugins.IPropertyAccessor"); PropertyInfoAccessorFactory = typeSystem.GetType("Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings.PropertyInfoAccessorFactory"); CompiledBinding = typeSystem.GetType("Avalonia.Data.CompiledBinding"); diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/XamlIlBindingPathHelper.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/XamlIlBindingPathHelper.cs index 1046658bc8..ca2fc6732d 100644 --- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/XamlIlBindingPathHelper.cs +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/XamlIlBindingPathHelper.cs @@ -783,8 +783,34 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions _acceptsNull = acceptsNull; } + public IXamlProperty Property => _property; + + public bool EmitTyped { get; set; } + + [UnconditionalSuppressMessage("Trimming", "IL2122", Justification = TrimmingMessages.TypesInCoreOrAvaloniaAssembly)] public void Emit(XamlIlEmitContext context, IXamlILEmitter codeGen) { + if (EmitTyped) + { + var types = context.GetAvaloniaTypes(); + var sourceType = _property.Getter?.DeclaringType ?? _property.Setter!.DeclaringType; + var valueType = _property.PropertyType; + + context.Configuration.GetExtra() + .EmitTyped(context, codeGen, _property); + + context.Configuration.GetExtra() + .EmitLoadInpcPropertyAccessorFactory(context, codeGen); + + codeGen.Ldc_I4(_acceptsNull ? 1 : 0); + + var typedPropertyMethod = types.CompiledBindingPathBuilder.GetMethod(m => + m is { Name: "Property", IsGenericMethod: true } && + m.Parameters.Count == 3); + codeGen.EmitCall(typedPropertyMethod.MakeGenericMethod(new[] { sourceType, valueType })); + return; + } + context.Configuration.GetExtra() .Emit(context, codeGen, _property); @@ -1079,6 +1105,8 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions { var types = context.GetAvaloniaTypes(); + TryEnableTypedEmission(_transformElements, Elements); + codeGen.Newobj(types.CompiledBindingPathBuilder.GetConstructor()); foreach (var transform in _transformElements) @@ -1095,6 +1123,31 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions return XamlILNodeEmitResult.Type(0, types.CompiledBindingPath); } + private static void TryEnableTypedEmission( + List transformElements, + List elements) + { + if (transformElements.Count != 0) + return; + if (elements.Count != 1) + return; + if (elements[0] is not XamlIlClrPropertyPathElementNode clr) + return; + + var property = clr.Property; + var declaringType = property.Getter?.DeclaringType ?? property.Setter?.DeclaringType; + + // TypedBindingExpression requires TSource : class. + if (declaringType is null || declaringType.IsValueType) + return; + + // We need an instance getter so the source value can be read. + if (property.Getter is null || property.Getter.IsStatic) + return; + + clr.EmitTyped = true; + } + public override void VisitChildren(IXamlAstVisitor visitor) { for (int i = 0; i < _transformElements.Count; i++) diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/XamlIlClrPropertyInfoHelper.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/XamlIlClrPropertyInfoHelper.cs index da27d4a59b..98463f96ce 100644 --- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/XamlIlClrPropertyInfoHelper.cs +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/XamlIlClrPropertyInfoHelper.cs @@ -16,6 +16,9 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions private Dictionary> _fields = new Dictionary>(); + private Dictionary> _typedFields + = new Dictionary>(); + private IXamlField? _boxedTrue; private IXamlField? _boxedFalse; @@ -65,6 +68,70 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions return baseKey + $"[{indexerArgumentsKey}]"; } + /// + /// Searches a property info cache for a method which was already generated for a property. + /// + /// The cache to search. + /// The cache key for the property, as returned by . + /// The property. + /// + /// When the method returns, contains the cache entries for . The list is + /// created and added to if it doesn't yet exist. + /// + /// + /// The method which returns the property info for , or null if no + /// such method has been generated yet. + /// + static IXamlMethod? GetCachedPropertyInfoMethod( + Dictionary> fields, + string key, + IXamlProperty property, + out List<(IXamlProperty prop, IXamlMethod get)> cached) + { + if (!fields.TryGetValue(key, out cached!)) + fields[key] = cached = new List<(IXamlProperty prop, IXamlMethod get)>(); + + foreach (var entry in cached) + { + if ( + ((entry.prop.Getter == null && property.Getter == null) || + entry.prop.Getter?.Equals(property.Getter) == true) && + ((entry.prop.Setter == null && property.Setter == null) || + entry.prop.Setter?.Equals(property.Setter) == true) + ) + return entry.get; + } + + return null; + } + + /// + /// Emits a delegate which invokes a property accessor, or a null reference if the property + /// has no such accessor. + /// + /// The emit context. + /// The emitter to write to. + /// The accessor to wrap, or null if the property has no accessor. + /// The delegate type to construct. + static void EmitFunc( + XamlEmitContext context, + IXamlILEmitter emitter, + IXamlMethod? method, + IXamlType del) + { + if (method == null) + emitter.Ldnull(); + else + { + emitter + .Ldnull() + .Ldftn(method) + .Newobj(del.Constructors.First(c => + c.Parameters.Count == 2 && + c.Parameters[0].Equals(context.Configuration.WellKnownTypes.Object))); + } + } + public IXamlType Emit( XamlEmitContext context, IXamlILEmitter codeGen, @@ -77,19 +144,9 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions IXamlMethod Get() { var key = GetKey(property, indexerArgumentsKey); - if (!_fields.TryGetValue(key, out var lst)) - _fields[key] = lst = new List<(IXamlProperty prop, IXamlMethod get)>(); - foreach (var cached in lst) - { - if ( - ((cached.prop.Getter == null && property.Getter == null) || - cached.prop.Getter?.Equals(property.Getter) == true) && - ((cached.prop.Setter == null && property.Setter == null) || - cached.prop.Setter?.Equals(property.Setter) == true) - ) - return cached.get; - } + if (GetCachedPropertyInfoMethod(_fields, key, property, out var lst) is { } cached) + return cached; var name = lst.Count == 0 ? key : key + "_" + context.Configuration.IdentifierGenerator.GenerateIdentifierPart(); @@ -175,25 +232,94 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions .MarkLabel(cacheMiss) .Ldstr(property.Name); - void EmitFunc(IXamlILEmitter emitter, IXamlMethod? method, IXamlType del) + EmitFunc(context, get.Generator, getter, ctor.Parameters[1]); + EmitFunc(context, get.Generator, setter, ctor.Parameters[2]); + get.Generator + .Ldtype(property.PropertyType) + .Newobj(ctor) + .Stsfld(field) + .Ldsfld(field) + .Ret(); + + lst.Add((property, get)); + return get; + } + + codeGen.EmitCall(Get()); + return types.IPropertyInfo; + } + + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2122", Justification = TrimmingMessages.TypesInCoreOrAvaloniaAssembly)] + public IXamlType EmitTyped( + XamlEmitContext context, + IXamlILEmitter codeGen, + IXamlProperty property) + { + var types = context.GetAvaloniaTypes(); + var sourceType = property.Getter?.DeclaringType ?? property.Setter?.DeclaringType + ?? throw new InvalidOperationException($"Couldn't get declaring type for property {property}"); + var valueType = property.PropertyType; + + var typedPropertyInfoType = types.IPropertyInfoT.MakeGenericType(sourceType, valueType); + + IXamlMethod Get() + { + var key = GetKey(property, null); + + if (GetCachedPropertyInfoMethod(_typedFields, key, property, out var lst) is { } cached) + return cached; + + // Only construct the generic types on a cache miss: they're not needed when an + // existing property info method is reused. + var typedClrPropertyInfoType = types.ClrPropertyInfoT.MakeGenericType(sourceType, valueType); + var funcType = context.Configuration.WellKnownTypes.GetFuncOfT(2).MakeGenericType(sourceType, valueType); + var actionType = context.Configuration.WellKnownTypes.GetActionOfT(2).MakeGenericType(sourceType, valueType); + + var name = lst.Count == 0 + ? key + "!Typed" + : key + "!Typed_" + context.Configuration.IdentifierGenerator.GenerateIdentifierPart(); + + var field = _builder.DefineField(typedPropertyInfoType, name + "!Field", XamlVisibility.Private, true); + + var getter = property.Getter == null + ? null + : _builder.DefineMethod(valueType, new[] { sourceType }, name + "!Getter", XamlVisibility.Private, true, false); + if (getter != null) { - if (method == null) - emitter.Ldnull(); - else - { - emitter - .Ldnull() - .Ldftn(method) - .Newobj(del.Constructors.First(c => - c.Parameters.Count == 2 && - c.Parameters[0].Equals(context.Configuration.WellKnownTypes.Object))); - } + if (!property.Getter!.IsStatic) + getter.Generator.Ldarg_0(); + getter.Generator.EmitCall(property.Getter).Ret(); } - EmitFunc(get.Generator, getter, ctor.Parameters[1]); - EmitFunc(get.Generator, setter, ctor.Parameters[2]); + var setter = property.Setter == null + ? null + : _builder.DefineMethod(types.XamlIlTypes.Void, new[] { sourceType, valueType }, name + "!Setter", XamlVisibility.Private, true, false); + if (setter != null) + { + if (!property.Setter!.IsStatic) + setter.Generator.Ldarg_0(); + setter.Generator.Ldarg(1); + setter.Generator.EmitCall(property.Setter, true).Ret(); + } + + var get = _builder.DefineMethod(typedPropertyInfoType, Array.Empty(), + name + "!Property", XamlVisibility.Public, true, false); + + var ctor = typedClrPropertyInfoType.Constructors.First(c => + c.Parameters.Count == 3 && !c.IsStatic); + + var cacheMiss = get.Generator.DefineLabel(); + get.Generator + .Ldsfld(field) + .Brfalse(cacheMiss) + .Ldsfld(field) + .Ret() + .MarkLabel(cacheMiss) + .Ldstr(property.Name); + + EmitFunc(context, get.Generator, getter, funcType); + EmitFunc(context, get.Generator, setter, actionType); get.Generator - .Ldtype(property.PropertyType) .Newobj(ctor) .Stsfld(field) .Ldsfld(field) @@ -204,7 +330,7 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions } codeGen.EmitCall(Get()); - return types.IPropertyInfo; + return typedPropertyInfoType; } } } diff --git a/tests/Avalonia.Base.UnitTests/Data/Core/TypedBindingExpressionTests.Compatibility.cs b/tests/Avalonia.Base.UnitTests/Data/Core/TypedBindingExpressionTests.Compatibility.cs new file mode 100644 index 0000000000..849a7ea0ba --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Data/Core/TypedBindingExpressionTests.Compatibility.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Data.Core; +using Avalonia.Diagnostics; +using Avalonia.Logging; +using Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings; +using Avalonia.Styling; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Base.UnitTests.Data.Core; + +/// +/// Tests which compare the behaviour of with +/// the untyped for bindings which are eligible for the typed path. +/// +/// +/// Each test is run twice: once with a binding which produces a typed expression and once with an +/// equivalent binding which produces an untyped expression. The assertions describe the behaviour +/// of the untyped expression, i.e. the behaviour of the binding before typed binding expressions +/// were introduced, so a failure in the typed: true case is a user-visible breaking change. +/// +public partial class TypedBindingExpressionTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Null_DataContext_Should_Not_Override_Style_Setter(bool typed) + { + // A binding which has no value must not contribute a value to the target property, + // otherwise the property's default value is applied at LocalValue priority, hiding the + // value from the style setter. + var target = new TextBlock(); + var root = new TestRoot + { + Styles = + { + new Style(x => x.OfType()) + { + Setters = { new Setter(TextBlock.TextProperty, "styled") }, + }, + }, + Child = target + }; + + AssertExpressionType(typed, target.Bind(TextBlock.TextProperty, CreateStringBinding(typed))); + + Assert.Equal("styled", target.Text); + Assert.Equal(BindingPriority.Style, target.GetDiagnostic(TextBlock.TextProperty).Priority); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Null_DataContext_Should_Not_Break_Property_Inheritance(bool typed) + { + // As above, but for an inherited property: applying the property's default value at + // LocalValue priority stops the value being inherited from the parent. + var target = new TextBlock(); + var root = new TestRoot + { + Child = target, + [TextBlock.FontSizeProperty] = 30.0, + }; + + AssertExpressionType(typed, target.Bind(TextBlock.FontSizeProperty, CreateDoubleBinding(typed))); + + Assert.Equal(30.0, target.FontSize); + Assert.Equal(BindingPriority.Inherited, target.GetDiagnostic(TextBlock.FontSizeProperty).Priority); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Style_Priority_Binding_With_Null_DataContext_Should_Not_Break_Property_Inheritance(bool typed) + { + // The same problem occurs for bindings at a priority other than LocalValue, such as a + // binding in a style setter. + var target = new TextBlock(); + var root = new TestRoot + { + Child = target, + [TextBlock.FontSizeProperty] = 30.0, + }; + + var binding = CreateDoubleBinding(typed); + binding.Priority = BindingPriority.Style; + + AssertExpressionType(typed, target.Bind(TextBlock.FontSizeProperty, binding)); + + Assert.Equal(30.0, target.FontSize); + Assert.Equal(BindingPriority.Inherited, target.GetDiagnostic(TextBlock.FontSizeProperty).Priority); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Setting_Object_Target_Property_To_A_Different_Type_Should_Not_Throw(bool typed) + { + // The binding value type only needs to be assignable to the target property type, so a + // string can be bound to an object-typed property. Writing a value of any other type to + // that property must not throw when the binding reads the new target value. + var data = new ViewModel { StringValue = "foo" }; + var target = new TextBlock { DataContext = data }; + var root = new TestRoot + { + Child = target + }; + + AssertExpressionType(typed, target.Bind(TextBlock.TagProperty, CreateStringBinding(typed))); + + Assert.Equal("foo", target.Tag); + + var ex = Record.Exception(() => target.Tag = 5); + + Assert.Null(ex); + Assert.Equal(5, target.Tag); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Setting_Object_Target_Property_To_Null_Should_Not_Throw(bool typed) + { + // As above, but with a value-typed binding: writing null to the object-typed target + // property must not throw when the binding reads the new target value. + var data = new ViewModel { DoubleValue = 1.0 }; + var target = new TextBlock { DataContext = data }; + var root = new TestRoot + { + Child = target + }; + + AssertExpressionType(typed, target.Bind(TextBlock.TagProperty, CreateDoubleBinding(typed))); + + Assert.Equal(1.0, target.Tag); + + var ex = Record.Exception(() => target.SetValue(TextBlock.TagProperty, null)); + + Assert.Null(ex); + Assert.Null(target.Tag); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Incompatible_DataContext_Should_Log_A_Binding_Error(bool typed) + { + // When the DataContext isn't of the expected type the untyped expression logs a binding + // error; the typed expression silently produces no value. + var errors = new List(); + + using var sink = TestLogSink.Start((level, area, source, template, values) => + { + if (level >= LogEventLevel.Warning && area == LogArea.Binding) + errors.Add(template); + }); + + var target = new TextBlock { DataContext = new ViewModel { StringValue = "foo" } }; + var root = new TestRoot + { + Child = target + }; + + AssertExpressionType(typed, target.Bind(TextBlock.TextProperty, CreateStringBinding(typed))); + + Assert.Equal("foo", target.Text); + + target.DataContext = new object(); + + Assert.NotEmpty(errors); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Source_Getter_Exception_Should_Clear_The_Target_Value(bool typed) + { + // When the source getter throws, the untyped expression reports a binding error and + // reverts the target to its default value; the typed expression silently leaves the stale + // value in place. + var errors = new List(); + + using var sink = TestLogSink.Start((level, area, source, template, values) => + { + if (level >= LogEventLevel.Warning && area == LogArea.Binding) + errors.Add(template); + }); + + var data = new ViewModel { StringValue = "foo" }; + var target = new TextBlock { DataContext = data }; + var root = new TestRoot + { + Child = target + }; + + AssertExpressionType(typed, target.Bind(TextBlock.TextProperty, CreateStringBinding(typed))); + + Assert.Equal("foo", target.Text); + + data.ThrowOnGet = true; + data.RaisePropertyChanged(nameof(ViewModel.StringValue)); + + Assert.Null(target.Text); + Assert.NotEmpty(errors); + } + + private static void AssertExpressionType(bool typed, BindingExpressionBase expression) + { + if (typed) + Assert.IsNotType(expression); + else + Assert.IsType(expression); + } + + private static CompiledBinding CreateStringBinding(bool typed, BindingMode mode = BindingMode.OneWay) + { + if (typed) + return CreateBinding(mode); + + var path = new CompiledBindingPathBuilder().Property( + new ClrPropertyInfo( + nameof(ViewModel.StringValue), + o => ((ViewModel)o).StringValue, + (o, v) => ((ViewModel)o).StringValue = (string?)v, + typeof(string)), + PropertyInfoAccessorFactory.CreateInpcPropertyAccessor).Build(); + + return new CompiledBinding(path) { Mode = mode }; + } + + private static CompiledBinding CreateDoubleBinding(bool typed, BindingMode mode = BindingMode.OneWay) + { + var builder = new CompiledBindingPathBuilder(); + + if (typed) + { + builder.Property( + new ClrPropertyInfo( + nameof(ViewModel.DoubleValue), + o => o.DoubleValue, + (o, v) => o.DoubleValue = v), + PropertyInfoAccessorFactory.CreateInpcPropertyAccessor, + false); + } + else + { + builder.Property( + new ClrPropertyInfo( + nameof(ViewModel.DoubleValue), + o => ((ViewModel)o).DoubleValue, + (o, v) => ((ViewModel)o).DoubleValue = (double)v!, + typeof(double)), + PropertyInfoAccessorFactory.CreateInpcPropertyAccessor); + } + + return new CompiledBinding(builder.Build()) { Mode = mode }; + } +} diff --git a/tests/Avalonia.Base.UnitTests/Data/Core/TypedBindingExpressionTests.cs b/tests/Avalonia.Base.UnitTests/Data/Core/TypedBindingExpressionTests.cs new file mode 100644 index 0000000000..db75726e64 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Data/Core/TypedBindingExpressionTests.cs @@ -0,0 +1,515 @@ +using System; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Data.Core; +using Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Base.UnitTests.Data.Core; + +public partial class TypedBindingExpressionTests : ScopedTestBase +{ + [Fact] + public void Should_Produce_TypedBindingExpression() + { + var binding = CreateBinding(); + var target = new TextBlock(); + + BindAndAssert(target, binding); + } + + [Fact] + public void Should_Bind_String_Value() + { + var data = new ViewModel { StringValue = "Hello" }; + var target = CreateTarget(data); + + Assert.Equal("Hello", target.Text); + } + + [Fact] + public void OneWay_Binding_Should_Track_String_Value() + { + var data = new ViewModel { StringValue = "Hello" }; + var target = CreateTarget(data, mode: BindingMode.OneWay); + + Assert.Equal("Hello", target.Text); + + data.StringValue = "World"; + + Assert.Equal("World", target.Text); + } + + [Fact] + public void OneWay_Binding_Should_Track_DataContext() + { + var data1 = new ViewModel { StringValue = "Hello" }; + var data2 = new ViewModel { StringValue = "World" }; + var target = CreateTarget(data1, mode: BindingMode.OneWay); + + Assert.Equal("Hello", target.Text); + + target.DataContext = data2; + + Assert.Equal("World", target.Text); + } + + // The name of this test makes no sense in English but keeping it as it matches the name of + // the test in BindingExpressionTests. + [Fact] + public void OneWay_Binding_Updates_Target_When_Changes_And_Source_Raises_PropertyChanged() + { + var data = new ViewModel { StringValue = "foo" }; + var target = CreateTarget(data, mode: BindingMode.OneWay); + + Assert.Equal("foo", target.Text); + + target.SetCurrentValue(TextBlock.TextProperty, "bar"); + + Assert.Equal("bar", target.Text); + + data.RaisePropertyChanged(nameof(data.StringValue)); + + Assert.Equal("foo", target.Text); + } + + [Fact] + public void TwoWay_Binding_Writes_Value_To_Source() + { + var source = new ViewModel { StringValue = "Hello" }; + var target = CreateTarget(source, mode: BindingMode.TwoWay); + + Assert.Equal("Hello", target.Text); + + source.StringValue = "World"; + + Assert.Equal("World", target.Text); + + target.Text = "Goodbye"; + + Assert.Equal("Goodbye", source.StringValue); + } + + [Fact] + public void TwoWay_Binding_Does_Not_Write_Back_To_Source_On_Attach() + { + var source = new ViewModel { StringValue = "Hello" }; + var setsAfterConstruction = source.StringValueSetCount; + + var target = CreateTarget(source, mode: BindingMode.TwoWay); + + Assert.Equal("Hello", target.Text); + + // Pushing the source value to the target must not echo it straight back to the source. + Assert.Equal(setsAfterConstruction, source.StringValueSetCount); + } + + [Fact] + public void TwoWay_Binding_Does_Not_Echo_Source_Change_Back_To_Source() + { + var source = new ViewModel { StringValue = "Hello" }; + var target = CreateTarget(source, mode: BindingMode.TwoWay); + + var before = source.StringValueSetCount; + + source.StringValue = "World"; // One setter call: this assignment. + + Assert.Equal("World", target.Text); + Assert.Equal(before + 1, source.StringValueSetCount); + } + + [Fact] + public void OneTime_Binding_Sets_Target_Only_Once_If_Data_Context_Does_Not_Change() + { + var data = new ViewModel { StringValue = "foo" }; + var target = CreateTarget(data, mode: BindingMode.OneTime); + + Assert.Equal("foo", target.Text); + + data.StringValue = "bar"; + + Assert.Equal("foo", target.Text); + } + + [Fact] + public void OneTime_Binding_Sets_Target_When_Data_Context_Changes() + { + var data = new ViewModel { StringValue = "foo" }; + var target = CreateTarget(data, mode: BindingMode.OneTime); + + Assert.Equal("foo", target.Text); + + target.DataContext = new ViewModel { StringValue = "bar" }; + + Assert.Equal("bar", target.Text); + } + + [Fact] + public void OneTime_Binding_Waits_For_DataContext() + { + var target = CreateTarget(null, mode: BindingMode.OneTime); + + Assert.Null(target.Text); + } + + [Fact] + public void OneTime_Binding_Waits_For_DataContext_With_Matching_Property_Name() + { + var data1 = new { Baz = "baz" }; + var data2 = new ViewModel { StringValue = "foo" }; + var target = CreateTarget(null, mode: BindingMode.OneTime); + + target.DataContext = data1; + Assert.Null(target.Text); + + target.DataContext = data2; + Assert.Equal("foo", target.Text); + + data2.StringValue = "bar"; + Assert.Equal("foo", target.Text); + } + + [Fact] + public void OneTime_Binding_Waits_For_DataContext_With_Matching_Property_Type() + { + var data1 = new { StringValue = 1.5 }; + var data2 = new ViewModel { StringValue = "foo" }; + var target = CreateTarget(null, mode: BindingMode.OneTime); + + target.DataContext = data1; + Assert.Null(target.Text); + + target.DataContext = data2; + Assert.Equal("foo", target.Text); + + data2.StringValue = "bar"; + Assert.Equal("foo", target.Text); + } + + [Fact] + public void OneWayToSource_Binding_Updates_Source_When_Target_Changes() + { + var data = new ViewModel(); + var target = CreateTarget(data, mode: BindingMode.OneWayToSource); + + Assert.Null(data.StringValue); + + target.Text = "foo"; + Assert.Equal("foo", data.StringValue); + } + + [Fact] + public void OneWayToSource_Binding_Does_Not_Update_Target_When_Source_Changes() + { + var data = new ViewModel(); + var target = CreateTarget(data, mode: BindingMode.OneWayToSource); + + target.Text = "foo"; + Assert.Equal("foo", data.StringValue); + + data.StringValue = "bar"; + Assert.Equal("foo", target.Text); + } + + [Fact] + public void OneWayToSource_Binding_Updates_Source_When_DataContext_Changes() + { + var data1 = new ViewModel(); + var data2 = new ViewModel(); + var target = CreateTarget(data1, mode: BindingMode.OneWayToSource); + + target.Text = "foo"; + Assert.Equal("foo", data1.StringValue); + + target.DataContext = data2; + Assert.Equal("foo", data2.StringValue); + } + + [Fact] + public void Can_Bind_Readonly_Property_OneWayToSource() + { + var data = new ViewModel(); + var target = new SelectableTextBlock + { + DataContext = data, + Text = "foobar", + SelectionStart = 0, + SelectionEnd = 3 + }; + + Assert.Equal("foo", target.SelectedText); + + var binding = CreateBinding(mode: BindingMode.OneWayToSource); + target.Bind(SelectableTextBlock.SelectedTextProperty, binding); + + Assert.Equal("foo", data.StringValue); + + target.SelectionEnd = 4; + + // TODO: Uncomment when https://github.com/AvaloniaUI/Avalonia/issues/21461 fixed. + //Assert.Equal("foob", data.StringValue); + } + + [Fact] + public void Can_Bind_String_To_Object() + { + var log = string.Empty; + using var logger = TestLogSink.Start((_, _, _, m, _) => log += m); + var source = new ViewModel { StringValue = "Hello" }; + var binding = CreateBinding(); + var target = new TextBlock { DataContext = source }; + var expression = target.Bind(TextBlock.TagProperty, binding); + + Assert.IsType>(expression); + } + + [Fact] + public void Disposing_Binding_Unsubscribes_From_Source() + { + var data = new ViewModel { StringValue = "foo" }; + var target = new TextBlock { DataContext = data }; + var binding = CreateBinding(); + var expression = target.Bind(TextBlock.TextProperty, binding); + + Assert.Equal("foo", target.Text); + Assert.Equal(1, data.PropertyChangedSubscriptionCount); + + expression.Dispose(); + + Assert.Equal(0, data.PropertyChangedSubscriptionCount); + + // Source changes no longer propagate to the (now unbound) target. + data.StringValue = "bar"; + Assert.NotEqual("bar", target.Text); + } + + [Fact] + public void Rebinding_Same_Property_Unsubscribes_Previous_Binding() + { + var data = new ViewModel { StringValue = "foo" }; + var target = new TextBlock { DataContext = data }; + + target.Bind(TextBlock.TextProperty, CreateBinding()); + target.Bind(TextBlock.TextProperty, CreateBinding()); + + // The first binding should have been disposed when the second was applied, leaving a + // single subscription rather than two. + Assert.Equal(1, data.PropertyChangedSubscriptionCount); + } + + [Fact] + public void Should_Not_Produce_TypedBindingExpression_When_Binding_String_To_Double() + { + var log = string.Empty; + using var logger = TestLogSink.Start((_, _, _, m, _) => log += m); + var source = new ViewModel { StringValue = "Hello" }; + var binding = CreateBinding(); + var target = new TextBlock { DataContext = source }; + var expression = target.Bind(TextBlock.OpacityProperty, binding); + + Assert.IsType(expression); + } + + [Fact] + public void Should_Not_Produce_TypedBindingExpression_When_Converter_Is_Present() + { + var binding = CreateBinding(); + binding.Converter = new FuncValueConverter(s => s); + + var target = new TextBlock(); + var expression = target.Bind(TextBlock.TextProperty, binding); + + Assert.IsType(expression); + } + + [Fact] + public void Should_Not_Produce_TypedBindingExpression_When_Binding_DataContext() + { + var log = string.Empty; + using var logger = TestLogSink.Start((_, _, _, m, _) => log += m); + var source = new ViewModel { StringValue = "Hello" }; + var binding = CreateBinding(); + var target = new TextBlock(); + var expression = target.Bind(TextBlock.DataContextProperty, binding); + + Assert.IsType(expression); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void OneWay_Binding_Updates_Target_When_Source_Raises_PropertyChanged_For_All_Properties( + string? allPropertiesName) + { + // A null or empty PropertyName means "all properties changed" per the INotifyPropertyChanged + // contract, so the binding must re-read its source value. + var data = new ViewModel { StringValue = "foo" }; + var target = CreateTarget(data, mode: BindingMode.OneWay); + + Assert.Equal("foo", target.Text); + + data.SetStringValueWithoutNotification("bar"); + data.RaisePropertyChanged(allPropertiesName); + + Assert.Equal("bar", target.Text); + } + + [Fact] + public void Getter_Exception_Does_Not_Propagate_When_Source_Raises_PropertyChanged() + { + // The untyped binding path swallows getter exceptions to avoid crashing the UI thread; the + // typed path must do the same rather than letting them escape into the event handler. + var data = new ViewModel { StringValue = "foo" }; + var target = CreateTarget(data, mode: BindingMode.OneWay); + + Assert.Equal("foo", target.Text); + + data.ThrowOnGet = true; + + var ex = Record.Exception(() => data.RaisePropertyChanged(nameof(ViewModel.StringValue))); + + Assert.Null(ex); + } + + [Fact] + public void Should_Not_Produce_TypedBindingExpression_For_ReadOnly_Source_In_TwoWay() + { + // A read-only source property cannot be written back to in TwoWay/OneWayToSource modes, so + // the untyped path (which fails silently) must be used instead. + var propertyInfo = new ClrPropertyInfo( + nameof(ViewModel.StringValue), + v => v.StringValue, + setter: null); + var binding = CreateBinding(propertyInfo, mode: BindingMode.TwoWay); + var target = new TextBlock { DataContext = new ViewModel() }; + + var expression = target.Bind(TextBlock.TextProperty, binding); + + Assert.IsType(expression); + } + + [Fact] + public void Should_Not_Produce_TypedBindingExpression_When_Target_Type_Is_Wider_In_TwoWay() + { + // The source is a string but the target property is object. The forward assignment is valid + // but writing an arbitrary object back to the string source could throw, so the untyped path + // must be used. + var source = new ViewModel { StringValue = "Hello" }; + var binding = CreateBinding(mode: BindingMode.TwoWay); + var target = new TextBlock { DataContext = source }; + + var expression = target.Bind(TextBlock.TagProperty, binding); + + Assert.IsType(expression); + } + + [Fact] + public void Should_Not_Produce_TypedBindingExpression_For_Non_StyledElement_Target() + { + // TypedBindingExpression only supports StyledElement targets; other AvaloniaObjects (e.g. + // Application, which is an IDataContextProvider but not a StyledElement) must use the untyped + // path rather than throwing at runtime. + var binding = CreateBinding(); + var target = new NonStyledTarget { DataContext = new ViewModel { StringValue = "Hello" } }; + + var expression = target.Bind(NonStyledTarget.ValueProperty, binding); + + Assert.IsType(expression); + } + + private static TypedBindingExpression BindAndAssert(StyledElement target, BindingBase binding) + { + var expression = target.Bind(TextBlock.TextProperty, binding); + return Assert.IsType>(expression); + } + + private static CompiledBinding CreateBinding(BindingMode mode = BindingMode.OneWay) + { + var propertyInfo = new ClrPropertyInfo( + nameof(ViewModel.StringValue), + v => v.StringValue, + (o, v) => o.StringValue = v); + return CreateBinding(propertyInfo, mode); + } + + private static CompiledBinding CreateBinding( + IPropertyInfo propertyInfo, + BindingMode mode = BindingMode.OneWay) + { + var path = new CompiledBindingPathBuilder().Property( + propertyInfo, + PropertyInfoAccessorFactory.CreateInpcPropertyAccessor, + false).Build(); + return new CompiledBinding(path) { Mode = mode, }; + } + + private static TextBlock CreateTarget(ViewModel? data, BindingMode mode = BindingMode.OneWay) + { + var result = new TextBlock { DataContext = data }; + var binding = CreateBinding(mode); + BindAndAssert(result, binding); + return result; + } + + private class ViewModel : NotifyingBase + { + private string? _stringValue; + private double _doubleValue; + + // Counts every setter invocation so tests can assert the binding doesn't write spurious + // values back to the source. PropertyChanged is only raised on a real change. + public int StringValueSetCount { get; private set; } + + // When set, the getter throws so tests can verify getter exceptions don't escape the + // binding's PropertyChanged handler. + public bool ThrowOnGet { get; set; } + + public string? StringValue + { + get + { + if (ThrowOnGet) + throw new InvalidOperationException("Getter failed."); + return _stringValue; + } + set + { + ++StringValueSetCount; + SetField(ref _stringValue, value); + } + } + + public double DoubleValue + { + get => _doubleValue; + set => SetField(ref _doubleValue, value); + } + + // Mutates the backing field without raising PropertyChanged, so tests can then raise an + // "all properties changed" notification (null/empty name) and observe the binding react. + public void SetStringValueWithoutNotification(string? value) => _stringValue = value; + } + + private class NonStyledTarget : AvaloniaObject, IDataContextProvider + { + public static readonly StyledProperty DataContextProperty = + StyledElement.DataContextProperty.AddOwner(); + + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public object? DataContext + { + get => GetValue(DataContextProperty); + set => SetValue(DataContextProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + } +} diff --git a/tests/Avalonia.Benchmarks/Data/TypedBinding_Setup.cs b/tests/Avalonia.Benchmarks/Data/TypedBinding_Setup.cs new file mode 100644 index 0000000000..94946a4d9c --- /dev/null +++ b/tests/Avalonia.Benchmarks/Data/TypedBinding_Setup.cs @@ -0,0 +1,143 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Data.Core; +using Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings; +using BenchmarkDotNet.Attributes; + +#nullable enable + +namespace Avalonia.Benchmarks.Data; + +// Compares the setup (create instance + attach + dispose) cost of the three kinds of binding to +// a DataContext property: +// +// - Typed: the new TypedBindingExpression (built here by hand as the XAML compiler does +// not yet produce it). Strongly typed, does not box. +// - CompiledBinding: the untyped CompiledBinding that the XAML compiler produces today for +// `{Binding Value}` (uses an InpcPropertyAccessor + boxed values). +// - Reflection: the classic reflection-based `Binding`. +// +// An `int` property is used as boxing avoidance is the main selling point of the typed expression. +// The binding instances are created once and reused across the loop (as they are in real XAML) +// so that only the per-bind cost is measured, not path/expression construction. +[MemoryDiagnoser] +public class TypedBinding_Setup +{ + private readonly TestData _data = new(); + private readonly TestControl _target = new(); + private readonly CompiledBinding _typedOneWay = CreateTypedBinding(BindingMode.OneWay); + private readonly CompiledBinding _typedTwoWay = CreateTypedBinding(BindingMode.TwoWay); + private readonly CompiledBinding _compiledOneWay = CreateCompiledBinding(BindingMode.OneWay); + private readonly CompiledBinding _compiledTwoWay = CreateCompiledBinding(BindingMode.TwoWay); + private readonly Binding _reflectionOneWay = new(nameof(TestData.IntValue)) { Mode = BindingMode.OneWay }; + private readonly Binding _reflectionTwoWay = new(nameof(TestData.IntValue)) { Mode = BindingMode.TwoWay }; + + public TypedBinding_Setup() + { + _target.DataContext = _data; + } + + [Benchmark] + public void Setup_Typed_OneWay() + { + for (var i = 0; i < 100; ++i) + using (_target.Bind(TestControl.IntValueProperty, _typedOneWay)) { } + } + + [Benchmark] + public void Setup_CompiledBinding_OneWay() + { + for (var i = 0; i < 100; ++i) + using (_target.Bind(TestControl.IntValueProperty, _compiledOneWay)) { } + } + + [Benchmark] + public void Setup_Reflection_OneWay() + { + for (var i = 0; i < 100; ++i) + using (_target.Bind(TestControl.IntValueProperty, _reflectionOneWay)) { } + } + + [Benchmark] + public void Setup_Typed_TwoWay() + { + for (var i = 0; i < 100; ++i) + using (_target.Bind(TestControl.IntValueProperty, _typedTwoWay)) { } + } + + [Benchmark] + public void Setup_CompiledBinding_TwoWay() + { + for (var i = 0; i < 100; ++i) + using (_target.Bind(TestControl.IntValueProperty, _compiledTwoWay)) { } + } + + [Benchmark] + public void Setup_Reflection_TwoWay() + { + for (var i = 0; i < 100; ++i) + using (_target.Bind(TestControl.IntValueProperty, _reflectionTwoWay)) { } + } + + private static CompiledBinding CreateTypedBinding(BindingMode mode) + { + var propertyInfo = new ClrPropertyInfo( + nameof(TestData.IntValue), + v => v.IntValue, + (o, v) => o.IntValue = v); + var path = new CompiledBindingPathBuilder().Property( + propertyInfo, + PropertyInfoAccessorFactory.CreateInpcPropertyAccessor, + false).Build(); + return new CompiledBinding(path) { Mode = mode }; + } + + // Builds the untyped CompiledBinding the same way the XAML compiler does today: a + // CompiledBindingPath with an (object-typed) ClrPropertyInfo accessed via an + // InpcPropertyAccessor. Differs from the typed binding only in the path element used. + private static CompiledBinding CreateCompiledBinding(BindingMode mode) + { + var propertyInfo = new ClrPropertyInfo( + nameof(TestData.IntValue), + o => ((TestData)o).IntValue, + (o, v) => ((TestData)o).IntValue = (int)v!, + typeof(int)); + var path = new CompiledBindingPathBuilder() + .Property(propertyInfo, PropertyInfoAccessorFactory.CreateInpcPropertyAccessor) + .Build(); + return new CompiledBinding(path) { Mode = mode }; + } + + private class TestControl : Control + { + public static readonly StyledProperty IntValueProperty = + AvaloniaProperty.Register(nameof(IntValue)); + + public int IntValue + { + get => GetValue(IntValueProperty); + set => SetValue(IntValueProperty, value); + } + } + + private class TestData : INotifyPropertyChanged + { + private int _intValue; + + public event PropertyChangedEventHandler? PropertyChanged; + + public int IntValue + { + get => _intValue; + set + { + if (_intValue == value) + return; + _intValue = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IntValue))); + } + } + } +} diff --git a/tests/Avalonia.Benchmarks/Data/TypedBinding_Values.cs b/tests/Avalonia.Benchmarks/Data/TypedBinding_Values.cs new file mode 100644 index 0000000000..16cdb85233 --- /dev/null +++ b/tests/Avalonia.Benchmarks/Data/TypedBinding_Values.cs @@ -0,0 +1,162 @@ +using System.ComponentModel; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Data.Core; +using Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings; +using BenchmarkDotNet.Attributes; + +#nullable enable + +namespace Avalonia.Benchmarks.Data; + +// Compares the steady-state cost of pushing values through the three kinds of binding to a +// DataContext property: see TypedBinding_Setup for a description of each kind. +// +// The binding is attached once and then 100 value changes are pushed through it. An `int` property +// is used so that the OneWay benchmarks highlight the per-value boxing that the typed expression +// avoids; the allocation column (from MemoryDiagnoser) is the interesting one here. +[MemoryDiagnoser] +public class TypedBinding_Values +{ + private TestData _data = null!; + private TestControl _target = null!; + + [GlobalSetup] + public void Setup() + { + _data = new TestData(); + _target = new TestControl { DataContext = _data }; + } + + [Benchmark] + public void Produce_Typed_OneWay() + { + _data.IntValue = -1; + using var d = _target.Bind(TestControl.IntValueProperty, CreateTypedBinding(BindingMode.OneWay)); + + for (var i = 0; i < 100; ++i) + _data.IntValue = i; + } + + [Benchmark] + public void Produce_CompiledBinding_OneWay() + { + _data.IntValue = -1; + using var d = _target.Bind(TestControl.IntValueProperty, CreateCompiledBinding(BindingMode.OneWay)); + + for (var i = 0; i < 100; ++i) + _data.IntValue = i; + } + + [Benchmark] + public void Produce_Reflection_OneWay() + { + _data.IntValue = -1; + using var d = _target.Bind(TestControl.IntValueProperty, CreateReflectionBinding(BindingMode.OneWay)); + + for (var i = 0; i < 100; ++i) + _data.IntValue = i; + } + + [Benchmark] + public void Produce_Typed_TwoWay() + { + _data.IntValue = -1; + using var d = _target.Bind(TestControl.IntValueProperty, CreateTypedBinding(BindingMode.TwoWay)); + + for (var i = 0; i < 100; ++i) + { + _data.IntValue = i * 2; + _target.IntValue = (i * 2) + 1; + } + } + + [Benchmark] + public void Produce_CompiledBinding_TwoWay() + { + _data.IntValue = -1; + using var d = _target.Bind(TestControl.IntValueProperty, CreateCompiledBinding(BindingMode.TwoWay)); + + for (var i = 0; i < 100; ++i) + { + _data.IntValue = i * 2; + _target.IntValue = (i * 2) + 1; + } + } + + [Benchmark] + public void Produce_Reflection_TwoWay() + { + _data.IntValue = -1; + using var d = _target.Bind(TestControl.IntValueProperty, CreateReflectionBinding(BindingMode.TwoWay)); + + for (var i = 0; i < 100; ++i) + { + _data.IntValue = i * 2; + _target.IntValue = (i * 2) + 1; + } + } + + private static CompiledBinding CreateTypedBinding(BindingMode mode) + { + var propertyInfo = new ClrPropertyInfo( + nameof(TestData.IntValue), + v => v.IntValue, + (o, v) => o.IntValue = v); + var path = new CompiledBindingPathBuilder().Property( + propertyInfo, + PropertyInfoAccessorFactory.CreateInpcPropertyAccessor, + false).Build(); + return new CompiledBinding(path) { Mode = mode }; + } + + // Builds the untyped CompiledBinding the same way the XAML compiler does today: a + // CompiledBindingPath with an (object-typed) ClrPropertyInfo accessed via an + // InpcPropertyAccessor. Differs from the typed binding only in the path element used. + private static CompiledBinding CreateCompiledBinding(BindingMode mode) + { + var propertyInfo = new ClrPropertyInfo( + nameof(TestData.IntValue), + o => ((TestData)o).IntValue, + (o, v) => ((TestData)o).IntValue = (int)v!, + typeof(int)); + var path = new CompiledBindingPathBuilder() + .Property(propertyInfo, PropertyInfoAccessorFactory.CreateInpcPropertyAccessor) + .Build(); + return new CompiledBinding(path) { Mode = mode }; + } + + private static Binding CreateReflectionBinding(BindingMode mode) + => new(nameof(TestData.IntValue)) { Mode = mode }; + + private class TestControl : Control + { + public static readonly StyledProperty IntValueProperty = + AvaloniaProperty.Register(nameof(IntValue)); + + public int IntValue + { + get => GetValue(IntValueProperty); + set => SetValue(IntValueProperty, value); + } + } + + private class TestData : INotifyPropertyChanged + { + private int _intValue; + + public event PropertyChangedEventHandler? PropertyChanged; + + public int IntValue + { + get => _intValue; + set + { + if (_intValue == value) + return; + _intValue = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IntValue))); + } + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs index 8c9a845050..cb4bc525f7 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs @@ -149,6 +149,46 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void CompiledBinding_To_DataValidation_Property_Reports_Data_Validation_Errors() + { + // This binding is shape-eligible for the typed binding expression (a directly + // assignable single-property DataContext binding), which does not support data + // validation. Because TextBox.Text enables data validation it must fall back to the + // untyped BindingExpression and still surface validation errors. + var path = new CompiledBindingPathBuilder() + .Property( + new ClrPropertyInfo( + nameof(IndeiStringTest.Value), + o => o.Value, + (o, v) => o.Value = v), + PropertyInfoAccessorFactory.CreateInpcPropertyAccessor, + acceptsNull: true) + .Build(); + + using (UnitTestApplication.Start(Services)) + { + var target = new TextBox + { + DataContext = new IndeiStringTest(), + [!TextBox.TextProperty] = new CompiledBindingExtension + { + Path = path, + Mode = BindingMode.TwoWay, + }, + Template = CreateTemplate(), + }; + + target.ApplyTemplate(); + + Assert.False(DataValidationErrors.GetHasErrors(target)); + target.Text = "bad"; + Assert.True(DataValidationErrors.GetHasErrors(target)); + target.Text = "good"; + Assert.False(DataValidationErrors.GetHasErrors(target)); + } + } + private static TestServices Services => TestServices.MockThreadingInterface.With( standardCursorFactory: Mock.Of(), textShaperImpl: new HarfBuzzTextShaper(), @@ -226,5 +266,36 @@ namespace Avalonia.Controls.UnitTests return Array.Empty(); } } + + private class IndeiStringTest : INotifyDataErrorInfo + { + private readonly Dictionary> _errors = new(); + private string? _value; + + public string? Value + { + get => _value; + set + { + _value = value; + if (value == "bad") + _errors[nameof(Value)] = new[] { "Invalid" }; + else + _errors.Remove(nameof(Value)); + ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value))); + } + } + + public bool HasErrors => _errors.Count > 0; + + public event EventHandler? ErrorsChanged; + + public IEnumerable GetErrors(string? propertyName) + { + if (propertyName is not null && _errors.TryGetValue(propertyName, out var result)) + return result; + return Array.Empty(); + } + } } } diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/CompiledBindingExtensionTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/CompiledBindingExtensionTests.cs index 16e4dc6623..6ec987edc1 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/CompiledBindingExtensionTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/CompiledBindingExtensionTests.cs @@ -640,7 +640,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions // Assert DataGridLikeColumn.Binding data type. var compiledPath = ((CompiledBinding)column.Binding!).Path; - var node = Assert.IsType(Assert.Single(compiledPath!.Elements)); + var node = Assert.IsAssignableFrom(Assert.Single(compiledPath!.Elements)); Assert.Equal(typeof(string), node.Property.PropertyType); Assert.Equal(nameof(TestData.StringProperty), node.Property.Name); @@ -683,7 +683,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions // Assert DataGridLikeColumn.Binding data type. var compiledPath = ((CompiledBinding)column.Binding!).Path; - var node = Assert.IsType(Assert.Single(compiledPath!.Elements)); + var node = Assert.IsAssignableFrom(Assert.Single(compiledPath!.Elements)); Assert.Equal(typeof(int), node.Property.PropertyType); // Assert DataGridLikeColumn.Template data type by evaluating the template. @@ -728,7 +728,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions // Assert DataGridLikeColumn.Binding data type. var compiledPath = ((CompiledBinding)column.Binding!).Path; - var node = Assert.IsType(Assert.Single(compiledPath!.Elements)); + var node = Assert.IsAssignableFrom(Assert.Single(compiledPath!.Elements)); Assert.Equal(typeof(int), node.Property.PropertyType); // Assert DataGridLikeColumn.Template data type by evaluating the template. @@ -2235,7 +2235,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions var control = (AssignBindingControl)AvaloniaRuntimeXamlLoader.Load(xaml); var compiledPath = ((CompiledBinding)control.X!).Path; - var node = Assert.IsType(Assert.Single(compiledPath!.Elements)); + var node = Assert.IsAssignableFrom(Assert.Single(compiledPath!.Elements)); Assert.Equal(typeof(string), node.Property.PropertyType); } } @@ -2253,7 +2253,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions var control = (AssignBindingControl)AvaloniaRuntimeXamlLoader.Load(xaml); var compiledPath = ((CompiledBinding)control.X!).Path; - var node = Assert.IsType(Assert.Single(compiledPath!.Elements)); + var node = Assert.IsAssignableFrom(Assert.Single(compiledPath!.Elements)); Assert.Equal(typeof(string), node.Property.PropertyType); } } @@ -2272,7 +2272,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions new RuntimeXamlLoaderConfiguration { UseCompiledBindingsByDefault = true }); var compiledPath = ((CompiledBinding)control.X!).Path; - var node = Assert.IsType(Assert.Single(compiledPath!.Elements)); + var node = Assert.IsAssignableFrom(Assert.Single(compiledPath!.Elements)); Assert.Equal(typeof(string), node.Property.PropertyType); } } @@ -2573,6 +2573,196 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions } } + [Fact] + public void Emits_TypedBindingExpression_For_Simple_DataContext_Binding() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var window = (Window)AvaloniaRuntimeXamlLoader.Load(@" + + +"); + var textBlock = window.GetControl("textBlock"); + window.DataContext = new TestDataContext { StringProperty = "hello" }; + + var expression = BindingOperations.GetBindingExpressionBase(textBlock, TextBlock.TextProperty); + Assert.IsType>(expression); + Assert.Equal("hello", textBlock.Text); + } + } + + [Theory] + [InlineData("OneWay")] + [InlineData("TwoWay")] + [InlineData("OneWayToSource")] + [InlineData("OneTime")] + public void Emits_TypedBindingExpression_For_All_Standard_Modes(string mode) + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var window = (Window)AvaloniaRuntimeXamlLoader.Load($@" + + +"); + var textBlock = window.GetControl("textBlock"); + window.DataContext = new TestDataContext { StringProperty = "x" }; + + var expression = BindingOperations.GetBindingExpressionBase(textBlock, TextBlock.TextProperty); + Assert.IsType>(expression); + } + } + + [Fact] + public void Emits_TypedBindingExpression_For_Binding_With_CompileBindings_True() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var window = (Window)AvaloniaRuntimeXamlLoader.Load(@" + + +"); + var textBlock = window.GetControl("textBlock"); + window.DataContext = new TestDataContext { StringProperty = "hi" }; + + var expression = BindingOperations.GetBindingExpressionBase(textBlock, TextBlock.TextProperty); + Assert.IsType>(expression); + } + } + + [Fact] + public void Falls_Back_To_BindingExpression_For_Nested_Path() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var window = (Window)AvaloniaRuntimeXamlLoader.Load(@" + + +"); + var textBlock = window.GetControl("textBlock"); + window.DataContext = new TestDataContext { NestedGenericString = new TestDataContext.NestedGeneric { Value = "v" } }; + + var expression = BindingOperations.GetBindingExpressionBase(textBlock, TextBlock.TextProperty); + Assert.IsType(expression); + } + } + + [Fact] + public void Falls_Back_To_BindingExpression_When_Converter_Set() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var window = (Window)AvaloniaRuntimeXamlLoader.Load(@" + + +"); + var textBlock = window.GetControl("textBlock"); + window.DataContext = new TestDataContext { StringProperty = "x" }; + + var expression = BindingOperations.GetBindingExpressionBase(textBlock, TextBlock.TextProperty); + Assert.IsType(expression); + } + } + + [Fact] + public void Falls_Back_To_BindingExpression_For_DataValidation_Enabled_Property() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + // TextBox.Text enables data validation, which TypedBindingExpression does not + // support, so the binding must fall back to the untyped BindingExpression even + // though it is otherwise eligible for the typed path. + var window = (Window)AvaloniaRuntimeXamlLoader.Load(@" + + +"); + var textBox = window.GetControl("textBox"); + window.DataContext = new TestDataContext { StringProperty = "hello" }; + + var expression = BindingOperations.GetBindingExpressionBase(textBox, TextBox.TextProperty); + Assert.IsType(expression); + Assert.Equal("hello", textBox.Text); + } + } + + [Fact] + public void Falls_Back_To_BindingExpression_When_StringFormat_Set() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var window = (Window)AvaloniaRuntimeXamlLoader.Load(@" + + +"); + var textBlock = window.GetControl("textBlock"); + window.DataContext = new TestDataContext(); + + var expression = BindingOperations.GetBindingExpressionBase(textBlock, TextBlock.TextProperty); + Assert.IsType(expression); + } + } + + [Fact] + public void Falls_Back_To_BindingExpression_For_Negated_Binding() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var window = (Window)AvaloniaRuntimeXamlLoader.Load(@" + + +"); + var textBlock = window.GetControl("textBlock"); + window.DataContext = new TestDataContext { BoolProperty = true }; + + var expression = BindingOperations.GetBindingExpressionBase(textBlock, TextBlock.TagProperty); + Assert.IsType(expression); + } + } + + [Fact] + public void Falls_Back_To_BindingExpression_For_DataContext_Target() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var window = (Window)AvaloniaRuntimeXamlLoader.Load(@" + + +"); + var textBlock = window.GetControl("textBlock"); + window.DataContext = new TestDataContext { StringProperty = "x" }; + + var expression = BindingOperations.GetBindingExpressionBase(textBlock, StyledElement.DataContextProperty); + Assert.IsType(expression); + } + } + static void Throws(string type, Action cb) { try