Search code examples
.netpowershellevent-handlingterminal.gui

How to add an event Action handler in PowerShell


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?


Solution

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

    PowerShell offers two fundamental event-subscription mechanisms:

    • (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://stackoverflow.com/a/50004706/45375
        # 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()