I have a form in which as soon as ready several elements will be added (for example, a list). It may take some time to add them (from fractions of a second to several minutes). Therefore, I want to add processing to a separate thread (child). The number of elements is not known in advance (for example, how many files are in the folder), so they are created in the child stream. When the processing in the child stream ends, I want to display these elements on the main form (before that the form did not have these elements and performed other tasks).
However, I am faced with the fact that I cannot add these elements to the main form from the child stream. I will give a simple example as an example. It certainly works:
$Main = New-Object System.Windows.Forms.Form
$Run = {
# The form is busy while adding elements (buttons here)
$Top = 0
1..5 | % {
$Button = New-Object System.Windows.Forms.Button
$Button.Top = $Top
$Main.Controls.Add($Button)
$Top += 30
Sleep 1
}
}
$Main.Add_Shown($Run)
# Adding and performing other tasks on the form here
[void]$Main.ShowDialog()
But, adding the same thing to the child stream I did not get the button to display on the main form. I do not understand why.
$Main = New-Object System.Windows.Forms.Form
$Run = {
$RS = [Runspacefactory]::CreateRunspace()
$RS.Open()
$RS.SessionStateProxy.SetVariable('Main', $Main)
$PS = [PowerShell]::Create().AddScript({
# Many items will be added here. Their number and processing time are unknown in advance
# Now an example with the addition of five buttons.
$Top = 0
1..5 | % {
$Button = New-Object System.Windows.Forms.Button
$Button.Top = $Top
$Main.Controls.Add($Button)
$Top += 30
Sleep 1
}
})
$PS.Runspace = $RS; $Null = $PS.BeginInvoke()
}
$Main.Add_Shown($Run)
[void]$Main.ShowDialog()
How can I add elements to the main form that are created in the child stream? thanks
While you can create controls on thread B, you cannot add them to a control that was created in thread A from thread B.
If you attempt that, you'll get the following exception:
Controls created on one thread cannot be parented to a control on a different thread.
Parenting to means calling the .Add()
or .AddRange()
method on a control (form) to add other controls as child controls.
In other words: In order to add controls to your $Main
form, which is created and later displayed in the original thread (PowerShell runspace), the $Main.Controls.Add()
call must occur in that same thread.
Similarly, you should always attach event delegates (event-handler script blocks) in that same thread too.
While your own answer attempts to ensure adding the buttons to the form in the original runspace, it doesn't work as written - see the bottom section.
I suggest a simpler approach:
Use a thread job to create the controls in the background, via Start-ThreadJob
.
Start-ThreadJob
is part of the the ThreadJob
module that offers a lightweight, thread-based alternative to the child-process-based regular background jobs and is also a more convenient alternative to creating runspaces via the PowerShell SDK.
It comes with PowerShell [Core] v6+ and in Windows PowerShell can be installed on demand with, e.g., Install-Module ThreadJob -Scope CurrentUser
.
In most cases, thread jobs are the better choice, both for performance and type fidelity - see the bottom section of this answer for why.
Show your form non-modally (.Show()
rather than .ShowDialog()
) and process GUI events in a [System.Windows.Forms.Application]::DoEvents()
loop.
[System.Windows.Forms.Application]::DoEvents()
can be problematic in general (it is essentially what the blocking .ShowDialog()
call does behind the scenes), but in this constrained scenario (assuming only one form is to be shown) it should be fine. See this answer for background information.In the loop, check for newly created buttons as output by the thread job, attach an event handler, and add them to your form.
Here is a working example that adds 3 buttons to the form after making it visible, one after the other while sleeping in between:
Add-Type -ea Stop -Assembly System.Windows.Forms
$Main = New-Object System.Windows.Forms.Form
# Start a thread job that will create the buttons.
$job = Start-ThreadJob {
$top = 0
1..3 | % {
# Create and output a button object.
($btn = [System.Windows.Forms.Button] @{
Name = "Button$_"
Text = "Button$_"
Top = $top
})
Start-Sleep 1
$top += $btn.Height
}
}
# Show the form asynchronously
$Main.Show()
# Process GUI events in a loop, and add
# buttons to the form as they're being created
# by the thread job.
while ($Main.Visible) {
[System.Windows.Forms.Application]::DoEvents()
if ($button = Receive-Job -Job $job) {
# Add an event handler...
$button.add_Click({ Write-Host "Button clicked: $($this.Name)" })
# .. and it to the form.
$Main.Controls.AddRange($button)
}
# Sleep a little, to avoid a near-tight loop.
# Note: [Threading.Thread]::Sleep() is used in lieu of Start-Sleep,
# to avoid the problem reported in https://github.com/PowerShell/PowerShell/issues/19796
[Threading.Thread]::Sleep(50)
}
# Clean up.
$Main.Dispose()
Remove-Job -Job $job -Force
'Done'
As of this writing, your own answer tries to achieve adding the controls to the form in the original runspace by using Register-ObjectEvent
to subscribe to the other thread's (runspace's) events, given that the -Action
script block used for event handling runs (in a dynamic module inside) the original thread (runspace), but there are two problems with that:
Unlike your answer suggests, the -Action
script block neither directly sees the $Main
variable from the original runspace, nor the other runspace's variables - these problems can be overcome, however, by passing $Main
to Register-ObjectEvent
via -MessageData
and accessing it via $Event.MessageData
in the script block, and by accessing the other runspace's variables via $Sender.Runspace.SessionStateProxy.GetVariable()
calls.
More importantly, however, the .ShowDialog()
call will block further processing; that is, your events won't fire and therefore your -Action
script block won't be invoked until after the form closes.
Update: You mention a workaround in order to get PowerShell's events to fire while the form is being displayed:
Subscribe to the MouseMove
event with a dummy event handler whose invocation gives PowerShell a chance to fire its own events while the form is being displayed modally; e.g.: $Main.Add_MouseMove({ Out-Host })
; note that this workaround is only effective if the script block
calls a command, such as Out-Host
in this example (which is effectively a no-op); a mere expression or .NET method call is not enough.
However, this workaround is suboptimal in that it relies on the user (continually) mousing over the form for the PowerShell events to fire; also, it is somewhat obscure and inefficient.