How to add an event Action handler in PowerShell
Asked Answered
S

3

11

Terminal.Gui (gui.cs) provides a Button class with a Clicked event defined as:

        public event Action Clicked;

I'm trying to write a sample app for Terminal.Gui in PowerShell and am struggling to get an event handler wired up.

Add-Type -AssemblyName Terminal.Gui
[Terminal.Gui.Application]::Init() 
$win = New-Object Terminal.Gui.Window
$win.Title = "Hello World"
$btn = New-Object Terminal.Gui.Button
$btn.X = [Terminal.Gui.Pos]::Center()
$btn.Y = [Terminal.Gui.Pos]::Center()
$btn.Text= "Press me"

# Here lies dragons
[Action]$btn.Clicked = {
    [Terminal.Gui.Application]::RequestStop() 
}

$win.Add($btn)

[Terminal.Gui.Application]::Top.Add($win)
[Terminal.Gui.Application]::Run()  

The Clicked = assignment in the sample above returns an error:

InvalidOperation: The property 'Clicked' cannot be found on this object. Verify that the property exists and can be set.

But intellisense auto-completes Clicked for me... So I'm guessing it's a type issue?

I can't find any PowerShell docs on [Action] and no other samples I've found have given me any joy.

How does one define an event handler for an Action-based dotnet event in PowerShell?

Sousa answered 2/10, 2020 at 21:51 Comment(0)
O
18

Steve Lee's helpful answer provides the crucial pointer; let me complement it with background information:

PowerShell offers two fundamental event-subscription mechanism:

  • (a) .NET-native, as shown in Steve's answer, where you attach a script block ({ ... }) as a delegate to an object's <Name> event via the .add_<Name>() instance method (a delegate is a piece of user-supplied callback code to be invoked when the event fires) - see next section.

  • (b) PowerShell-mediated, using the Register-ObjectEvent and related cmdlets:

    • A callback-based approach, similar to (a), is available by passing a script block to the -Action paramter.
    • Alternatively, queued events can be retrieved on demand via the Get-Event cmdlet.

Method (b)'s callback approach only works in a timely fashion while PowerShell is in control of the foreground thread, which is not the case here, because the [Terminal.Gui.Application]::Run() call blocks it. Therefore, method (a) must be used.


Re (a):

C# offers syntactic sugar in the form of operators += and -= for attaching and detaching event-handler delegates, which look like assignments, but are in reality translated to add_<Event>() and remove_<Event>() method calls.

You can see these method names as follows, using the [powerShell] type as an example:

PS> [powershell].GetEvents() | select Name, *Method, EventHandlerType


