diff --git a/src/ReactiveUI.SourceGenerator.Tests/UnitTests/ControlHostBehaviourTests.cs b/src/ReactiveUI.SourceGenerator.Tests/UnitTests/ControlHostBehaviourTests.cs new file mode 100644 index 00000000..9be23831 --- /dev/null +++ b/src/ReactiveUI.SourceGenerator.Tests/UnitTests/ControlHostBehaviourTests.cs @@ -0,0 +1,315 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections; +using System.Reflection; +using System.Runtime.Loader; +using ReactiveUI.SourceGenerators.WinForms; + +namespace ReactiveUI.SourceGenerator.Tests; + +/// +/// Runs the generated Windows Forms hosts against the desktop stubs: they are constructed, show their default content, +/// and swap in the view for a routed or hosted view model, without a WhenAny call the binding engine has to +/// dispatch. +/// +public class ControlHostBehaviourTests +{ + /// The hosts, and a view model, view and view locator to route between. + private const string Source = """ + using System.ComponentModel; + using System.Windows.Forms; + using ReactiveUI; + using ReactiveUI.SourceGenerators.WinForms; + + namespace Hosts + { + [RoutedControlHost("System.Windows.Forms.UserControl")] + public partial class RoutedHost + { + private IContainer? components; + + private void InitializeComponent() + { + } + } + + [ViewModelControlHost("System.Windows.Forms.UserControl")] + public partial class ViewModelHost + { + private IContainer? components; + + private void InitializeComponent() + { + } + } + + public sealed class Shell : IScreen + { + public RoutingState Router { get; } = new(); + } + + public sealed class PageViewModel(IScreen screen) : ReactiveObject, IRoutableViewModel + { + public string UrlPathSegment => "page"; + + public IScreen HostScreen => screen; + } + + public sealed class PageView : UserControl, IViewFor + { + public PageViewModel? ViewModel { get; set; } + + object? IViewFor.ViewModel { get => ViewModel; set => ViewModel = (PageViewModel?)value; } + } + + public sealed class Locator : IViewLocator + { + public IViewFor? ResolveView() where TViewModel : class => null; + + public IViewFor? ResolveView(string? contract) where TViewModel : class => null; + + public IViewFor? ResolveView(object? viewModel) => viewModel is PageViewModel ? new PageView() : null; + + public IViewFor? ResolveView(object? viewModel, string? contract) => ResolveView(viewModel); + } + } + """; + + /// The hosts' default content property. + private const string DefaultContent = nameof(DefaultContent); + + /// The routed host's router property. + private const string Router = nameof(Router); + + /// The view-model host's view model property, and a view's. + private const string ViewModel = nameof(ViewModel); + + /// The assembly compiled from , built once. + private static readonly Lazy HostAssembly = new(Compile, LazyThreadSafetyMode.ExecutionAndPublication); + + /// The routed host shows its default content, then the view for the view model the router navigates to. + /// A task to monitor the async. + [Test] + public async Task RoutedHostShowsDefaultContentThenTheRoutedView() + { + var host = Create("Hosts.RoutedHost"); + var defaultContent = Create("System.Windows.Forms.UserControl"); + Set(host, nameof(ViewLocator), Create("Hosts.Locator")); + + Set(host, DefaultContent, defaultContent); + await AssertShowsOnly(host, defaultContent); + + var shell = Create("Hosts.Shell"); + var router = Get(shell, Router); + Set(host, Router, router); + var navigationStack = (IList)Get(router, "NavigationStack"); + _ = navigationStack.Add(Activator.CreateInstance(HostAssembly.Value.GetType("Hosts.PageViewModel", throwOnError: true)!, shell)); + + var shown = ShownControls(host); + await Assert.That(shown.Count).IsEqualTo(1); + await Assert.That(shown[0]!.GetType().FullName).IsEqualTo("Hosts.PageView"); + } + + /// The view-model host shows its default content, then the view for the view model it is given. + /// A task to monitor the async. + [Test] + public async Task ViewModelHostShowsDefaultContentThenTheViewForItsViewModel() + { + var host = Create("Hosts.ViewModelHost"); + var defaultContent = Create("System.Windows.Forms.UserControl"); + Set(host, nameof(ViewLocator), Create("Hosts.Locator")); + + Set(host, DefaultContent, defaultContent); + await AssertShowsOnly(host, defaultContent); + + var viewModel = Activator.CreateInstance(HostAssembly.Value.GetType("Hosts.PageViewModel", throwOnError: true)!, Create("Hosts.Shell"))!; + Set(host, ViewModel, viewModel); + + var shown = ShownControls(host); + await Assert.That(shown.Count).IsEqualTo(1); + await Assert.That(shown[0]!.GetType().FullName).IsEqualTo("Hosts.PageView"); + await Assert.That(Get(shown[0]!, ViewModel)).IsSameReferenceAs(viewModel); + } + + /// A host in the global namespace, and a host nested in another type, get their generated members. + /// A task to monitor the async. + [Test] + public async Task HostsInTheGlobalNamespaceAndNestedTypesGetTheirMembers() + { + const string source = """ + using System.ComponentModel; + using ReactiveUI.SourceGenerators.WinForms; + + [RoutedControlHost("System.Windows.Forms.UserControl")] + public partial class GlobalRoutedHost + { + private IContainer? components; + + private void InitializeComponent() + { + } + } + + namespace Hosts + { + public partial class Outer + { + [ViewModelControlHost("System.Windows.Forms.UserControl")] + public partial class NestedViewModelHost + { + private IContainer? components; + + private void InitializeComponent() + { + } + } + } + } + """; + var (output, _) = Generate(source); + + await Assert.That(output.GetTypeByMetadataName("GlobalRoutedHost")!.GetMembers(Router)).IsNotEmpty(); + await Assert.That(output.GetTypeByMetadataName("Hosts.Outer+NestedViewModelHost")!.GetMembers(ViewModel)).IsNotEmpty(); + await Assert.That(output.GetTypeByMetadataName("NestedViewModelHost")).IsNull(); + await Assert.That(GetErrors(output)).IsEmpty(); + } + + /// Neither host calls WhenAny, which the binding engine could not dispatch from generated code. + /// A task to monitor the async. + [Test] + public async Task HostsDoNotCallWhenAny() + { + foreach (var generated in Generate(Source).GeneratedSources) + { + await Assert.That(generated).DoesNotContain("WhenAny"); + } + } + + /// Creates an instance of a type from the compiled assembly or its references. + /// The type's full name. + /// The instance. + private static object Create(string typeName) => Activator.CreateInstance(HostAssembly.Value.GetType(typeName, throwOnError: false) ?? FindType(typeName))!; + + /// Finds a type in the compiled assembly's load context. + /// The type's full name. + /// The type. + private static Type FindType(string typeName) + { + foreach (var assembly in AssemblyLoadContext.GetLoadContext(HostAssembly.Value)!.Assemblies) + { + if (assembly.GetType(typeName, throwOnError: false) is { } type) + { + return type; + } + } + + throw new InvalidOperationException($"{typeName} was not found."); + } + + /// Reads a public property. + /// The object. + /// The property name. + /// The property's value. + private static object Get(object target, string name) => target.GetType().GetProperty(name)!.GetValue(target)!; + + /// Sets a public property. + /// The object. + /// The property name. + /// The value. + private static void Set(object target, string name, object? value) => target.GetType().GetProperty(name)!.SetValue(target, value); + + /// Lists the controls a host shows. + /// The host. + /// The controls. + private static List ShownControls(object host) + { + var controls = new List(); + foreach (var control in (IEnumerable)Get(host, "Controls")) + { + controls.Add(control); + } + + return controls; + } + + /// Asserts that a host shows exactly one control, the given one. + /// The host. + /// The control it should show. + /// A task to monitor the async. + private static async Task AssertShowsOnly(object host, object control) + { + var shown = ShownControls(host); + await Assert.That(shown.Count).IsEqualTo(1); + await Assert.That(shown[0]).IsSameReferenceAs(control); + } + + /// Formats a compilation's errors. + /// The compilation. + /// The errors, one per line. + private static string GetErrors(Compilation compilation) + { + var errors = new List(); + foreach (var diagnostic in compilation.GetDiagnostics()) + { + if (diagnostic.Severity == DiagnosticSeverity.Error) + { + errors.Add(diagnostic.ToString()); + } + } + + return string.Join(Environment.NewLine, errors); + } + + /// Runs both host generators over a consumer source and the desktop stubs. + /// The consumer source. + /// The output compilation and the generated host sources. + private static (Compilation Output, List GeneratedSources) Generate(string source) + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp13); + var compilation = CSharpCompilation.Create( + "ControlHostBehaviour", + [ + CSharpSyntaxTree.ParseText(source, parseOptions), + CSharpSyntaxTree.ParseText(TestCompilationReferences.WindowsDesktopStubs, parseOptions), + ], + TestCompilationReferences.CreatePortableDefault(), + new(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + var driver = CSharpGeneratorDriver + .Create([new RoutedControlHostGenerator().AsSourceGenerator(), new ViewModelControlHostGenerator().AsSourceGenerator()], parseOptions: parseOptions) + .RunGeneratorsAndUpdateCompilation(compilation, out var output, out _); + + var generated = new List(); + foreach (var result in driver.GetRunResult().Results) + { + foreach (var file in result.GeneratedSources) + { + generated.Add(file.SourceText.ToString()); + } + } + + return (output, generated); + } + + /// Generates, compiles and loads the hosts. + /// The loaded assembly. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "SES1402", Justification = "The test loads the assembly it has just compiled from its own source.")] + private static Assembly Compile() + { + // ReactiveUI marks its default exception handler initialized before it assigns it, so a host constructed on + // another thread at the same moment could read null. Initialize it here, once, before any host exists. + _ = RxState.DefaultExceptionHandler; + + var (output, _) = Generate(Source); + using var image = new MemoryStream(); + var emit = output.Emit(image); + if (!emit.Success) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, emit.Diagnostics)); + } + + image.Position = 0; + return new AssemblyLoadContext(nameof(ControlHostBehaviourTests), isCollectible: true).LoadFromStream(image); + } +} diff --git a/src/ReactiveUI.SourceGenerator.Tests/UnitTests/TestCompilationReferences.cs b/src/ReactiveUI.SourceGenerator.Tests/UnitTests/TestCompilationReferences.cs index cddaacaf..eb8eb51b 100644 --- a/src/ReactiveUI.SourceGenerator.Tests/UnitTests/TestCompilationReferences.cs +++ b/src/ReactiveUI.SourceGenerator.Tests/UnitTests/TestCompilationReferences.cs @@ -73,7 +73,8 @@ public void Remove(Control? control) } } - public global::System.Collections.Generic.IEnumerator GetEnumerator() => controls.GetEnumerator(); + // Enumerates a copy: like the real collection, it can be changed while it is enumerated. + public global::System.Collections.Generic.IEnumerator GetEnumerator() => ((global::System.Collections.Generic.IEnumerable)controls.ToArray()).GetEnumerator(); global::System.Collections.IEnumerator global::System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); } diff --git a/src/ReactiveUI.SourceGenerators.Roslyn/Core/CodeGeneration/ControlHostWriter.cs b/src/ReactiveUI.SourceGenerators.Roslyn/Core/CodeGeneration/ControlHostWriter.cs new file mode 100644 index 00000000..b40106ec --- /dev/null +++ b/src/ReactiveUI.SourceGenerators.Roslyn/Core/CodeGeneration/ControlHostWriter.cs @@ -0,0 +1,122 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.SourceGenerators.CodeGeneration; + +/// The members both Windows Forms hosts share, written through a . +/// +/// A host follows its own properties through its PropertyChanged event, never through WhenAny: the +/// binding engine's generator cannot see call sites in this generator's output, so a WhenAny in a host would not +/// be dispatched. +/// +internal static class ControlHostWriter +{ + /// Writes the host's change events and its IReactiveObject implementation. + /// The writer, at the level of the host's members. + /// + /// ReactiveUI raises the classic events of a hand-written IReactiveObject only once it has called + /// SubscribePropertyChangedEvents and SubscribePropertyChangingEvents, so the first handler added + /// makes that call, as ReactiveObject does. + /// + internal static void WritePropertyChangeEvents(SourceWriter writer) => + _ = writer.Lines(""" + private bool _propertyChangingEventsSubscribed; + private bool _propertyChangedEventsSubscribed; + + /// + public event PropertyChangingEventHandler? PropertyChanging + { + add + { + if (!_propertyChangingEventsSubscribed) + { + this.SubscribePropertyChangingEvents(); + _propertyChangingEventsSubscribed = true; + } + + PropertyChangingHandler += value; + } + remove => PropertyChangingHandler -= value; + } + + /// + public event PropertyChangedEventHandler? PropertyChanged + { + add + { + if (!_propertyChangedEventsSubscribed) + { + this.SubscribePropertyChangedEvents(); + _propertyChangedEventsSubscribed = true; + } + + PropertyChangedHandler += value; + } + remove => PropertyChangedHandler -= value; + } + + private event PropertyChangingEventHandler? PropertyChangingHandler; + + private event PropertyChangedEventHandler? PropertyChangedHandler; + + /// + void IReactiveObject.RaisePropertyChanging(PropertyChangingEventArgs args) => PropertyChangingHandler?.Invoke(this, args); + + /// + void IReactiveObject.RaisePropertyChanged(PropertyChangedEventArgs args) => PropertyChangedHandler?.Invoke(this, args); + """); + + /// Writes the nested observable that follows one of the host's own properties. + /// The writer, at the level of the host's members. + /// + /// Each change of the property switches to the observable the selector returns, as WhenAnyObservable does; + /// a value is followed by selecting a ReturnObservable of it, as WhenAnyValue does. + /// + internal static void WritePropertyObservable(SourceWriter writer) => + _ = writer.Lines(""" + private sealed class PropertyObservable(INotifyPropertyChanged source, string propertyName, Func?> select) : IObservable + { + public IDisposable Subscribe(IObserver observer) => new Subscription(source, propertyName, select, observer); + + private sealed class Subscription : IDisposable + { + private readonly INotifyPropertyChanged _source; + private readonly string _propertyName; + private readonly Func?> _select; + private readonly IObserver _observer; + private readonly SubscriptionSlot _inner = new(); + + public Subscription(INotifyPropertyChanged source, string propertyName, Func?> select, IObserver observer) + { + _source = source; + _propertyName = propertyName; + _select = select; + _observer = observer; + _source.PropertyChanged += OnPropertyChanged; + Switch(); + } + + public void Dispose() + { + _source.PropertyChanged -= OnPropertyChanged; + _inner.Dispose(); + } + + private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (string.IsNullOrEmpty(e.PropertyName) || e.PropertyName == _propertyName) + { + Switch(); + } + } + + private void Switch() + { + var inner = _select(); + _inner.Set(inner is null ? EmptyDisposable.Instance : inner.Subscribe(new ValueObserver(_observer.OnNext, _observer.OnError))); + } + } + } + """); +} diff --git a/src/ReactiveUI.SourceGenerators.Roslyn/RoutedControlHost/Models/RoutedControlHostInfo.cs b/src/ReactiveUI.SourceGenerators.Roslyn/RoutedControlHost/Models/RoutedControlHostInfo.cs index 6acbc8c3..5f0e2678 100644 --- a/src/ReactiveUI.SourceGenerators.Roslyn/RoutedControlHost/Models/RoutedControlHostInfo.cs +++ b/src/ReactiveUI.SourceGenerators.Roslyn/RoutedControlHost/Models/RoutedControlHostInfo.cs @@ -12,6 +12,7 @@ namespace ReactiveUI.SourceGenerators.Models; /// The target type visibility. /// The target type keyword. /// The routed control host base type name. +/// The type containing a nested host, or for a top-level host. internal sealed record RoutedControlHostInfo( string FileHintName, string TargetName, @@ -19,4 +20,5 @@ internal sealed record RoutedControlHostInfo( string TargetNamespaceWithNamespace, string TargetVisibility, string TargetType, - string BaseTypeName); + string BaseTypeName, + TargetInfo? ParentInfo); diff --git a/src/ReactiveUI.SourceGenerators.Roslyn/RoutedControlHost/RoutedControlHostGenerator.Execute.cs b/src/ReactiveUI.SourceGenerators.Roslyn/RoutedControlHost/RoutedControlHostGenerator.Execute.cs index 4ce63332..80b79d0c 100644 --- a/src/ReactiveUI.SourceGenerators.Roslyn/RoutedControlHost/RoutedControlHostGenerator.Execute.cs +++ b/src/ReactiveUI.SourceGenerators.Roslyn/RoutedControlHost/RoutedControlHostGenerator.Execute.cs @@ -71,7 +71,8 @@ public partial class RoutedControlHostGenerator targetInfo.TargetNamespaceWithNamespace, targetInfo.TargetVisibility, targetInfo.TargetType, - baseTypeName); + baseTypeName, + targetInfo.ParentInfo); }; /// Gets routed control host metadata from a generator attribute context. @@ -104,9 +105,8 @@ private static string GenerateSource(RoutedControlHostInfo info, ReactiveUiInteg var writer = SourceWriter.Rent(); WriteFileHeader(writer, integration); - // The namespace is written as-is, even when empty, to keep the historical output. - _ = writer.Append("namespace ").Line(info.TargetNamespace).OpenBlock() - .Line(AttributeDefinitions.ExcludeFromCodeCoverage) + var depth = writer.OpenNamespace(info.TargetNamespace) + writer.OpenContainingTypes(info.ParentInfo); + _ = writer.Line(AttributeDefinitions.ExcludeFromCodeCoverage) .Line("[DefaultProperty(\"ViewModel\")]") .Line(GeneratedCodeAttribute) .Append(info.TargetVisibility).Append(" partial ").Append(info.TargetType).Append(' ').Append(info.TargetName) @@ -117,6 +117,7 @@ private static string GenerateSource(RoutedControlHostInfo info, ReactiveUiInteg WriteProperties(writer.BlankLine(), integration); WriteDispose(writer.BlankLine()); WriteRouting(writer.BlankLine(), integration); + ControlHostWriter.WritePropertyObservable(writer.BlankLine()); WriteObservableHelpers(writer.BlankLine()); WriteCombineLatestSubscription(writer.BlankLine()); WriteDisposableCollection(writer.BlankLine()); @@ -124,7 +125,7 @@ private static string GenerateSource(RoutedControlHostInfo info, ReactiveUiInteg WriteEmptyDisposable(writer.BlankLine()); return writer.CloseBlock() - .CloseBlock() + .CloseBlocks(depth) .RestoreNullableAndWarnings() .ToStringAndReturn(); } @@ -171,7 +172,7 @@ private static void WriteConstructor(SourceWriter writer, string typeName, strin .OpenBlock() .Lines(""" InitializeComponent(); - _disposables.Add(this.WhenAny(x => x.DefaultContent, x => x.Value).Subscribe(new ValueObserver(x => + _disposables.Add(new PropertyObservable(this, nameof(DefaultContent), () => new ReturnObservable(DefaultContent)).Subscribe(new ValueObserver(x => { if (x is not null && Controls.Count == 0) { @@ -189,22 +190,18 @@ private static void WriteConstructor(SourceWriter writer, string typeName, strin .Lines(""" _disposables.Add(routeSubscription); routeSubscription.Connect( - this.WhenAnyObservable(x => x.Router!.CurrentViewModel!), - this.WhenAnyObservable(x => x.ViewContractObservable!)); + new PropertyObservable(this, nameof(Router), () => Router?.CurrentViewModel), + new PropertyObservable(this, nameof(ViewContractObservable), () => ViewContractObservable)); """) .CloseBlock(); /// Writes the host's events and properties, and its IReactiveObject implementation. /// The writer, at the level of the host's members. /// The detected ReactiveUI integration, which names the view locator's interface. - private static void WriteProperties(SourceWriter writer, ReactiveUiIntegration integration) => - _ = writer.Lines(""" - /// - public event PropertyChangingEventHandler? PropertyChanging; - - /// - public event PropertyChangedEventHandler? PropertyChanged; - + private static void WriteProperties(SourceWriter writer, ReactiveUiIntegration integration) + { + ControlHostWriter.WritePropertyChangeEvents(writer); + _ = writer.BlankLine().Lines(""" /// /// Gets or sets the default content. /// @@ -233,15 +230,8 @@ private static void WriteProperties(SourceWriter writer, ReactiveUiIntegration i /// [Browsable(false)] """) - .Append("public ").Append(integration.ViewNamespace).Line(".IViewLocator? ViewLocator { get; set; }") - .BlankLine() - .Lines(""" - /// - void IReactiveObject.RaisePropertyChanging(PropertyChangingEventArgs args) => PropertyChanging?.Invoke(this, args); - - /// - void IReactiveObject.RaisePropertyChanged(PropertyChangedEventArgs args) => PropertyChanged?.Invoke(this, args); - """); + .Append("public ").Append(integration.ViewNamespace).Line(".IViewLocator? ViewLocator { get; set; }"); + } /// Writes the host's disposal. /// The writer, at the level of the host's members. diff --git a/src/ReactiveUI.SourceGenerators.Roslyn/ViewModelControlHost/Models/ViewModelControlHostInfo.cs b/src/ReactiveUI.SourceGenerators.Roslyn/ViewModelControlHost/Models/ViewModelControlHostInfo.cs index bdd98603..38a5b615 100644 --- a/src/ReactiveUI.SourceGenerators.Roslyn/ViewModelControlHost/Models/ViewModelControlHostInfo.cs +++ b/src/ReactiveUI.SourceGenerators.Roslyn/ViewModelControlHost/Models/ViewModelControlHostInfo.cs @@ -12,6 +12,7 @@ namespace ReactiveUI.SourceGenerators.Models; /// The target type visibility. /// The target type keyword. /// The view-model control host base type name. +/// The type containing a nested host, or for a top-level host. internal sealed record ViewModelControlHostInfo( string FileHintName, string TargetName, @@ -19,4 +20,5 @@ internal sealed record ViewModelControlHostInfo( string TargetNamespaceWithNamespace, string TargetVisibility, string TargetType, - string ViewModelTypeName); + string ViewModelTypeName, + TargetInfo? ParentInfo); diff --git a/src/ReactiveUI.SourceGenerators.Roslyn/ViewModelControlHost/ViewModelControlHostGenerator.Execute.cs b/src/ReactiveUI.SourceGenerators.Roslyn/ViewModelControlHost/ViewModelControlHostGenerator.Execute.cs index 358c7845..21331514 100644 --- a/src/ReactiveUI.SourceGenerators.Roslyn/ViewModelControlHost/ViewModelControlHostGenerator.Execute.cs +++ b/src/ReactiveUI.SourceGenerators.Roslyn/ViewModelControlHost/ViewModelControlHostGenerator.Execute.cs @@ -65,7 +65,8 @@ public partial class ViewModelControlHostGenerator targetInfo.TargetNamespaceWithNamespace, targetInfo.TargetVisibility, targetInfo.TargetType, - viewModelTypeName!); + viewModelTypeName!, + targetInfo.ParentInfo); }; /// Gets view-model control host metadata from a generator attribute context. @@ -98,9 +99,8 @@ private static string GenerateSource(ViewModelControlHostInfo info, ReactiveUiIn var writer = SourceWriter.Rent(); WriteFileHeader(writer, integration); - // The namespace is written as-is, even when empty, to keep the historical output. - _ = writer.Append("namespace ").Line(info.TargetNamespace).OpenBlock() - .Line(AttributeDefinitions.ExcludeFromCodeCoverage) + var depth = writer.OpenNamespace(info.TargetNamespace) + writer.OpenContainingTypes(info.ParentInfo); + _ = writer.Line(AttributeDefinitions.ExcludeFromCodeCoverage) .Line("[DefaultProperty(\"ViewModel\")]") .Line(GeneratedCodeAttribute) .Append(info.TargetVisibility).Append(" partial ").Append(info.TargetType).Append(' ').Append(info.TargetName) @@ -113,6 +113,7 @@ private static string GenerateSource(ViewModelControlHostInfo info, ReactiveUiIn WriteDispose(writer.BlankLine()); WriteSetupBindings(writer.BlankLine(), exceptionHandler); WriteUpdateContent(writer.BlankLine(), integration); + ControlHostWriter.WritePropertyObservable(writer.BlankLine()); WriteObservableHelpers(writer.BlankLine()); WriteCombineLatestSubscription(writer.BlankLine()); WriteDisposableCollection(writer.BlankLine()); @@ -120,7 +121,7 @@ private static string GenerateSource(ViewModelControlHostInfo info, ReactiveUiIn WriteEmptyDisposable(writer.BlankLine()); return writer.CloseBlock() - .CloseBlock() + .CloseBlocks(depth) .RestoreNullableAndWarnings() .ToStringAndReturn(); } @@ -175,14 +176,10 @@ private static void WriteConstructor(SourceWriter writer, string typeName) => /// Writes the host's events and its content and view-location properties. /// The writer, at the level of the host's members. /// The detected ReactiveUI integration, which names the view locator's interface. - private static void WriteProperties(SourceWriter writer, ReactiveUiIntegration integration) => - _ = writer.Lines(""" - /// - public event PropertyChangingEventHandler? PropertyChanging; - - /// - public event PropertyChangedEventHandler? PropertyChanged; - + private static void WriteProperties(SourceWriter writer, ReactiveUiIntegration integration) + { + ControlHostWriter.WritePropertyChangeEvents(writer); + _ = writer.BlankLine().Lines(""" /// /// Gets or sets a value indicating whether [default cache views enabled]. /// @@ -215,6 +212,7 @@ private static void WriteProperties(SourceWriter writer, ReactiveUiIntegration i [Browsable(false)] """) .Append("public ").Append(integration.ViewNamespace).Line(".IViewLocator? ViewLocator { get; set; }"); + } /// Writes the host's bindable view-model properties and its IReactiveObject implementation. /// The writer, at the level of the host's members. @@ -242,12 +240,6 @@ private static void WriteBindableProperties(SourceWriter writer) => [Bindable(true)] [DefaultValue(true)] public bool CacheViews { get => _cacheViews; set => this.RaiseAndSetIfChanged(ref _cacheViews, value); } - - /// - void IReactiveObject.RaisePropertyChanging(PropertyChangingEventArgs args) => PropertyChanging?.Invoke(this, args); - - /// - void IReactiveObject.RaisePropertyChanged(PropertyChangedEventArgs args) => PropertyChanged?.Invoke(this, args); """); /// Writes the host's disposal. @@ -277,7 +269,7 @@ private static void WriteSetupBindings(SourceWriter writer, string exceptionHand _ = writer.Line("private void SetupBindings()") .OpenBlock() .Lines(""" - AddSubscription(this.WhenAnyValue(x => x!.Content), new ValueObserver(x => + AddSubscription(new PropertyObservable(this, nameof(Content), () => new ReturnObservable(Content)), new ValueObserver(x => { if (x is not Control control) { @@ -299,7 +291,7 @@ private static void WriteSetupBindings(SourceWriter writer, string exceptionHand """) .Append("}, ").Append(exceptionHandler).Line("));") .Lines(""" - AddSubscription(this.WhenAnyValue(x => x.DefaultContent), new ValueObserver(x => + AddSubscription(new PropertyObservable(this, nameof(DefaultContent), () => new ReturnObservable(DefaultContent)), new ValueObserver(x => { if (x is not null) { @@ -316,8 +308,8 @@ private static void WriteSetupBindings(SourceWriter writer, string exceptionHand .Lines(""" _disposables.Add(viewModelSubscription); viewModelSubscription.Connect( - this.WhenAnyValue(x => x.ViewModel), - this.WhenAnyObservable(x => x.ViewContractObservable!)); + new PropertyObservable(this, nameof(ViewModel), () => new ReturnObservable(ViewModel)), + new PropertyObservable(this, nameof(ViewContractObservable), () => ViewContractObservable)); """) .CloseBlock();