2

I'm experimenting with creating GUIs and using classes in powershell. I'm really new to both of those things (and to a lesser extent powershell generally) so bear with me.

The problem I am having is I cannot make any control which makes any modification to the form. This is because when adding a handler to a button it goes into the scope of the button class in the handler and none of the form references are accessible.

Most examples of UI code in powershell are not class heavy. I realize that I could get around this, if it was not in a class, by having the handlers and form being in the global scope, but I'm trying to make use of classes so that I have the ability to make base forms and inherit from them. And I want to see what is possible.

Below is some test code including multiple of my attempts to make this work with the results commented. I even got the idea of passing the form reference into the handler (DI style). Things I'm trying are all over the map since I'm also feeling out basic powershell syntax.

Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing

 
class Window : System.Windows.Forms.Form
{
   Handler () {
      Write-Host Handler
      $this.BackColor = [System.Drawing.Color]::Blue
   }

   HandlerArgs ([object]$sender, [System.Eventargs]$eventArgs) {
      Write-Host HandlerArgs
      $this.BackColor = [System.Drawing.Color]::Blue
   }
 
   $HandlerVar = {
      Write-Host HandlerVar
      $this.BackColor = [System.Drawing.Color]::Blue
   }

   HandlerParam ($form) {
      Write-Host HandlerParam
      $form.BackColor = [System.Drawing.Color]::Blue
   }

   $HandlerVarParam = {
      (params $form)
      Write-Host HandlerVarParam
      $form.BackColor = [System.Drawing.Color]::Blue
   }
 
   Window ()
   {
      $button = New-Object System.Windows.Forms.Button
      $button.Text = "ClickMe"
      $button.AutoSize = $true
      $this.Controls.Add($button)



      # $button.Add_Click( $this.Handler )
      # "Cannot convert argument "value", with value: "void Handler()", for "add_Click"
      # to type "System.EventHandler": "Cannot convert the "void SelectNextPage()" 
      # value of type "System.Management.Automation.PSMethod" to type "System.EventHandler"."

      # $button.Add_Click(([System.EventHandler]$x = $this.Handler ))
      # turns the window blue immediatly
 
      # $button.Add_Click( $this.HandlerArgs )
      # "Cannot convert the "void HandlerArgs(System.Object sender, System.EventArgs eventArgs)" 
      # value of type "System.Management.Automation.PSMethod" to type "System.EventHandler".""

      # $button.Add_Click( $this.HandlerVar ) 
      # this works but turns the button blue instead of the form

      # $button.Add_Click( { $this.Handler } )
      # does nothing?

      # $button.Add_Click( { $this.Handler() } )
      # Method invocation failed because [System.Windows.Forms.Button] does not contain a 
      # method named 'Handler'.

      # $button.Add_Click( $this.HandlerParam($this) )
      # turns the window blue immediatly 

      # $button.Add_Click( { $this.HandlerParam($this) } )
      # Method invocation failed because [System.Windows.Forms.Button] does not contain a
      # method named 'HandlerParam'.

      # $button.Add_Click( $this.HandlerVarParam $this )
      # parse error
      # I can't find a syntax that lets me pass a param to a function in a variable
   }
}


$foo = New-Object Window
$foo.ShowDialog()

Although it's likely super obvious already, c# is my main language.

Perhaps this is just a limitation of the OO support in an interpreted scripting language, or maybe it's just my syntax deficiency. Is there any pattern that will get me what I want in this class-based structure? I would hope the pattern would be a general solution for doing normal form-things with handlers of form-controls.

user1169420
  • 680
  • 7
  • 18

2 Answers2

4

Although mklement0 is absolutely spot on - class method can't be used directly as event delegates - there is a way to bind the instance to it without storing the handler in a property.

The following approach fails because $this resolves to the event owner at runtime:

$button.add_Click( { $this.Handler() } )
# Method invocation failed because [System.Windows.Forms.Button] does not contain a 
# method named 'Handler'.

You can bypass this late-binding behavior by using any other (non-automatic) local variable to reference $this and then closing over it before calling add_Click():

$thisForm = $this
$button.add_Click( { $thisForm.Handler() }.GetNewClosure() )
# or 
$button.add_Click( { $thisForm.HandlerArgs($this,$EventArgs) }.GetNewClosure() )

Now, $thisForm will resolve to whatever $this referenced when the handler was added, and the button will work as expected.

