cmiles - info

Life, Tech and Unimportant Minutiae

INPC, RelayCommand, Source Generators, Windows Community Toolkit -> Metalama, 6/6/2023

Created by Charles on 6/6/2023. Updated on 6/18/2023.

2023 May Caught
Caught. Charles Miles. 5/22/2023.

INotifyPropertyChanged is central to the WPF binding system and as far as I know the interface hasn't changed since WPF's beginnings in .NET Framework 3.0 (2006 or so). It is rather beautiful - spare, simple, easy to implement and it creates quite a bit of value by facilitating WPF bindings.

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace System.ComponentModel
{
    public interface INotifyPropertyChanged
    {
        event PropertyChangedEventHandler? PropertyChanged;
    }
}

For many years I've written code like this:

public class MainViewModel
{
    public string? UserInput { get; set; }
}

And then used Resharper to generate the INPC implementation and expand the properties to use it.

public class MainViewModel : INotifyPropertyChanged
{
    private string? _userInput;

    public string? UserInput
    {
        get => _userInput;
        set
        {
            if (value == _userInput) return;
            _userInput = value;
            OnPropertyChanged();
        }
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

}

The boiler plate code for INPC looks out of place against 'modern' C#/.NET with its minimal APIs and top level statements... Over a decade of real world experience suggests the cost of this verbose code is fairly low, but hundreds/thousands/+ lines of code, even stable and simple code, obviously has some cost. For many years I didn't find an alternative to the classic INPC boiler plate/generation where the various trade offs seemed worthwhile - but recently I've been using the source generator approach from The Windows Community Toolkit successfully. The example above reduces down to the code below:

public partial class MainViewModel : ObservableObject
{
    [ObservableProperty] private string? _userInput;
}

ObservableObject (which can either be inherited or added via attribute/source generation) implements INPC and _userInput is transformed into a public property suitable for use with WPF bindings! The Windows Community Toolkit seems well supported and the project continues to evolve - I suspect using The Windows Community toolkit is a good choice for use in long lived applications.

As far as I can tell The Windows Community Toolkit's focus is creating great tools/approaches like [ObservableProperty] - not on helping you build functionality like [ObservableProperty] yourself. This distinction recently became relevant to me because The Windows Community Toolkit offers another great feature for WPF/MVVM/ICommand based approaches with the RelayCommand attribute. Similar to the [ObservableProperty] [RelayCommand] can help reduce boiler plate by generating the code to create a RelayCommand property automatically!

But the [RelayCommand] attribute doesn't work for me in the Pointless Waymarks Project - I have a custom setup for Commands that is central to the project. For several months I have occasionally tried to think of a way to modify my approach to work with [RelayCommand] - the reduction of boiler plate using the [ObservableProperty] creates is addictive... But I've failed to come up with any elegant ideas.

Enter Metalama: A Framework for Clean & Concise Code in C#. For me Metalama is interesting because it provides a toolkit to implement functionality like [ObservableProperty] or [RelayCommand]. Thru examples, documentation and a bit of hacking I was able to build INPC and Command aspects in about a day so that I can write code like this:

[NotifyPropertyChanged]
[GenerateStatusCommands]
public class MainViewModel
{
    public string? UserInput { get; set; }

    public MainViewModel()
    {
        BuildCommands();
    }

    [BlockingCommand]
    public Task ModifyUserInput()
    {
        UserInput += "Modified";
        return Task.CompletedTask;
    }
}

The code below is NOT, ABSOLUTELY NOT, meant as 'good Metalama examples' (or even as 'good code examples' in general!) - but I found it quite encouraging that I was able to create useful code so quickly using Metalama. Some of the code below is specific to the Pointless Waymarks Project but the look and feel of the code might be interesting if you are considering trying Metalama, this was created using Visual Studio and the experience was like editing any other code:

public class NotifyPropertyChangedAttribute : TypeAspect
{
    public override void BuildAspect(IAspectBuilder<INamedType> builder)
    {
        builder.Advice.ImplementInterface(builder.Target, typeof(INotifyPropertyChanged), OverrideStrategy.Ignore);

        foreach (var property in builder.Target.Properties.Where(p =>
                     p is { IsAbstract: false, Writeability: Writeability.All } && !p.Attributes.Any(typeof(DoNotGenerateInpc))))
            builder.Advice.OverrideAccessors(property, null, nameof(OverridePropertySetter));
    }

    [Introduce(WhenExists = OverrideStrategy.Ignore)]
    protected void OnPropertyChanged(string name)
    {
        PropertyChanged?.Invoke(meta.This, new PropertyChangedEventArgs(name));
    }

