winformspowershellrunspace

Adding elements to a form from another runspace


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


Solution

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

    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: