3

I am trying to dynamically add buttons to a list, and have $Button (or another variable) be set to whatever is clicked (No OK/Cancel button needed).

The below code creates the buttons, and resizes the window fine, but I cannot for the life of me figure out how to return the button name, or anything else unique to it. I have tried too many things to remember, but throughout all those tries, write-host $Button.name (when added, not below) works fine upon clicking, but with similar to below either $Button is empty, or contains "cancelled" (which I guess is because of the form.close()), or only the first assigned button .name. What am I missing?

#Add assemblies
add-type -assemblyname System.Windows.Forms
add-type -assemblyname System.Drawing

#Define the form
$Form = new-object System.Windows.Forms.Form
$Form.text = 'Which ticket to work on?'

$Tickets = "A","2","i"

#Define each button
for($i = 0; $i -lt $Tickets.count; $i++)
{
  write-host "`$i = $i"
  $Button = new-object System.Windows.Forms.Button
  $Button.name = $i
  $Position = 25 + (55 * $i)
  $Button.location = new-object System.Drawing.Point(25,$Position)
  $Button.size = new-object System.Drawing.Size(525,50)
  $Button.text = $Tickets[$i]

  $Button.add_click({
    $Button = $this.text
    $form.close()
  }.GetNewClosure())

  $Form.Controls.Add($Button)
}

#Set form size, for contents
$Height = 125 + (50 * ($Tickets.count))
$Form.Size = New-Object System.Drawing.Size(600,$Height)
$Form.StartPosition = 'CenterScreen'
$Form.Topmost = $true

#display the form
$Form.ShowDialog()

#display the result
"Button $Button was pressed"

3 Answers 3

2

You’re running into two issues:

  • Scope: the variable $Button inside the click handler is not the same “result” variable you read after ShowDialog(). Event scriptblocks execute in their own scope unless you explicitly write to a parent scope.

  • Identification: relying on the outer $Button variable is fragile because you overwrite it each loop; use the event sender instead.

Use the event sender to know which button was clicked, store the selected value somewhere accessible (e.g., the form’s Tag), set DialogResult to OK, then close the form.

# Add assemblies
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing

# Define the form
$Form = New-Object System.Windows.Forms.Form
$Form.Text = 'Which ticket to work on?'

$Tickets = 'A','2','i'

# Create buttons dynamically
for ($i = 0; $i -lt $Tickets.Count; $i++) {
    $button = New-Object System.Windows.Forms.Button
    $button.Name = "btn_$i"                 # unique name if you want it
    $button.Text = $Tickets[$i]             # what the user sees
    $button.Tag  = $Tickets[$i]             # store the value you want to return
    $button.Size = New-Object System.Drawing.Size(525, 50)
    $button.Location = New-Object System.Drawing.Point(25, 25 + (55 * $i))

    # Use the sender to know which button was clicked
    $null = $button.Add_Click({
        param($sender, $eventArgs)
        $form.Tag = $sender.Tag                                # or $sender.Text / $sender.Name
        $form.DialogResult = [System.Windows.Forms.DialogResult]::OK
        $form.Close()
    }.GetNewClosure())

    $Form.Controls.Add($button)
}

# Size and show
$Form.Size = New-Object System.Drawing.Size(600, 125 + (55 * $Tickets.Count))
$Form.StartPosition = 'CenterScreen'
$Form.TopMost = $true

$result = $Form.ShowDialog()

# Read back the selection
if ($result -eq [System.Windows.Forms.DialogResult]::OK -and $Form.Tag) {
    "Button $($Form.Tag) was pressed"
} else {
    "No selection."
}
Sign up to request clarification or add additional context in comments.

2 Comments

