Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ IFieldSymbol field when IsReadableFieldLink(field, isLeaf) => field.Type,

// A field raises no notification, so it resolves no mechanism and is read once, like any other link whose
// owner cannot notify. It still carries its owner, which is how a call site whose first path starts at a
// field learns how the observed type notifies.
// field learns how the observed type notifies, and which view model property a view binding follows.
return member is IPropertySymbol propertySymbol
? new(
propertySymbol.Name,
Expand All @@ -179,7 +179,7 @@ IFieldSymbol field when IsReadableFieldLink(field, isLeaf) => field.Type,
memberType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
owner.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
memberType.IsReferenceType,
TypeDetectionExtractor.CreateTypeInfo(owner, semanticModel.Compilation, new([]), ct),
TypeDetectionExtractor.ExtractFieldOwner(owner, semanticModel.Compilation, ct),
IsField: true);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ namespace ReactiveUI.Binding.SourceGenerators.Helpers;
/// <summary>Extracts notification capabilities from property-owner symbols.</summary>
internal static class TypeDetectionExtractor
{
/// <summary>The name a view exposes its view model under.</summary>
private const string ViewModelPropertyName = "ViewModel";

/// <summary>Reads a type's notification mechanisms and observable properties from its symbol.</summary>
/// <param name="typeSymbol">The type to inspect, declared in this compilation or referenced from another.</param>
/// <param name="compilation">The compilation the type is resolved against.</param>
Expand All @@ -39,13 +42,35 @@ internal static ClassBindingInfo ExtractPropertyOwner(
CancellationToken ct)
{
var propertyInfo = ExtractProperty(owner, property);
var properties = property.Name != "ViewModel"
&& PlatformSymbols.FindMember(owner, "ViewModel") is IPropertySymbol { IsStatic: false, GetMethod.DeclaredAccessibility: Accessibility.Public } viewModel
var properties = property.Name != ViewModelPropertyName && FindViewModelProperty(owner) is { } viewModel
? new EquatableArray<ObservablePropertyInfo>([propertyInfo, ExtractProperty(owner, viewModel)])
: new EquatableArray<ObservablePropertyInfo>([propertyInfo]);
return CreateTypeInfo(owner, compilation, properties, ct);
}

/// <summary>Captures the concrete owner of a field link: its notification interfaces and its view model property.</summary>
/// <param name="owner">The type through which the field is read.</param>
/// <param name="compilation">The consumer compilation.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The owner's capabilities, listing its view model property when it exposes one.</returns>
/// <remarks>
/// A view binding usually starts at a field, a control named in XAML or by a designer, as in
/// <c>v =&gt; v.NameBox.Text</c>. The view model property is recorded so the binding follows the view model the view
/// currently holds, as it does when the path starts at a property.
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static ClassBindingInfo ExtractFieldOwner(
INamedTypeSymbol owner,
Compilation compilation,
CancellationToken ct) =>
CreateTypeInfo(
owner,
compilation,
FindViewModelProperty(owner) is { } viewModel
? new EquatableArray<ObservablePropertyInfo>([ExtractProperty(owner, viewModel)])
: new EquatableArray<ObservablePropertyInfo>([]),
ct);

/// <summary>Reads one property's native candidates while its owner symbols are available.</summary>
/// <param name="owner">The concrete property owner.</param>
/// <param name="property">The selected declaration.</param>
Expand Down Expand Up @@ -150,6 +175,14 @@ internal static EquatableArray<ObservablePropertyInfo> ExtractProperties(
return new([.. properties]);
}

/// <summary>Finds the public instance view model property a view exposes, through its base classes.</summary>
/// <param name="owner">The type to search.</param>
/// <returns>The view model property, or null when the type exposes none.</returns>
private static IPropertySymbol? FindViewModelProperty(INamedTypeSymbol owner) =>
PlatformSymbols.FindMember(owner, ViewModelPropertyName) is IPropertySymbol { IsStatic: false, GetMethod.DeclaredAccessibility: Accessibility.Public } viewModel
? viewModel
: null;

/// <summary>Finds the dependency-property field and change event a property notifies through.</summary>
/// <param name="members">The members of the type and of the bases its own assembly declares.</param>
/// <param name="propertyName">The property being described.</param>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ public static string BindTwoWayToFieldControl()
public static string BindCommandToFieldControl()
{
var person = new Person();
var view = new PersonView();
var view = new PersonView { ViewModel = person };
using (view.BindCommand(person, x => x.Save, v => v.SaveButton))
{
view.SaveButton.PerformClick();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,37 @@ public Task Bind_AfterTheViewModelIsReplaced_TracksTheNewViewModel() =>
"view.Bind(view.ViewModel, vm => vm.Name, v => v.Text)",
StringComparison.Ordinal));

/// <summary>A one-way binding created while the view has no view model follows the first assigned model.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Task OneWayBind_WhenViewModelInitiallyNull_TracksTheAssignedViewModel() =>
AssertTracksReplacement(WithNullViewModel(ViewModelReplacementSource, "view.OneWayBind"));

/// <summary>A two-way binding created while the view has no view model follows the first assigned model.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Task Bind_WhenViewModelInitiallyNull_TracksTheAssignedViewModel() =>
AssertTracksReplacement(WithNullViewModel(ToBind(ViewModelReplacementSource), "view.Bind"));

/// <summary>
/// A one-way binding onto a control the view holds in a field, as a control named in XAML is, follows the view's
/// view model when the view has none yet.
/// </summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Task OneWayBind_ToAFieldControl_WhenViewModelInitiallyNull_TracksTheAssignedViewModel() =>
AssertTracksReplacement(WithNullViewModel(ToFieldControl(ViewModelReplacementSource), "view.OneWayBind"));

/// <summary>A two-way binding onto a control the view holds in a field follows the view model it is given later.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Task Bind_ToAFieldControl_WhenViewModelInitiallyNull_TracksTheAssignedViewModel() =>
AssertTracksReplacement(WithNullViewModel(ToFieldControl(ToBind(ViewModelReplacementSource)), "view.Bind"));

/// <summary>
/// A command binding follows the view's current view model, so clicking the control invokes the command the
/// view model on display exposes rather than the one belonging to the view model it replaced.
Expand Down Expand Up @@ -324,6 +355,32 @@ public Task BindInteraction_WhenViewModelInitiallyNull_HandlesTheAssignedInterac
"view.ViewModel = null; var binding = view.BindInteraction",
StringComparison.Ordinal));

