1

I am working on a PowerShell script with a small WPF GUI. My code is organzized in a class from which a singleton is created. I have read that $this inside an event handler script block points to the event sender and not to my containing class instance. How can I access my class instance from the event handler?

Ex.

class MyClass {

    $form  #Reference to the WPF form


    [void] StartAction([object] $sender, [System.Windows.RoutedEventArgs] $e) {
        
        ...
    }


    [void] SetupEventHandlers() {

        $this.form.FindName("BtnStartAction").add_Click({ 
            param($sender, $e) 
            # !!!! Does not work, $this is not my class instance but the event sender !!!!
            $this.StartAction($sender, $e) 
        })

    }


    [void] Run() {

        $this.InitWpf() #Initializes the form from a XAML file.

        $this.SetupEventHandlers()

        ...
    }
}


$instance = [MyClass]::new()
$instance.Run()
NicolasR
  • 2,222
  • 3
  • 23
  • 38
  • 3
    Can you try creating an instance inside `SetupEventHandlers` like `$instance=$this` and use `$instance` inside the handler(`add_Click`)? – Abdul Niyas P M Nov 22 '21 at 17:20
  • the `$sender` is your class in `add_click`. `StartAction` is not an event handler, it is just a method of your class. Modify `StartAction` signature and call the method `([MyClass]$sender).StartAction()` in the event handler by casting `$sender` – Hazrelle Nov 22 '21 at 19:32

2 Answers2

2
  • Indeed, the automatic $this variable in a script block acting as a .NET event handler refers to the event sender.

  • If an event-handler script block is set up from inside a method of a PowerShell custom class, the event-sender definition of $this shadows the usual definition in a class method (referring to the class instance at hand).

There are two workarounds, both relying on PowerShell's dynamic scoping, which allows descendant scopes to see variables from ancestral scopes.

  • Use Get-Variable -Scope 1 to reference the parent scope's $this value (event-handler script blocks run in a child scope of the caller).
    [void] SetupEventHandlers() {

        $this.form.FindName("BtnStartAction").add_Click({ 
            param($sender, $e) 
            # Get the value of $this from the parent scope.
            (Get-Variable -ValueOnly -Scope 1 this).StartAction($sender, $e) 
        })

    }
  • Taking more direct advantage of dynamic scoping, you can go with Abdul Niyas P M's suggestion, namely to define a helper variable in the caller's scope that references the custom-class instance under a different name, which you can reference in - potentially multiple - event-handler script blocks set up from the same method:

    • Note that a call to .GetNewClosure() is required on the script block, so as to make the helper variable available inside the script block.Tip of the hat to Sven.
    [void] SetupEventHandlers() {

        # Set up a helper variable that points to $this
        # under a different name.
        $thisClassInstance = $this

        # Note the .GetNewClosure() call.
        $this.form.FindName("BtnStartAction").add_Click({ 
            param($sender, $e) 
            # Reference the helper variable.             
            $thisClassInstance.StartAction($sender, $e) 
        }.GetNewClosure())

    }

Also note that, as of PowerShell 7.2, you cannot directly use custom-class methods as event handlers - this answer shows workarounds, which also require solving the $this shadowing problem.


Self-contained sample code:

  • Important: Before running the code below, ensure that you have run the following in your session:

    # Load WPF assemblies.
    Add-Type -AssemblyName PresentationCore, PresentationFramework
    
  • Unfortunately, placing this call inside a script that contains the code below does not work, because class definitions are processed at parse time, i.e., before the Add-Type command runs, whereas all .NET types referenced by a class must already be loaded - see this answer.

    • While using assembly statements in lieu of Add-Type calls may some day be a solution (they too are processed at parse time), their types aren't currently discovered until runtime, leading to the same problem - see GitHub issue #3641; as a secondary problem, well-known assemblies cannot currently be referenced by file name only; e.g., using assembly PresentationCore.dll does not work, unlike Add-Type -AssemblyName PresentationCore - see GitHub issue #11856
# IMPORTANT: 
#  Be sure that you've run the following to load the WPF assemblies
#  BEFORE calling this script:
#    Add-Type -AssemblyName PresentationCore, PresentationFramework

class MyClass {

  $form  #Reference to the WPF form