Mathias R. Jessen
  • 157,619
  • 12
  • 148
  • 206
  • @mklement0 Interesting. I'm not sure I'd prefer it over just doing the variable re-binding in the calling method even when registering handlers on different targets - you only need to do the local assignment _once_ anyway, and with the stored handler scriptblocks you still need to manually maintain alignment between the variable reference used in the delegate block and the `GetHandler()` method - we end up adding complexity without gaining anything in terms of maintainability (unless I'm missing something) – Mathias R. Jessen Oct 07 '20 at 16:27
  • 1
    I should note that I'm reasoning _specifically_ about a case like this, where all event handlers are registered from the same method. If you want to reuse the same block as a delegate and register it to different owners at different points in the object's lifetime (ie. from different methods), it makes perfect sense to implement it as you have – Mathias R. Jessen Oct 07 '20 at 16:30
  • One more thought: To pass all arguments through, notably the event arguments, your second `.add_Click()` command must actually be defined as `$button.add_Click( { param($sender, $eventArgs) $thisForm.HandlerArgs($sender, $eventArgs) }.GetNewClosure() )` - still serviceable, but a little cumbersome. – mklement0 Oct 07 '20 at 16:42
  • 1
    Final thought, take 2: I think I found a solution that avoids the coupling problem: stick with methods as event handlers (as you did) and pass the _method object_ to a generic helper method that wraps the invocation of that method in a script block closed over the helper method's variable (the closure is necessary to capture the method-object parameter variable alone, obviating the need for an aux. variable such as `$thisForm`) - please see my update. – mklement0 Oct 07 '20 at 17:39
3

PowerShell, as of PowerShell 7.1, only knows how to pass script blocks as event delegates, not custom-class methods:

Mathias R. Jessen's helpful answer shows how to work around this limitation:

  • By wrapping a call to the event-handler method in a script block...

  • ... and additionally providing a closure that captures a variable that refers to the class instance usually accessible as $this under a different name, to work around $this inside the script block referring to the event-originating object instead (same as the first handler argument, $sender), so as to ensure that access to the class instance remains possible.

    • As an aside: in this particular case, a solution without a .GetNewClosure() call would have been possible too, by calling $this.FindForm().

The following defines an idiom for generalizing this approach by encapsulating via a single helper method, which may be of interest if you have multiple event handlers in your class.:

  • As in your original approach and in Mathias' solution, individual event handlers are defined as you would normally define them in C#, as instance methods.

  • An auxiliary GetHandler() method encapsulates the logic of wrapping a call to a given event-handler method in a script block so that it is accepted by .add_{Event}() calls.

    • Any .add_{EventName}() call must then be passed the event-handler method via this auxiliary $this.GetHandler() method, as shown below.
Add-Type -AssemblyName System.Windows.Forms

class Window : System.Windows.Forms.Form {

  # Define the event handler as an instance method, as you would in C#.
  hidden ClickHandler([object] $sender, [EventArgs] $eventArgs) {

    # Diagnostically print info about the sender and the event arguments.
    $sender.GetType().FullName | Write-Verbose -vb
    $eventArgs | Format-List | Out-String | Write-Verbose -vb
  
    $this.BackColor = [System.Drawing.Color]::Blue
  }
  
  # Define a generic helper method that takes an event-handling instance method 
  # and turns it into a script block, so that it is accepted by 
  # .add_{Event}() calls.
  hidden [scriptblock] GetHandler([Management.Automation.PSMethod] $method) {

    # Wrap the event-handling method in a script block, because only a 
    # script block can be passed to .add_{Event}() calls.
    # The .GetNewClosure() call is necessary to ensure that the $method variable
    # is available when the script block is called by the event.
    # Calling via a *method* also ensures that the method body still sees $this
    # as the enclosing class instance, whereas the script block itself sees
    # $this as the event-originating object (same as $sender).
    return { 
      param([object] $sender, [EventArgs] $eventArgs)
      $method.Invoke($sender, $eventArgs) 
    }.GetNewClosure()

  }

  # Constructor.
  Window() {

    $button = New-Object System.Windows.Forms.Button
    $button.Text = "ClickMe"
    $button.AutoSize = $true
    $this.Controls.Add($button)

    # Pass the event-handler method via the GetHandler() helper method.
    $button.add_Click( $this.GetHandler($this.ClickHandler) )

  }

}

$foo = New-Object Window
$foo.ShowDialog()

Note:

  • Add-Type -AssemblyName System.Windows.Forms must actually be executed before the script loads in order for you custom class definition deriving from System.Windows.Forms.Form to work, which is a known problem:

    • Only if the assembly containing the type that a custom PS class derives from has already been loaded at script parse time does the class definition succeed - see GitHub issue #3641.

    • The same applies to the using assembly statement - see about_Using, (which in PowerShell [Core] as of 7.0 has the added problem of not recognizing well-known assemblies such as System.Windows.Forms - see GitHub issue #11856)

In general, support for custom classes in PowerShell is, unfortunately, very much a work in progress, and many existing issues are tracked in GitHub meta issue #6652.

mklement0
  • 382,024
  • 64
  • 607
  • 775