/// <summary>Makes the one-way replacement scenario bind two-way.</summary>
/// <param name="source">The one-way scenario.</param>
/// <returns>The same scenario, calling <c>Bind</c>.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string ToBind(string source) =>
source.Replace("view.OneWayBind(", "view.Bind(", StringComparison.Ordinal);

/// <summary>Clears the view's view model just before the scenario makes its binding.</summary>
/// <param name="source">The scenario.</param>
/// <param name="call">The binding call the scenario makes, such as <c>view.OneWayBind</c>.</param>
/// <returns>The same scenario, binding while the view holds no view model.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string WithNullViewModel(string source, string call) =>
source.Replace($"var binding = {call}", $"view.ViewModel = null; var binding = {call}", StringComparison.Ordinal);

/// <summary>Moves the bound view property onto a control the view holds in a field.</summary>
/// <param name="source">The scenario, binding <c>v =&gt; v.Text</c>.</param>
/// <returns>The same scenario, binding <c>v =&gt; v.NameBox.Text</c>.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string ToFieldControl(string source) =>
source
.Replace("public string Text { get; set; } = \"\";", "public TextBox NameBox = new TextBox();", StringComparison.Ordinal)
.Replace("public static class Usage", "public class TextBox { public string Text { get; set; } = \"\"; }\n\npublic static class Usage", StringComparison.Ordinal)
.Replace("v => v.Text)", "v => v.NameBox.Text)", StringComparison.Ordinal)
.Replace("return view.Text;", "return view.NameBox.Text;", StringComparison.Ordinal);

/// <summary>Runs a replacement scenario and asserts the view ends up showing the replacement's value.</summary>
/// <param name="source">The scenario source to generate, compile and run.</param>
/// <returns>A task representing the asynchronous assertion.</returns>
Expand Down
Loading