Good point about the scope. Calls such as New-Object System.Drawing.Point(25, 25 + (55 * $i) fail for syntactic reason (passes 3 rather than 2 arguments). While not advisable, it's fine to update the variable in the caller's scope, even though the same variable is used to construct the buttons; initializing the result variable to $null before calling .ShowDialog() avoids fragility. While declaring parameters in the event handler is helpful in advanced use cases, in simple cases use of $this will do.
On a meta note: it's better not to discuss typos or use of wrong variable names in answers; instead, it's better to address them in a comment on the question and/or simply edit the question so as to eliminate such incidental issues.
2

Event callbacks are invoked in a child scope so the assignment to $Button is lost when the callback ends. Simple solution can be to use the $script: scope modifier, see about_Scopes for details.

for ($i = 0; $i -lt $Tickets.count; $i++) {
    Write-Host "`$i = $i"
    $Button = [System.Windows.Forms.Button]@{
        Name     = $i
        Location = [System.Drawing.Point]::new(25, 25 + (55 * $i))
        Size     = [System.Drawing.Size]::new(525, 50)
        Text     = $Tickets[$i]
    }

    $Button.Add_Click({
        $script:callbackResult = "Button Number '$($this.Name)' with Text '$($this.Text)'"
        $form.Close()
    })

    $Form.Controls.Add($Button)
}

...
...

$Form.ShowDialog()
"Button $callBackResult was pressed"

Or a reference type defined before your for loop, for example a hash table. In both cases the closure is not needed, either using a reference type or a $script: scoped variable, you do not need a closure mainly because of your usage of $this to refer to the Button control.

$callbackResult = @{}
for ($i = 0; $i -lt $Tickets.count; $i++) {
    Write-Host "`$i = $i"
    $Button = [System.Windows.Forms.Button]@{
        Name     = $i
        Location = [System.Drawing.Point]::new(25, 25 + (55 * $i))
        Size     = [System.Drawing.Size]::new(525, 50)
        Text     = $Tickets[$i]
    }

    $Button.Add_Click({
        $callbackResult['Value'] = "Button Number '$($this.Name)' with Text '$($this.Text)'"
        $form.Close()
    })

    $Form.Controls.Add($Button)
}

...
...

$Form.ShowDialog()
"Button $($callBackResult['Value']) was pressed"

Comments

2
  • Remove the .GetNewClosure() call on the event-handler script block you're passing to .add_Click() - not only is not necessary, it gets in the way of cross-scope variable updates (see next point).

  • Replace $Button = $this.Text with $script:Button = $this.Text:

    • Event handlers run in a child scope of the caller, so in order to create or update variables in the caller's scope, the latter must be targeted explicitly, typically with $script:variable = ...; without that, you'll implicitly create a block-local variable. See this answer for details.
  • Replace $Form.ShowDialog() with $null = $Form.ShowDialog() in order to silence the (unused by you) return value from the method call.

    • However, you should initialize the result variable, $Button, to $null before calling $Form.ShowDialog(), so you can infer whether a button was actually pressed or not.
      As an aside: consider using a different variable (as shown below), so as to distinguish the result variable from the helper variable you're using to construct the buttons.

To put it all together:

# Add assemblies
Add-Type -AssemblyName System.Windows.Forms, System.Drawing

#Define the form
$Form = new-object System.Windows.Forms.Form
$Form.text = 'Which ticket to work on?'

$Tickets = "A","2","i"

#Define each button
for($i = 0; $i -lt $Tickets.count; $i++)
{
  $Button = New-Object System.Windows.Forms.Button
  $Button.name = $i
  $Button.location = New-Object System.Drawing.Point 25, (25 + 55 * $i)
  $Button.size = New-Object System.Drawing.Size 525, 50
  $Button.text = $Tickets[$i]

  $Button.add_click({
    # Update the variable in the script (parent) scope.
    $script:ButtonClicked = $this.text
    $Form.close()
  }) # Note: NO .GetNewClosure() call.

  $Form.Controls.Add($Button)
}

#Set form size, for contents
$Form.Size = New-Object System.Drawing.Size 600, (125 + 50 * $Tickets.count)
$Form.StartPosition = 'CenterScreen'
$Form.Topmost = $true

# Initialize the result variable.
$ButtonClicked = $null 

#display the form
$null = $Form.ShowDialog()

#display the result
if ($null -eq $ButtonClicked) { "NO button was pressed." }
else                          { "Button $ButtonClicked was pressed" }

As an aside:

  • Calls such as New-Object System.Drawing.Point(25,$Position) use pseudo method syntax that is best avoided; use New-Object System.Drawing.Point 25, $Position instead (as shown above), which is short for
    New-Object -TypeName System.Drawing.Point -ArgumentList 25, $Position

    • PowerShell cmdlets, scripts and functions are invoked like shell commands, not like methods. That is, no parentheses around the argument list, and whitespace-separated arguments (, constructs an array as a single argument, as needed for -ArgumentList).
  • A notable pitfall with pseudo method syntax is that , is not an argument separator, as it is in true method calls; instead, it is , the array constructor operator, whose high precedence can have surprising results; e.g., the expression (25, 1 + 2) is equivalent to (25, 1) + 2, i.e. it creates a 2-element array and then appends 2, so that a pseudo-method-syntax call such as New-Object System.Drawing.Point(25, 1 + 2) would pass 3 arguments and therefore break the call. See this answer for more information.

  • A way to avoid this pitfall is to call constructors via the v5+ intrinsic static ::new() method on type literals, which does require (true) method syntax, e.g., [System.Drawing.Point]::new(25, 1 + 2)

    • Doing so is generally preferable to New-Object, because it also avoids the [psobject] wrappers that New-Object wraps newly constructed objects in, which are usually invisible and benign, but on occasion cause problems (e.g. see this answer). With repeated calls (such as in a loop), ::new() calls outperform New-Object.

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.