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

3
  • 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) Commented Jul 24, 2023 at 11:15
  • Weird, I got Hello XXXX. I'm trying to get Hello World @mklement0 answer explains it Commented Jul 26, 2023 at 7: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? Commented Jul 26, 2023 at 14:26

3 Answers 3

2

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.

Sign up to request clarification or add additional context in comments.

2 Comments

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
Thanks for the feedback, @tamir. I encourage you to write up an answer of your own, based on your comment.
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()

Comments

0

To further build on the helpful answer from @mklement0 as in a common use case:

  • You just want to replace specific variables (placeholders) and don't want to escape all other variables (that start with a $) with a backtick (`)
  • You might prefer to use a script block (surrounded by parenthesis: { ... }) rather than a string (or a here-string) surrounded by quotes. The advantage is that design-time code analyzers (including color coding) is still working.
    • For this the first desire is a requirement

So the question here is: what would be a robust PowerShell compatible placeholder that doesn't interfere with the rest of the code or break the PowerShell syntax.
I think this would be an inline block comment: <# <expression> #> where <expression> defines the static value embedded in the code.

This might be done with the following regular expression and callback substitution:

$PlaceHolder = '<# (.+?) #\>'
$Substitute = { Invoke-Expression $Args.Groups[1] }

Note:
in PowerShell 6 and later, you might also use the Replacement with a script block (-replace $PlaceHolder, $Substitute) for this.

Example 1:

$a = 3
$b = 4

$Code = { Write-Host 'The predefined sum of $a and $b is <# $a + $b #>' }
$Code = [ScriptBlock]::Create([Regex]::Replace($Code, $PlaceHolder, $Substitute))
& $Code

The predefined sum of $a and $b is 7

Example 2:

$Script = {
    function Say-Hello {
        Param (
            [Parameter(Mandatory=$true)]
            [ValidateNotNullOrEmpty()]
            [String]$name
        )

        Write-Host '<# $name #>' $name
    }
}

$Name = 'Hello'
. ([ScriptBlock]::Create([Regex]::Replace($Script, $PlaceHolder, $Substitute)))
$Name = 'World'
Say-Hello $Name

Hello World

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.