Name             : InvocationStateChanged
AddMethod        : Void add_InvocationStateChanged(System.EventHandler`1[System.Management.Automation.PSInvocationStateChangedEventArgs])
RemoveMethod     : Void remove_InvocationStateChanged(System.EventHandler`1[System.Management.Automation.PSInvocationStateChangedEventArgs])
RaiseMethod      : 
EventHandlerType : System.EventHandler`1[System.Management.Automation.PSInvocationStateChangedEventArgs]

PowerShell offers no such syntactic sugar for attaching/removing event handlers, so the methods must be called directly.

Unfortunately, neither Get-Member nor tab-completion are aware of these methods, while, conversely, the raw event names confusingly do get tab-completed, even though you cannot directly act on them.

Github suggestion #12926 aims to address both problems.

Conventions used for event definitions:

The EventHandlerType property above shows the type name of the event-handler delegate, which in this case properly adheres to the convention of using a delegate based on generic type System.EventHandler<TEventArgs>, whose signature is:

public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e);

TEventArgs represents the type of the instance that contains event-specific information. Another convention is that such event-arguments type are derived from the System.EventArgs class, which the type at hand, PSInvocationStateChangedEventArgs, is.

Events that provide no event-specific information by convention use the non-generic System.EventHandler delegate:

public delegate void EventHandler(object? sender, EventArgs e);

Presumably, because this delegate was historically used for all delegates, even for those with event arguments - before generics came along in .NET 2 - an EventArgs parameter is still present, and the convention is to pass EventArgs.Empty rather than null to signal the absence of arguments.
Similarly, long-established framework types define non-generic custom delegates with their specific event-arguments type, e.g. System.Windows.Forms.KeyPressEventHandler.

None of these conventions are enforced by the CLR, however, as evidenced by the event in question being defined as public event Action Clicked;, which uses a parameterless delegate as the event handler.

It is generally advisable to adhere to the conventions so as not contravene user expectations, even though doing so is sometimes less convenient.


PowerShell is very flexible when it comes to using script blocks ({ ... }) as delegates, and it notably does not enforce a specific parameter signature via param(...):

The script block is accepted irrespective of whether it declares any, too many, or too few parameters, although those arguments that are actually passed by the event-originating object that do bind to script-block parameters must be type-compatible (assuming the script block's parameters are explicitly typed).

Thus, Steve's code:

$btn.Add_Clicked({
    param($sender, $e)
    [Terminal.Gui.Application]::RequestStop()
})

still worked, despite the useless parameter declarations, given that no arguments are ever passed to the script block, given that the System.Action delegate type is parameterless.

The following is sufficient:

$btn.Add_Clicked({
  [Terminal.Gui.Application]::RequestStop()
})

Note: Even without declaring parameters you you can refer to the event sender (the object that triggered the event) via the automatic $this variable (in this case, the same as $btn).


Streamlined sample code:

  • It is important to call [Terminal.Gui.Application]::Shutdown() in order to return the terminal to a usable state after exiting the application

  • At least one of the Terminal.Gui types isn't PowerShell-friendly:

    • What are conceptually text properties aren't implemented as type [string], but as [NStack.ustring]; while you can use [string] instances transparently to assign to such properties, displaying them again performs enumeration and renders the code points of the underlying characters individually.
      • Workaround: call .ToString(); e.g. $btn.Text.ToString()
  • As of PowerShell 7.3.2, there is no direct integration with NuGet packages, so it is quite cumbersome to load an installed package's assemblies into a PowerShell session - see this answer, which shows how to use the .NET Core SDK to download a package and also make its dependencies available.

    • In PowerShell (Core) 7.2+, the problem can be worked around in this case: The Microsoft.PowerShell.ConsoleGuiTools module ships with Terminal.Gui.dll, so you can install that module, and reference the DLL there.

      • This workaround is is courtesy of Jonathan DeMarks from this GitHub comment, and it is integrated into the sample code below.

      • In Windows PowerShell you'll have to follow the steps in the aforementioned answer first to make Terminal.Gui.dll available in order for the sample code to run.

    • Note that Add-Type -AssemblyName only works with assemblies that are either in the current directory (as opposed to the script's directory) or ship with PowerShell itself (PowerShell [Core] v6+) / are in the GAC (Windows PowerShell).

    • Given how cumbersome use of NuGet packages from PowerShell currently is, GitHub feature suggestion #6724 asks for Add-Type to be enhanced to support NuGet packages directly.

using namespace Terminal.Gui

# Load the Terminal.Gui.dll assembly, if necessary.
if (-not ('Terminal.Gui.Application' -as [Type])) {
  if ($PSVersionTable.PSVersion -ge '7.2') {
    # Load the Terminal.Gui assembly via the 'Microsoft.PowerShell.ConsoleGuiTools'
    # module, by installing that module on demand.
    if (-not (Get-Module -ListAvailable Microsoft.PowerShell.ConsoleGuiTools)) {
      Write-Verbose -Verbose "Installing module Microsoft.PowerShell.ConsoleGuiTools on demand, in the current user's scope."
      Install-Module -Scope CurrentUser -ErrorAction Stop Microsoft.PowerShell.ConsoleGuiTools
    }
    # Terminal.Gui.dll is inside the module's folder.
    try { Add-Type -LiteralPath (Join-Path (Get-Module -ListAvailable Microsoft.PowerShell.ConsoleGuiTools).ModuleBase Terminal.Gui.dll) } catch { throw }
  }
  else {
    # Windows PowerShell (or earlier PS Core versions)
    # Unfortunately, there's no easy way to gain access to Terminal.Gui.dll, and the
    # best option is to use an aux. NET SDK project as shown in https://mcmap.net/q/473773/-loading-assemblies-from-nuget-packages
    # The next command assumes that the steps there have been followed.
    try { Add-Type -Path $HOME\.nuget-pwsh\packages-winps\terminal.gui\*\Terminal.Gui.dll } catch { throw }
  }
}

# Initialize the "GUI".
# Note: This must come before creating windows and controls.
[Application]::Init()

$win = [Window] @{
  Title = 'Hello World'
}

$btn = [Button] @{
  X    = [Pos]::Center()
  Y    = [Pos]::Center()
  Text = 'Quit'
}
$win.Add($btn)
[Application]::Top.Add($win)

# Attach an event handler to the button.
# Note: Register-ObjectEvent -Action is NOT an option, because
# the [Application]::Run() method that isused to display the window is blocking.
$btn.add_Clicked({
    # Close the modal window.
    # This call is also necessary to stop printing garbage in response to mouse
    # movements later.
    [Application]::RequestStop()
  })

# Show the window (takes over the whole screen). 
# Note: This is a blocking call.
[Application]::Run()

# Required to restore the previous terminal screen
# and for being able to rerun the application in the same session.
[Application]::Shutdown()
Oblige answered 6/10, 2020 at 19:43 Comment(5)
I'm learning about events and happen to notice that you mention "you can refer to the event sender ... via the automatic $this variable". For completion, you might add that $e can be referred to via automatic $_ variable. I don't fully understand when and where this works, but seems true when an instance of a class inheriting from EventArgs is passed as an argument. I have code where it worked with MouseDown, MouseMove, and Paint events in WinForms. And Yesterday I created a C# class from scratch and $_ was available in the PowerShell script using it.Breen
@Darin, even if that works (I've never tried), I wouldn't rely on it, given that it is neither documented nor makes sense conceptually.Oblige
Looks like the closest to docs I can find is WPF & PowerShell — Part 3 (Handling Events) by James Brundage: "It is possible to cast a script block to an event handler. The script block has two variables: $this (which is the sender), and $_, which contains the event arguments."Breen
@Darin, if you can get it to work consistently, you could try to convince the documentation team to document it as such. However, as stated, conceptually it doesn't make sense to me, and even the way $this currently works is problematic.Oblige
Thank you for the link. Kind of sad they didn't make it $sender and $e from day one. All the programmer would have to remember is placing the $ in front of the parameter names they normally use in C#. Based on the issue with $this, and what I'm now running into with Register-EngineEvent and New-Event, I'm thinking I will standardize my code with $sender and $e, either via param() or direct assignment as needed. Thank you for taking the time to respond to my comments and have a good evening/day.Breen
A
10

The C# code would be adding a lambda:

btn.Clicked += ...

So in PowerShell, you need to explicitly call the Add_Clicked() method:

$btn.Add_Clicked({
    [Terminal.Gui.Application]::RequestStop()
})

The params match the method signature although not used in this example.

Atalaya answered 3/10, 2020 at 4:7 Comment(1)
Nice, though note that the standard signature you're showing (more specifically, param([object] $sender, [EventArgs] $eventArgs), where a class derived from [EventArgs] is by convention used to pass actual arguments) happens not to apply here, because the event in question is defined in a nonstandard way: it is declared as taking an Action delegate, which is parameterless. Terminology quibble: event handlers are delegates, and in C# both regular methods and lambda expressions (anonymous methods) can serve as such.Oblige
I
2

This change does not display the error but the event seems not to be firing.

Register-ObjectEvent -InputObject $btn -EventName Clicked --Action {
        [Terminal.Gui.Application]::RequestStop() 
}

Edit:

@Steve Lee's solution works like a charm, but what is also needed is to add [Terminal.Gui.Application]::Shutdown() at the end. The param($sender,$e) is not needed because it's not a EventHandler but a event Action. Thanks.

Interpretive answered 3/10, 2020 at 3:4 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.