    [Template]
    private dynamic OverridePropertySetter(dynamic value)
    {
        SetField(ref meta.Target.FieldOrProperty.Value, value);

        return value;
    }

    [InterfaceMember] public event PropertyChangedEventHandler? PropertyChanged;

    [Introduce(WhenExists = OverrideStrategy.Ignore)]
    protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
    {
        if (string.IsNullOrWhiteSpace(propertyName)) throw new ArgumentNullException(nameof(propertyName));

        if (EqualityComparer<T>.Default.Equals(field, value)) return false;
        field = value;
        OnPropertyChanged(propertyName);
        return true;
    }
}

public class DoNotGenerateInpc : Attribute
{
}

public class GenerateStatusCommandsAttribute : TypeAspect
{
    public override void BuildAspect(IAspectBuilder<INamedType> builder)
    {
        foreach (var method in builder.Target.Methods.Where(p =>
                     (p.Attributes.Any(typeof(BlockingCommandAttribute)) ||
                      p.Attributes.Any(typeof(NonBlockingCommandAttribute))) && p.Parameters.Count == 0))
            builder.Advice.IntroduceAutomaticProperty(method.DeclaringType, $"{method.Name}Command",
                TypeFactory.GetType(typeof(RelayCommand)).ToNullableType(), IntroductionScope.Default,
                OverrideStrategy.Ignore,
                propertyBuilder => propertyBuilder.Accessibility = Accessibility.Public);

        foreach (var method in builder.Target.Methods.Where(p =>
                     (p.Attributes.Any(typeof(BlockingCommandAttribute)) ||
                      p.Attributes.Any(typeof(NonBlockingCommandAttribute))) && p.Parameters.Count == 1 &&
                     p.Parameters[0].Type.ToType() != typeof(CancellationToken)))
        {
            var firstParameterType = method.Parameters[0].Type;

            builder.Advice.IntroduceAutomaticProperty(method.DeclaringType, $"{method.Name}Command",
                ((INamedType)TypeFactory.GetType(typeof(RelayCommand<>)))
                   .WithTypeArguments(firstParameterType).ToNullableType(),
                IntroductionScope.Default,
                OverrideStrategy.Ignore,
                propertyBuilder =>
                {
                    propertyBuilder.Accessibility = Accessibility.Public;
                    propertyBuilder.InitializerExpression = null;
                });
        }

        foreach (var method in builder.Target.Methods.Where(p =>
                     (p.Attributes.Any(typeof(BlockingCommandAttribute)) ||
                      p.Attributes.Any(typeof(NonBlockingCommandAttribute))) && p.Parameters.Count == 1 &&
                     p.Parameters[0].Type.ToType() == typeof(CancellationToken)))
            builder.Advice.IntroduceAutomaticProperty(method.DeclaringType, $"{method.Name}Command",
                TypeFactory.GetType(typeof(RelayCommand)).ToNullableType(), IntroductionScope.Default, OverrideStrategy.Ignore,
                propertyBuilder => propertyBuilder.Accessibility = Accessibility.Public);

        builder.Advice.IntroduceMethod(builder.Target, "BuildCommands");
    }

    [Template]
    public void BuildCommands()
    {
        foreach (var loopMethods in meta.Target.Type.Methods.Where(p =>
                     p.Attributes.Any(typeof(BlockingCommandAttribute)) && p.Parameters.Count == 0))
            meta.InsertStatement(
                $"{loopMethods.Name}Command = StatusContext.RunBlockingTaskCommand({loopMethods.Name});");

        foreach (var loopMethods in meta.Target.Type.Methods.Where(p =>
                     p.Attributes.Any(typeof(NonBlockingCommandAttribute)) && p.Parameters.Count == 0))
            meta.InsertStatement(
                $"{loopMethods.Name}Command = StatusContext.RunNonBlockingTaskCommand({loopMethods.Name});");

        foreach (var loopMethods in meta.Target.Type.Methods.Where(p =>
                     p.Attributes.Any(typeof(BlockingCommandAttribute)) && p.Parameters.Count == 1))
            if (loopMethods.Parameters[0].Type != TypeFactory.GetType(typeof(CancellationToken)))
                meta.InsertStatement(
                    $"{loopMethods.Name}Command = StatusContext.RunBlockingTaskCommand<{loopMethods.Parameters[0].Type}>({loopMethods.Name});");
            else
                meta.InsertStatement(
                    $"{loopMethods.Name}Command = StatusContext.RunBlockingTaskWithCancellationCommand({loopMethods.Name}, \"Cancel {SplitCamelCase(loopMethods.Name)}\");");

        foreach (var loopMethods in meta.Target.Type.Methods.Where(p =>
                     p.Attributes.Any(typeof(NonBlockingCommandAttribute)) && p.Parameters.Count == 1 &&
                     p.Parameters[0].Type != TypeFactory.GetType(typeof(CancellationToken))))
            if (loopMethods.Parameters[0].Type != TypeFactory.GetType(typeof(CancellationToken)))
                meta.InsertStatement(
                    $"{loopMethods.Name}Command = StatusContext.RunNonBlockingTaskCommand<{loopMethods.Parameters[0].Type}>({loopMethods.Name});");
    }

