4

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?

JPBlanc
  • 67,114
  • 13
  • 128
  • 165
tig
  • 3,283
  • 3
  • 30
  • 64

3 Answers3

7

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({
    param($sender,$e)
    [Terminal.Gui.Application]::RequestStop()
})

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

Steve Lee
  • 221
  • 2
  • 3
  • 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`](https://docs.microsoft.com/en-US/dotnet/api/System.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. – mklement0 Oct 08 '20 at 20:00
6

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 even 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:

Notes as of version 1.0.0-pre.4:

  • At least on macOS, for the terminal to return the terminal to a usable state after exiting the application, the following additional actions were needed:

    • [Terminal.Gui.Application]::Shutdown(). Without it, reinvoking the application in the same session didn't work.
    • tput init. Without it, later command-line editing broke (notably, up- and down-arrow).
  • The Terminal.Gui types aren't PowerShell-friendly, for two reasons:

    • [View] and its subclasses implement the IEnumerable interface, which causes PowerShell's default output formatting to attempt enumeration, which results in no output.

      • Workaround: $btn.psobject.Properties | select Name, Value, TypeNameOfValue
    • 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().
    • tig (the OP) has submitted GitHub issue #951 to potentially fix this behavior.

  • As of PowerShell 7.1, 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.

    • 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 assembly and its dependencies (assumed to be in the
# the same directory).
# NOTE: `using assmembly <path>` seemingly only works with full, literal paths
# as of PowerShell Core 7.1.0-preview.7.
# The assumption here is that all relevant DLLs are stored in subfolder
# assemblies/bin/Release/*/publish of the script directory, as shown in 
#   https://stackoverflow.com/a/50004706/45375
Add-Type -Path $PSScriptRoot/assemblies/bin/Release/*/publish/Terminal.Gui.dll

# 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 used 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()

# As of 1.0.0-pre.4, at least on macOS, the following two statements
# are necessary on in order for the terminal to behave properly again.
[Application]::Shutdown() # Clears the screen too; required for being able to rerun the application in the same session.
tput init # Reset the terminal to make PSReadLine work properly again, notably up- and down-arrow.
mklement0
  • 312,089
  • 56
  • 508
  • 622
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.

mklement0
  • 312,089
  • 56
  • 508
  • 622
BDisp
  • 39
  • 4