1

I'm trying to generate a dynamic UI. I haven't been able to add an OnClick event dynamically. Here's a sample

function Say-Hello
{
    Param (
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [String]$name
    )
    
    Write-Host "Hello " + $name
}

$name = "World"

$null = [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")

$mainform = New-Object System.Windows.Forms.Form

$b1 = New-Object System.Windows.Forms.Button
$b1.Location = New-Object System.Drawing.Point(20, 20)
$b1.Size = New-Object System.Drawing.Size(80,30)
$b1.Text = "Start"
#$b1.Add_Click({Say-Hello $name})
$b1.Add_Click({Say-Hello $name}.GetNewClosure())


$mainform.Controls.Add($b1)

$name = "XXXX"

$mainform.ShowDialog() | Out-Null

First I've tried with $b1.Add_Click({Say-Start $name}) but that yields Hello XXXX. I then tried the above code as it is $b1.Add_Click({Say-Hello $name}.GetNewClosure()) and I got an error that Say-Hello is not found (Say-Hello : The term 'Say-Hello' is not recognized as the name of a cmdlet, function, script file...)

The reason I'm overriding the name, is because I actually want to turn the button creation to a function that I will call several ties, each time with a different $name parameter.

Any suggestions how to handle this?

thanks

mklement0
  • 382,024
  • 64
  • 607
  • 775
tamir
  • 81
  • 2
  • 9
  • 2
    Just to clarify, are you expecting ```Hello World``` or ```Hello XXXX``` on the console? When I run your code I see ```Hello + World``` printed to the console (although you probably want to use ```Write-Host ("Hello " + $name)``` or ```Write-Host "Hello $name"``` instead) – mclayton Jul 24 '23 at 11:15
  • Weird, I got `Hello XXXX`. I'm trying to get `Hello World` @mklement0 answer explains it – tamir Jul 26 '23 at 07:08
  • @mclayton's comment re `Write-Host` was an aside to point out a syntax problem: `Write-Host 'hi ' + 'there'` prints verbatim `hi + there`, because the lack of `(...)` enclosure around the `+` operation means that _three separate arguments_ are passed. As for the answer: glad to hear it explains your intent, but does it also solve your problem? – mklement0 Jul 26 '23 at 14:26

2 Answers2

1

It sounds like you want to use a script block to create a closure over the state of your $name variable, meaning that the value of $name should be locked in at the time of creating the closure with .GetNewClosure(), without being affected by later changes to the value of the $name variable in the caller's scope.

The problem is that PowerShell uses a dynamic module to implement the closure, and - like all modules - the only ancestral scope a dynamic module shares with an outside caller is the global scope.

In other words: the dynamic module returned by .GetNewClosure() does not know about your Say-Hello function, because it was created in a child scope of the global scope, which is where scripts and functions run by default.

  • As an aside: If you were to dot-source your script from the global scope, the problem would go away, but that is undesirable, because you would then pollute the global scope with all the variable, function, ... definitions in your script.

  • Selectively defining your function as function global:Say-Hello { ... } is a "less polluting" alternative, but still suboptimal.


Solution:

Redefine the function in the context of the script block for which the closure will be created.

Here's a simplified, stand-alone example:

& { # Execute the following code in a *child* scope.

  $name = 'before' # The value to lock in.

  function Say-Hello { "Hello $name" } # Your function.

  # Create a script block from a *string* inside of which you can redefine
  # function Say-Hello in the context of the dynamic module.
  $scriptBlockWithClosure = 
    [scriptblock]::Create("
      `${function:Say-Hello} = { ${function:Say-Hello} }
      Say-Hello `$name
    ").GetNewClosure()

  $name = 'after'

  # Call the script block, which still has 'before' as the value of $name
  & $scriptBlockWithClosure # -> 'Hello before'
}
  • ${function:Say-Hello} is an instance of namespace variable notation - see this answer for general background information.

  • On getting an expression such as ${function:Say-Hello}, the targeted function's body is returned, as a [scriptblock] instance.

  • On assigning to ${function:Say-Hello}, the targeted function is defined; the assignment value can either be a script block or a string containing the function's source code (without enclosing it in { ... })

    • In the above code, an expandable (double-quoted) string ("..."), i.e. string interpolation is used to embed the stringified source code of the script block returned by ${function:Say-Hello} in the string passed to [scriptblock]::Create()

    • By enclosing the ${function:Say-Hello} reference in { ... }, the stringified script block - which stringifies without the { ... } enclosure - becomes a script block literal in the source code from which the script block is constructed.

mklement0
  • 382,024
  • 64
  • 607
  • 775
  • Thanks for the elaborate explanation. I did suspect some scoping issues, due to the error message, but I didn't know how to handle it. For now, I implemented one of the suggestions you made, the one that's polluting the global scope. Mainly because I wanted to keep simplicity. My next question would be, what if `Say-Hello` calls other functions. So what I eventually did, I implemented a single function `global:Call-LocalFunction` that calls any local function by name, so I keep the pollution minimal – tamir Jul 26 '23 at 15:17
  • Thanks for the feedback, @tamir. I encourage you to write up an answer of your own, based on your comment. – mklement0 Jul 26 '23 at 15:22
0

Following @mklement0's answer and comment I wrote a small sample to demonstrate the issue and a solution. The code below shows the next options

  1. Button "A" - a simple block, using a built-in (global) cmdlet with a 'constant' string

  2. Button "B" - generating block dynamically from parameters. This shows the problem - "B" is kept in a variable passed to AddButtonAutoBlock and it does not exist when the button is pressed. The message prints is empty.

  3. Button "C" - a closure is generated from the block as in "B". However, "C" is copied, but the function Show-Message is unknown from the global scope, so it get's an error

  4. Button "D" - polluting the global scope with a global function. This overcomes the problem in "C" and works

  5. Button "E" - to avoid filling the global scope with this script's functions a single callback is used. The callback internally dispatches the call to the right local function

  6. Button "F" - a global callback is used, calling a local function. This time the call is a bit more generic. The call is directed back into the same object that actually holds the button.

Comments

  • "E" has an if-else structure that needs to be extended for each new callback, but "F" is using the same code for all callbacks. However, "E" is more generic regarding parameter types. Call-LocalObjectCallbackString is invoking "$callback('$arg0')" assuming $arg0 is a string
  • Is there a way to combine both - have a generic call back with generic parameter list? I tried passing a list, but the issue is that GetNewClosure converts the data to (what seems like) a raw string. Perhaps some pack and unpack operations can help here.
  • The singleton show here is trivial, but there's a nice, more formal one here
function Show-Message
{
    Param([String]$message)
    
    Write-Host "Message: $message"
}

function Show-Message-Beautify
{
    Param([String]$message)
    
    Write-Host "Message: <<<$message>>>"
}

function global:Show-Message-Global
{
    Param([String]$message)
    
    Show-Message $message
}

function global:Show-Message-Global-Callback
{
    Param($callback, $arg0)
    
    if ($callback -eq "Show-Message")
    {
        Show-Message $arg0
    }
    elseif  ($callback -eq "Show-Message-Beautify")
    {
        Show-Message-Beautify $arg0
    }
    else
    {
        # throw exception
    }
}

function global:Call-LocalObjectCallbackString
{
    Param($callback, $arg0)
    
    Invoke-Expression -Command "$callback('$arg0')"
}

class MainForm
{
    static [MainForm]$mainSingletone = $null
    
    static [MainForm] Instance()
    {
        return [MainForm]::mainSingletone
    }
    
    static ShowMainForm()
    {
        $null = [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")
        
        $main = [MainForm]::new()
        $main.AddButtonBlock("A", {Write-Host "A"})
        $main.AddButtonAutoBlock("B")
        $main.AddButtonAutoBlockClosure("C")
        $main.AddButtonAutoBlockGlobalClosure("D")
        $main.AddButtonAutoBlockGlobalClosureCallback("E")
        $main.AddButtonAutoBlockGlobalClosureCallbackObject("F")
        
        $main.form.ShowDialog() | Out-Null
    }
    
    # non statics

    $form
    [int] $nextButtonOffsetY

    MainForm()
    {
        $this.form = New-Object System.Windows.Forms.Form
        $this.form.Text = "test"
        $this.form.Size = New-Object System.Drawing.Size(200,400)
        $this.nextButtonOffsetY = 20
        
        [MainForm]::mainSingletone = $this
    }

    [object] AddButton($name)
    {
        $b = New-Object System.Windows.Forms.Button
        $b.Location = New-Object System.Drawing.Point(20, $this.nextButtonOffsetY)
        $b.Size = New-Object System.Drawing.Size(160,30)
        $b.Text = $name
        
        $this.nextButtonOffsetY += 40
        $this.form.Controls.Add($b)
        
        return $b
    }

    AddButtonBlock($name, $block)
    {
        $b = $this.AddButton($name)
        $b.Add_Click($block)
    }

    AddButtonAutoBlock($name)
    {
        $b = $this.AddButton($name)
        $b.Add_Click({Show-Message $name})
    }
    
    AddButtonAutoBlockClosure($name)
    {
        $b = $this.AddButton($name)
        $b.Add_Click({Show-Message $name}.GetNewClosure())
    }

    AddButtonAutoBlockGlobalClosure($name)
    {
        $b = $this.AddButton($name)
        $b.Add_Click({Show-Message-Global $name}.GetNewClosure())
    }
    
    AddButtonAutoBlockGlobalClosureCallback($name)
    {
        $b = $this.AddButton($name)
        $b.Add_Click({Show-Message-Global-Callback "Show-Message" $name}.GetNewClosure())

        $b = $this.AddButton("Beautify-$name")
        $b.Add_Click({Show-Message-Global-Callback "Show-Message-Beautify" $name}.GetNewClosure())
    }
    
    Callback ($message)
    {
        Write-Host "Callback: $message"
    }
    
    AddButtonAutoBlockGlobalClosureCallbackObject($name)
    {
        $b = $this.AddButton($name)
        $b.Add_Click({Call-LocalObjectCallbackString "[MainForm]::Instance().Callback" $name}.GetNewClosure())
    }
}

[MainForm]::ShowMainForm()

tamir
  • 81
  • 2
  • 9