    private string SplitCamelCase(string str)
    {
        //https://stackoverflow.com/questions/5796383/insert-spaces-between-words-on-a-camel-cased-token
        return Regex.Replace(Regex.Replace(str, @"(\P{Ll})(\P{Ll}\p{Ll})", "$1 $2"), @"(\p{Ll})(\P{Ll})", "$1 $2");
    }
}

public class BlockingCommandAttribute : Attribute
{
}

public class NonBlockingCommandAttribute : Attribute
{
}

Moving to Metalama is a tradeoff - The Windows Community Toolkit source generators offer more functionality than I will ever implement myself in Metalama and there is a cost if you want all of the Metalama features - but so far Metalama has offered good functionality without a major investment and I'm intrigued by the idea that code generation/aspects could be an integrated, easy to create, everyday part of coding a C# project.

2023 June Bird on a Wire
Purple Martin. Charles Miles. 6/4/2023.

PS - I had enough code to convert that I quickly got tired of editing everything 'manually' - but not enough (and enough variety) that a full scripted conversion didn't seem worthwhile. My in-between solution was helper LINQPad scripts that when run pull text off the clipboard, do some simple-but-helpful text conversion work and then put it back on the clipboard. This did speed me up - maybe it could help you with something so left here because ... why not!

//LinqPad snippet: Takes the text on the clipboard, does text replacement to convert
//[ObservableProperty] declarations to Public Property declarations and puts the 
//converted text on the clipboard:
//  [ObservableProperty] private int _userInitialValue = 1;
//becomes:
//  public int UserInitialValue { get; set; } = 1;
//
//Written to help convert blocks of MVVM Toolkit [ObservableProperty] declarations into
//public property declarations.

var source = Clipboard.GetText();

var lines = source.Split(";");

var results = new List<string>();

foreach (var loopLine in lines)
{
 if (string.IsNullOrWhiteSpace(loopLine) || loopLine.Contains("RelayCommand")) continue;
 
 var modifiedLine = string.Empty;
 modifiedLine = loopLine.Replace("[ObservableProperty] private ", "public ");
 if (modifiedLine.Contains("="))
 {
  modifiedLine = $"{modifiedLine.Replace("=", "{get; set;} =")};";
 }
 else
 {
  modifiedLine = $"{modifiedLine} {{ get; set;}}";
 }
 
 var underscorePosition = modifiedLine.IndexOf(" _");

 if (underscorePosition >= 0)
 {
  var underscoreAndLowercase = modifiedLine.Substring(underscorePosition + 1, 2);
  var underscoreAndUppercase = underscoreAndLowercase.Substring(1, 1).ToUpper();
  modifiedLine = modifiedLine.Replace(underscoreAndLowercase, underscoreAndUppercase);
 }
 
 results.Add(modifiedLine);
}

//Show the conversion in the output window and put it on the clipboard
String.Join(string.Empty, results).Dump();
Clipboard.SetText(String.Join(string.Empty, results));
//LinqPad Snippet: Takes the text on the clipboard, thru text replacement changes private
//field initialization to public property initializations and puts the coverted clip onto
//the clipboard for pasting - no code introspection, just text processing:
//  _userInitialValue = referenceObject.Something;
//to:
//  UserInitialValue = referenceObject.Something;
//
//Written as a little help for moving away from the MVVM Toolkits [ObservableProperty] 
//where you may end up with quite a bit of private field initialization - esp. in constructors 
//and in Nullable enabled scenarios - that you now want to be public property initialization.

var source = Clipboard.GetText();

var currentUnderscorePosition = source.IndexOf(" _");

while(currentUnderscorePosition >= 0){
 var underscoreAndLowercase = source.Substring(currentUnderscorePosition + 1, 2);
 var underscoreAndUppercase = underscoreAndLowercase.Substring(1, 1).ToUpper();
 source = source.Replace(underscoreAndLowercase, underscoreAndUppercase);
 currentUnderscorePosition = source.IndexOf(" _");
}

//Show the conversion in the output window and put it on the clipboard
source.Dump();
Clipboard.SetText(source);

Tags:
Posts Before/After: