diff --git a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj index 9324503cb0..780c05d18d 100644 --- a/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj +++ b/src/Markup/Avalonia.Markup/Avalonia.Markup.csproj @@ -60,6 +60,9 @@ + + + diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs b/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs index 26e1234d34..905cdcd713 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionNode.cs @@ -17,6 +17,7 @@ namespace Avalonia.Markup.Data private WeakReference _target = UnsetReference; private IDisposable _valueSubscription; private IObserver _observer; + private IDisposable _valuePluginSubscription; public ExpressionNode Next { get; set; } @@ -35,6 +36,7 @@ namespace Avalonia.Markup.Data { _valueSubscription?.Dispose(); _valueSubscription = null; + _valuePluginSubscription?.Dispose(); _target = value; if (running) @@ -60,6 +62,8 @@ namespace Avalonia.Markup.Data { _valueSubscription?.Dispose(); _valueSubscription = null; + _valuePluginSubscription?.Dispose(); + _valuePluginSubscription = null; nextSubscription?.Dispose(); _observer = null; }); @@ -117,13 +121,16 @@ namespace Avalonia.Markup.Data if (notification == null) { - if (Next != null) + if (!HandleSpecialValue(value)) { - Next.Target = new WeakReference(value); - } - else - { - _observer.OnNext(value); + if (Next != null) + { + Next.Target = new WeakReference(value); + } + else + { + _observer.OnNext(value); + } } } else @@ -134,16 +141,38 @@ namespace Avalonia.Markup.Data } else if (notification.HasValue) { - if (Next != null) + if (!HandleSpecialValue(notification.Value)) { - Next.Target = new WeakReference(notification.Value); + if (Next != null) + { + Next.Target = new WeakReference(notification.Value); + } + else + { + _observer.OnNext(value); + } } - else + } + } + } + + private bool HandleSpecialValue(object value) + { + if (_valuePluginSubscription == null) + { + var reference = new WeakReference(value); + + foreach (var plugin in ExpressionObserver.ValueHandlers) + { + if (plugin.Match(reference)) { - _observer.OnNext(value); + _valuePluginSubscription = plugin.Start(reference)?.Subscribe(TargetValueChanged); + return true; } } } + + return false; } private BindingNotification TargetNullNotification() diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs index 2e4c98fe82..77d47cbdfc 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionObserver.cs @@ -36,7 +36,18 @@ namespace Avalonia.Markup.Data new List { new IndeiValidationPlugin(), - ExceptionValidationPlugin.Instance, + new ExceptionValidationPlugin(), + }; + + /// + /// An ordered collection of value handlers that can be used to customize the handling + /// of certain values. + /// + public static readonly IList ValueHandlers = + new List + { + new TaskValuePlugin(), + new ObservableValuePlugin(), }; private static readonly object UninitializedValue = new object(); diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs index 63c2b1bf24..8c3c10aeb9 100644 --- a/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs +++ b/src/Markup/Avalonia.Markup/Data/Plugins/ExceptionValidationPlugin.cs @@ -12,11 +12,6 @@ namespace Avalonia.Markup.Data.Plugins /// public class ExceptionValidationPlugin : IDataValidationPlugin { - /// - /// Gets the default instance of the / - /// - public static ExceptionValidationPlugin Instance { get; } = new ExceptionValidationPlugin(); - /// public bool Match(WeakReference reference) => true; diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/IValuePlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/IValuePlugin.cs new file mode 100644 index 0000000000..fb285c6d73 --- /dev/null +++ b/src/Markup/Avalonia.Markup/Data/Plugins/IValuePlugin.cs @@ -0,0 +1,29 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; + +namespace Avalonia.Markup.Data.Plugins +{ + /// + /// Defines how values are observed by an . + /// + public interface IValuePlugin + { + /// + /// Checks whether this plugin handles the specified value. + /// + /// A weak reference to the value. + /// True if the plugin can handle the value; otherwise false. + bool Match(WeakReference reference); + + /// + /// Starts producing output based on the specified value. + /// + /// A weak reference to the object. + /// + /// An observable that produces the output for the value. + /// + IObservable Start(WeakReference reference); + } +} diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/ObservableValuePlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/ObservableValuePlugin.cs new file mode 100644 index 0000000000..a406fc55b9 --- /dev/null +++ b/src/Markup/Avalonia.Markup/Data/Plugins/ObservableValuePlugin.cs @@ -0,0 +1,44 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Reflection; +using System.Threading.Tasks; +using System.Windows.Input; +using Avalonia.Data; + +namespace Avalonia.Markup.Data.Plugins +{ + /// + /// Handles binding to s in an . + /// + public class ObservableValuePlugin : IValuePlugin + { + /// + /// Checks whether this plugin handles the specified value. + /// + /// A weak reference to the value. + /// True if the plugin can handle the value; otherwise false. + public virtual bool Match(WeakReference reference) + { + var target = reference.Target; + + // ReactiveCommand is an IObservable but we want to bind to it, not its value. + return target is IObservable && !(target is ICommand); + } + + /// + /// Starts producing output based on the specified value. + /// + /// A weak reference to the object. + /// + /// An observable that produces the output for the value. + /// + public virtual IObservable Start(WeakReference reference) + { + return reference.Target as IObservable; + } + } +} diff --git a/src/Markup/Avalonia.Markup/Data/Plugins/TaskValuePlugin.cs b/src/Markup/Avalonia.Markup/Data/Plugins/TaskValuePlugin.cs new file mode 100644 index 0000000000..b6fda67503 --- /dev/null +++ b/src/Markup/Avalonia.Markup/Data/Plugins/TaskValuePlugin.cs @@ -0,0 +1,82 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Reflection; +using System.Threading.Tasks; +using Avalonia.Data; + +namespace Avalonia.Markup.Data.Plugins +{ + /// + /// Handles binding to s in an . + /// + public class TaskValuePlugin : IValuePlugin + { + /// + /// Checks whether this plugin handles the specified value. + /// + /// A weak reference to the value. + /// True if the plugin can handle the value; otherwise false. + public virtual bool Match(WeakReference reference) => reference.Target is Task; + + /// + /// Starts producing output based on the specified value. + /// + /// A weak reference to the object. + /// + /// An observable that produces the output for the value. + /// + public virtual IObservable Start(WeakReference reference) + { + var task = reference.Target as Task; + + if (task != null) + { + var resultProperty = task.GetType().GetTypeInfo().GetDeclaredProperty("Result"); + + if (resultProperty != null) + { + switch (task.Status) + { + case TaskStatus.RanToCompletion: + case TaskStatus.Faulted: + return HandleCompleted(task); + default: + var subject = new Subject(); + task.ContinueWith( + x => HandleCompleted(task).Subscribe(subject), + TaskScheduler.FromCurrentSynchronizationContext()) + .ConfigureAwait(false); + return subject; + } + } + } + + return Observable.Empty(); + } + + protected IObservable HandleCompleted(Task task) + { + var resultProperty = task.GetType().GetTypeInfo().GetDeclaredProperty("Result"); + + if (resultProperty != null) + { + switch (task.Status) + { + case TaskStatus.RanToCompletion: + return Observable.Return(resultProperty.GetValue(task)); + case TaskStatus.Faulted: + return Observable.Return(new BindingNotification(task.Exception, BindingErrorType.Error)); + default: + throw new AvaloniaInternalException("HandleCompleted called for non-completed Task."); + } + } + + return Observable.Empty(); + } + } +} diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs index 99f53f6979..3263aaace2 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Observable.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Reactive.Linq; using System.Reactive.Subjects; +using Avalonia.Data; using Avalonia.Markup.Data; using Avalonia.UnitTests; using Xunit; @@ -27,7 +28,7 @@ namespace Avalonia.Markup.UnitTests.Data source.OnNext("bar"); sync.ExecutePostedCallbacks(); - Assert.Equal(new[] { AvaloniaProperty.UnsetValue, "foo", "bar" }, result); + Assert.Equal(new[] { "foo", "bar" }, result); } } @@ -44,7 +45,47 @@ namespace Avalonia.Markup.UnitTests.Data data.Next.OnNext(new Class2("foo")); sync.ExecutePostedCallbacks(); - Assert.Equal(new[] { AvaloniaProperty.UnsetValue, "foo" }, result); + Assert.Equal(new[] { "foo" }, result); + + sub.Dispose(); + Assert.Equal(0, data.PropertyChangedSubscriptionCount); + } + } + + [Fact] + public void Should_Get_Simple_Observable_Value_With_DataValidation_Enabled() + { + using (var sync = UnitTestSynchronizationContext.Begin()) + { + var source = new BehaviorSubject("foo"); + var data = new { Foo = source }; + var target = new ExpressionObserver(data, "Foo", true); + var result = new List(); + + var sub = target.Subscribe(x => result.Add(x)); + source.OnNext("bar"); + sync.ExecutePostedCallbacks(); + + // What does it mean to have data validation on an observable? Without a use-case + // it's hard to know what to do here so for the moment the value is returned. + Assert.Equal(new[] { "foo", "bar" }, result); + } + } + + [Fact] + public void Should_Get_Property_Value_From_Observable_With_DataValidation_Enabled() + { + using (var sync = UnitTestSynchronizationContext.Begin()) + { + var data = new Class1(); + var target = new ExpressionObserver(data, "Next.Foo", true); + var result = new List(); + + var sub = target.Subscribe(x => result.Add(x)); + data.Next.OnNext(new Class2("foo")); + sync.ExecutePostedCallbacks(); + + Assert.Equal(new[] { new BindingNotification("foo") }, result); sub.Dispose(); Assert.Equal(0, data.PropertyChangedSubscriptionCount); diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Task.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Task.cs index 3d4c0b1b43..48b93107b1 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Task.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionObserverTests_Task.cs @@ -4,8 +4,8 @@ using System; using System.Collections.Generic; using System.Reactive.Linq; -using System.Threading; using System.Threading.Tasks; +using Avalonia.Data; using Avalonia.Markup.Data; using Avalonia.UnitTests; using Xunit; @@ -28,7 +28,7 @@ namespace Avalonia.Markup.UnitTests.Data tcs.SetResult("foo"); sync.ExecutePostedCallbacks(); - Assert.Equal(new object[] { AvaloniaProperty.UnsetValue, "foo" }, result.ToArray()); + Assert.Equal(new[] { "foo" }, result); } } @@ -43,7 +43,7 @@ namespace Avalonia.Markup.UnitTests.Data var sub = target.Subscribe(x => result.Add(x)); - Assert.Equal(new object[] { "foo" }, result.ToArray()); + Assert.Equal(new[] { "foo" }, result); } } @@ -61,7 +61,74 @@ namespace Avalonia.Markup.UnitTests.Data tcs.SetResult(new Class2("foo")); sync.ExecutePostedCallbacks(); - Assert.Equal(new object[] { AvaloniaProperty.UnsetValue, "foo" }, result.ToArray()); + Assert.Equal(new[] { "foo" }, result); + } + } + + [Fact] + public void Should_Return_BindingNotification_Error_On_Task_Exception() + { + using (var sync = UnitTestSynchronizationContext.Begin()) + { + var tcs = new TaskCompletionSource(); + var data = new { Foo = tcs.Task }; + var target = new ExpressionObserver(data, "Foo"); + var result = new List(); + + var sub = target.Subscribe(x => result.Add(x)); + tcs.SetException(new NotSupportedException()); + sync.ExecutePostedCallbacks(); + + Assert.Equal( + new[] + { + new BindingNotification( + new AggregateException(new NotSupportedException()), + BindingErrorType.Error) + }, + result); + } + } + + [Fact] + public void Should_Return_BindingNotification_Error_For_Faulted_Task() + { + using (var sync = UnitTestSynchronizationContext.Begin()) + { + var data = new { Foo = Task.FromException(new NotSupportedException()) }; + var target = new ExpressionObserver(data, "Foo"); + var result = new List(); + + var sub = target.Subscribe(x => result.Add(x)); + + Assert.Equal( + new[] + { + new BindingNotification( + new AggregateException(new NotSupportedException()), + BindingErrorType.Error) + }, + result); + } + } + + [Fact] + public void Should_Get_Simple_Task_Value_With_Data_DataValidation_Enabled() + { + using (var sync = UnitTestSynchronizationContext.Begin()) + { + var tcs = new TaskCompletionSource(); + var data = new { Foo = tcs.Task }; + var target = new ExpressionObserver(data, "Foo", true); + var result = new List(); + + var sub = target.Subscribe(x => result.Add(x)); + tcs.SetResult("foo"); + sync.ExecutePostedCallbacks(); + + // What does it mean to have data validation on a Task? Without a use-case it's + // hard to know what to do here so for the moment the value is returned. + Assert.Equal(new [] { "foo" }, result); } }