  [void] InitWpf() {
    [xml] $xaml=@"
    <Window
            xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
            xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
            xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
            xmlns:local="clr-namespace:Test"
            Title="MainWindow" Height="500" Width="500">
        <Grid>
            <Button x:Name="BtnStartAction" Content="StartAction" HorizontalAlignment="Left" VerticalAlignment="Top" Width="100" IsDefault="True" Height="22" Margin="170,0,0,0" />        
            <TextBox x:Name="Log" Height="400" TextWrapping="Wrap" VerticalAlignment="Top" AcceptsReturn="True" AcceptsTab="True" Padding="4" VerticalScrollBarVisibility="Auto" Margin="0,40,0,0"/>
        </Grid>
    </Window>
"@    
    $this.form = [Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $xaml))
  }

  [void] StartAction([object] $sender, [System.Windows.RoutedEventArgs] $e) {
      
      $tb = $this.form.FindName("Log")
      $tb.Text += $e | Out-String
  }


  [void] SetupEventHandlers() {

      $btn = $this.form.FindName("BtnStartAction")

      # -- Solution with Get-Variable
      $btn.add_Click({ 
          param($sender, $e) 
          (Get-Variable -ValueOnly -Scope 1 this).StartAction($sender, $e) 
      }.GetNewClosure())

      # -- Solution with helper variable.
      # Note the need for .GetNewClosure()
      # Helper variable that points to $this under a different name.
      $thisClassInstance = $this
      $btn.add_Click({ 
          param($sender, $e) 

          $thisClassInstance.StartAction($sender, $e)
      }.GetNewClosure())

  }

  [void] Run() {
      $this.InitWpf() #Initializes the form from a XAML file.
      $this.SetupEventHandlers()
      $this.form.ShowDialog()
  }
}


$instance = [MyClass]::new()
$instance.Run()

The above demonstrates both workarounds: when you press the StartAction button, both event handlers should add the event-arguments object they've each received to the text box, as shown in the following screenshot:

screenshot

mklement0
  • 382,024
  • 64
  • 607
  • 775
  • Are you sure this is working? I just tested it and it didn't work for me. In fact the method .GetNewClosure() on the scriptblock is missing, to make sure that the local vars are available inside the scriptblock. – Sven Sep 09 '22 at 07:35
  • Thank you, @Sven. While the `Get-Variable` workaround worked fine, the helper-variable workaround indeed requires `.GetNewClosure()` - please see my update, which includes self-contained sample code. – mklement0 Sep 09 '22 at 18:18
0

Try this sample

class MyClass {

    $form  #Reference to the WPF form


    [void] StartAction([System.Windows.RoutedEventArgs] $e) {
        #$this ...
    }


    [void] SetupEventHandlers() {

        $this.form.FindName("BtnStartAction").add_Click({ 
            param($sender, $e) 
            ([MyClass]$sender).StartAction($e)
        })

    }


    [void] Run() {
        $this.InitWpf()
        $this.SetupEventHandlers()
    }
}


$instance = [MyClass]::new()
$instance.Run()

Edit 1: Could you try creating a delegate referring to a dedicated method of the class like this?

class MyClass {

    $form  #Reference to the WPF form
    
    [void] StartAction() {
        #$this ...
    }


    [void] SetupEventHandlers() {
        $handler = [System.EventHandler]::CreateDelegate([System.EventHandler], $this, "Handler")
    
        $this.form.FindName("BtnStartAction").add_Click($handler)

    }

    [void] Handler ([System.Object]$sender, [System.EventArgs]$e) {
        $this.StartAction()
    }

    [void] Run() {
        $this.InitWpf()
        $this.SetupEventHandlers()
    }
}


$instance = [MyClass]::new()
$instance.Run()
Hazrelle
  • 758
  • 5
  • 9
  • Note that the `$sender` in the event-handler script block is the _event sender, i.e. the WPF button control, not the custom-class instance at hand. Unfortunately, `$this` _also_ refers to the event sender, and it is this shadowing of `$this` that makes it challenging to refer to the custom-class instance at hand. – mklement0 Nov 22 '21 at 21:31
  • 1
    I confirm that this does not work for me. Debugging shows: $sender.Name is BtnStartAction, so it's the button and not the custom-class instance and trying to cast it to MyClass fails. – NicolasR Nov 22 '21 at 22:24