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.
Note: [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)
}
}
# 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.
I think you can't create form and controls on different threads. But you can access control properties though. So you can create form with control placeholders in a runspace, then change them on the main thread once your calculations are complete. Example:
$form = New-Object System.Windows.Forms.Form
$rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
$rs.ApartmentState = [System.Threading.ApartmentState]::MTA
$ps = [powershell]::create()
$ps.Runspace = $rs
$rs.Open()
$out = $ps.AddScript({param($form)
$button1 = New-Object System.Windows.Forms.Button
$button1.Name = "button1"
$form.Controls.Add($button1)
$form.ShowDialog()
}).AddArgument($form).BeginInvoke()
#-----------------------------
sleep 1;
$form.Controls["button1"].Text = "some button"
This is the way I'm using now. Thanks to #mklement0 for the talk about the Register-ObjectEvent method (here). I applied it here. The essence of the method is that the elements are created in the child stream (in this case, the Button), and when the child space has finished work, Register-ObjectEvent is processed. Register-ObjectEvent is located in the main space and therefore allows you to add an element (Button here) to the form.
$Main = New-Object System.Windows.Forms.Form
$Run = {
$RS = [Runspacefactory]::CreateRunspace()
$RS.Open()
$RS.SessionStateProxy.SetVariable('Main', $Main)
$PS = [PowerShell]::Create().AddScript({
$Button = New-Object System.Windows.Forms.Button
}
})
$PS.Runspace = $RS
$Null = Register-ObjectEvent -InputObject $PS -EventName InvocationStateChanged -Action {
if ($EventArgs.InvocationStateInfo.State -in 'Completed', 'Failed') {
$Main.Controls.Add($Button)
}
}
$Null = $PS.BeginInvoke()
}
$Main.Add_Shown($Run)
[void]$Main.ShowDialog()
This is my a workaround. However, I still do not know if it is possible in principle to add elements of a child space to a form from a child space. This, of course, is about adding, not managing, because managing from the child space is successful.
Putting this here, since it is too long for the regular comment section.
Now, I don't spend much time using runspaces in production, as I've no had a real need (at least to date) for them, in class, sure, but I digress.
However, from all my previous readings and notes I've kept, this sounds like a use case for RunSpace Pools. Here are three of my saved resources. Working under the assumption that you may have not seen all of them of course. Now, I would post their code as well, but all are very long, so, there's that. Based on your use case, it could be seen as a duplicate to the last link resource.
PowerShell and WPF: Writing Data to a UI From a Different
Runspace
PowerShell Tip: Utilizing Runspaces for Responsive WPF GUI
Applications
Sharing Variables and Live Objects Between PowerShell Runspaces
How to access a different powershell runspace without WPF-object
Related
I'm experimenting with creating GUIs and using classes in powershell. I'm really new to both of those things (and to a lesser extent powershell generally) so bear with me.
The problem I am having is I cannot make any control which makes any modification to the form. This is because when adding a handler to a button it goes into the scope of the button class in the handler and none of the form references are accessible.
Most examples of UI code in powershell are not class heavy. I realize that I could get around this, if it was not in a class, by having the handlers and form being in the global scope, but I'm trying to make use of classes so that I have the ability to make base forms and inherit from them. And I want to see what is possible.
Below is some test code including multiple of my attempts to make this work with the results commented. I even got the idea of passing the form reference into the handler (DI style). Things I'm trying are all over the map since I'm also feeling out basic powershell syntax.
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
class Window : System.Windows.Forms.Form
{
Handler () {
Write-Host Handler
$this.BackColor = [System.Drawing.Color]::Blue
}
HandlerArgs ([object]$sender, [System.Eventargs]$eventArgs) {
Write-Host HandlerArgs
$this.BackColor = [System.Drawing.Color]::Blue
}
$HandlerVar = {
Write-Host HandlerVar
$this.BackColor = [System.Drawing.Color]::Blue
}
HandlerParam ($form) {
Write-Host HandlerParam
$form.BackColor = [System.Drawing.Color]::Blue
}
$HandlerVarParam = {
(params $form)
Write-Host HandlerVarParam
$form.BackColor = [System.Drawing.Color]::Blue
}
Window ()
{
$button = New-Object System.Windows.Forms.Button
$button.Text = "ClickMe"
$button.AutoSize = $true
$this.Controls.Add($button)
# $button.Add_Click( $this.Handler )
# "Cannot convert argument "value", with value: "void Handler()", for "add_Click"
# to type "System.EventHandler": "Cannot convert the "void SelectNextPage()"
# value of type "System.Management.Automation.PSMethod" to type "System.EventHandler"."
# $button.Add_Click(([System.EventHandler]$x = $this.Handler ))
# turns the window blue immediatly
# $button.Add_Click( $this.HandlerArgs )
# "Cannot convert the "void HandlerArgs(System.Object sender, System.EventArgs eventArgs)"
# value of type "System.Management.Automation.PSMethod" to type "System.EventHandler".""
# $button.Add_Click( $this.HandlerVar )
# this works but turns the button blue instead of the form
# $button.Add_Click( { $this.Handler } )
# does nothing?
# $button.Add_Click( { $this.Handler() } )
# Method invocation failed because [System.Windows.Forms.Button] does not contain a
# method named 'Handler'.
# $button.Add_Click( $this.HandlerParam($this) )
# turns the window blue immediatly
# $button.Add_Click( { $this.HandlerParam($this) } )
# Method invocation failed because [System.Windows.Forms.Button] does not contain a
# method named 'HandlerParam'.
# $button.Add_Click( $this.HandlerVarParam $this )
# parse error
# I can't find a syntax that lets me pass a param to a function in a variable
}
}
$foo = New-Object Window
$foo.ShowDialog()
Although it's likely super obvious already, c# is my main language.
Perhaps this is just a limitation of the OO support in an interpreted scripting language, or maybe it's just my syntax deficiency. Is there any pattern that will get me what I want in this class-based structure? I would hope the pattern would be a general solution for doing normal form-things with handlers of form-controls.
Although mklement0 is absolutely spot on - class method can't be used directly as event delegates - there is a way to bind the instance to it without storing the handler in a property.
The following approach fails because $this resolves to the event owner at runtime:
$button.add_Click( { $this.Handler() } )
# Method invocation failed because [System.Windows.Forms.Button] does not contain a
# method named 'Handler'.
You can bypass this late-binding behavior by using any other (non-automatic) local variable to reference $this and then closing over it before calling add_Click():
$thisForm = $this
$button.add_Click( { $thisForm.Handler() }.GetNewClosure() )
# or
$button.add_Click( { $thisForm.HandlerArgs($this,$EventArgs) }.GetNewClosure() )
Now, $thisForm will resolve to whatever $this referenced when the handler was added, and the button will work as expected.
PowerShell, as of PowerShell 7.1, only knows how to pass script blocks as event delegates, not custom-class methods:
Mathias R. Jessen's helpful answer shows how to work around this limitation:
By wrapping a call to the event-handler method in a script block...
... and additionally providing a closure that captures a variable that refers to the class instance usually accessible as $this under a different name, to work around $this inside the script block referring to the event-originating object instead (same as the first handler argument, $sender), so as to ensure that access to the class instance remains possible.
As an aside: in this particular case, a solution without a .GetNewClosure() call would have been possible too, by calling $this.FindForm().
The following defines an idiom for generalizing this approach by encapsulating via a single helper method, which may be of interest if you have multiple event handlers in your class.:
As in your original approach and in Mathias' solution, individual event handlers are defined as you would normally define them in C#, as instance methods.
An auxiliary GetHandler() method encapsulates the logic of wrapping a call to a given event-handler method in a script block so that it is accepted by .add_{Event}() calls.
Any .add_{EventName}() call must then be passed the event-handler method via this auxiliary $this.GetHandler() method, as shown below.
Add-Type -AssemblyName System.Windows.Forms
class Window : System.Windows.Forms.Form {
# Define the event handler as an instance method, as you would in C#.
hidden ClickHandler([object] $sender, [EventArgs] $eventArgs) {
# Diagnostically print info about the sender and the event arguments.
$sender.GetType().FullName | Write-Verbose -vb
$eventArgs | Format-List | Out-String | Write-Verbose -vb
$this.BackColor = [System.Drawing.Color]::Blue
}
# Define a generic helper method that takes an event-handling instance method
# and turns it into a script block, so that it is accepted by
# .add_{Event}() calls.
hidden [scriptblock] GetHandler([Management.Automation.PSMethod] $method) {
# Wrap the event-handling method in a script block, because only a
# script block can be passed to .add_{Event}() calls.
# The .GetNewClosure() call is necessary to ensure that the $method variable
# is available when the script block is called by the event.
# Calling via a *method* also ensures that the method body still sees $this
# as the enclosing class instance, whereas the script block itself sees
# $this as the event-originating object (same as $sender).
return {
param([object] $sender, [EventArgs] $eventArgs)
$method.Invoke($sender, $eventArgs)
}.GetNewClosure()
}
# Constructor.
Window() {
$button = New-Object System.Windows.Forms.Button
$button.Text = "ClickMe"
$button.AutoSize = $true
$this.Controls.Add($button)
# Pass the event-handler method via the GetHandler() helper method.
$button.add_Click( $this.GetHandler($this.ClickHandler) )
}
}
$foo = New-Object Window
$foo.ShowDialog()
Note:
Add-Type -AssemblyName System.Windows.Forms must actually be executed before the script loads in order for you custom class definition deriving from System.Windows.Forms.Form to work, which is a known problem:
Only if the assembly containing the type that a custom PS class derives from has already been loaded at script parse time does the class definition succeed - see GitHub issue #3641.
The same applies to the using assembly statement - see about_Using, (which in PowerShell [Core] as of 7.0 has the added problem of not recognizing well-known assemblies such as System.Windows.Forms - see GitHub issue #11856)
In general, support for custom classes in PowerShell is, unfortunately, very much a work in progress, and many existing issues are tracked in GitHub meta issue #6652.
Is it possible to add data to a listview after a certain variable contains data ?
I have a function, which gets info from ConfigMgr when I press on a button.
After I press on that button, some info will be stored in a variable called $Results.
I want the winform listview to wait until the variable $Results contains data, and then load my function, that will add data to my listview - is that possible ?
It's working fine if I don't clear the variable $Results, and run the winform a second time, because then the variable $Results is not empty,
by having$Form.Add_Shown( { $Form.Activate(); Results }) in my script
Is there an equivalent method to achieve what I want ?
This is my function:
Function Get-SKUNotExists {
#Get objects stored in $Results
$listview_NotExists_SKU_Results = $Results | Select-Object Name,ID
# Compile a list of the properties
$listview_NotExists_SKU_Properties = $listview_NotExists_SKU_Results[0].psObject.Properties
# Create a column in the listView for each property
$listview_NotExists_SKU_Properties | ForEach-Object {
$listview_NotExists_SKU.Columns.Add("$($_.Name)")
}
# Looping through each object in the array, and add a row for each
ForEach ($listview_NotExists_SKU_Result in $listview_NotExists_SKU_Results) {
# Create a listViewItem, and assign it it's first value
$listview_NotExists_SKU_Item = New-Object System.Windows.Forms.ListViewItem($listview_NotExists_SKU_Result.Name)
# For each properties, except for 'Id' that we've already used to create the ListViewItem,
# find the column name, and extract the data for that property on the current object/Tasksequence
$listview_NotExists_SKU_Result.psObject.Properties | Where-Object { $_.Name -ne "ID" } | ForEach-Object {
$listview_NotExists_SKU_Item.SubItems.Add("$($listview_NotExists_SKU_Result.$($_.Name))")
}
# Add the created listViewItem to the ListView control
# (not adding 'Out-Null' at the end of the line will result in numbers outputred to the console)
$listview_NotExists_SKU.items.Add($listview_NotExists_SKU_Item)
}
# Resize all columns of the listView to fit their contents
$listview_NotExists_SKU.AutoResizeColumns("HeaderSize")
}
This is the button that generate data to $results:
# Adding another button control to Form
$button_whatif = New-Object System.Windows.Forms.Button
$button_whatif.Location = New-Object System.Drawing.Size(352, 954)
$button_whatif.Size = New-Object System.Drawing.Size(320, 32)
$button_whatif.TextAlign = "MiddleCenter"
$button_whatif.Text = “WhatIf”
$button_whatif.Add_Click( { $script:Results = Set-DynamicVariables -Manufacturer "$($listview_Vendor.SelectedItems)" -TSPackageID "$($ListView_Tasksequences.SelectedItems.SubItems[1].Text)" -WhatIf })
$Form.Controls.Add($button_whatif)
Simply call your list-populating function (Get-SKUNotExists) directly after assigning to $Results:
$button_whatif.Add_Click({
$Results = Set-DynamicVariables -Manufacturer "$($listview_Vendor.SelectedItems)" -TSPackageID "$($ListView_Tasksequences.SelectedItems.SubItems[1].Text)" -WhatIf
Get-SKUNotExists
})
Note that I've removed the $scipt: scope specifier from $Results, since it is no longer needed, given that you're calling Get-SKUNotExists from the same scope, which means that the child scope that Get-SKUNotExists runs in implicitly sees it.
That said:
In general, if a value is directly available, it's more robust to pass it as a parameter (argument) rather than relying on PowerShell's dynamic scoping (where descendant scopes implicitly see variables from ancestral scopes, unless shadowed by local variables).
If values must be shared between multiple event handlers, storing them in the script scope ($script:...), or, more generally, the parent scope may be needed, after all - see this answer.
I am trying to open up a manpage (Get-Help alias) when I click on a button using Powershell and WinForms.
I have a text box that allows you to input a cmdlet or help topic and when you press a button, it should open up the manpage documentation in GridView. Currently, it opens the GridView and grabs the correct help docs but something messes up along the way and I think it has to do with interpretation before it is passed off to GridView.
Here is what I have:
[void][Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')
$form = New-Object Windows.Forms.Form
$input = New-Object Windows.Forms.TextBox
$input.Size = '100,20'
$input.Location = '10,20'
$button = New-Object Windows.Forms.Button
$button.Size = '100,20'
$button.Location = '10,60'
$button.Add_Click({
Invoke-Expression ("man " + ($global:input.Text)) | Out-GridView
})
$form.Controls.AddRange(#($input, $button)
$form.Add_Shown({$form.Activate()})
$form.ShowDialog()
What happens is the GridView opens but the title shows Invoke-Expression ("man " + ($input.Text)) | Out-GridView and the contents are the generic default information for manpages.
I tried to attach the Invoke-Expression to a variable and then pipe the variable out to GridView. I tried to set (Get-Help ($input.Text)) to a variable and then pipe it to GridView. I even tried to initialize the $input.Text property by putting $input.Text = '' just after the $input.Location property.
I really think its how the Powershell engine is interpreting the expression but I don't know how to tell it to work the way I am wanting it to.
What am I doing wrong here?
EDIT: Ok, I just realized something. I think the $input.Text property is not getting populated correctly.
What I did was added [Windows.Forms.MessageBox]::Show($input.Text) in the Click event for $button and commented out the Invoke-Expression. What it should do is open a message box and place within it what is typed in the text box ($input.Text). The message box is blank. I'm thinking that it may have to do with scoping but the $input.Text should be $global and accessible from within the Click event on the button control item.
I messed around with it after typing that last paragraph and I realized that the $input.Text property is populated correctly and is accessible in the $global scope. What I did was add [Windows.Forms.MessageBox]::Show($input.Text) at the very end of the script (after $form.ShowDialog()) and it showed exactly what I typed in the text box.
So, why is it that I can't see the $input text box properties? I haven't had this issue with some of the other WinForms apps I have built in Powershell.
Thanks for the insight.
$input is an automatic variable used in the context of the Powershell pipeline, which is why it was empty. Powershell populates it by itself, therefore overwriting anything you put in it. Rename $input to any other available name and it should work.
E.g.:
[void][Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')
$form = New-Object Windows.Forms.Form
$box = New-Object Windows.Forms.TextBox
$box.Size = '100,20'
$box.Location = '10,20'
$button = New-Object Windows.Forms.Button
$button.Size = '100,20'
$button.Location = '10,60'
$button.Add_Click({
[Windows.Forms.MessageBox]::Show($box.Text)
Invoke-Expression ("man " + ($box.Text)) | Out-GridView
})
$form.Controls.AddRange(#($box, $button))
$form.Add_Shown({$form.Activate()})
$form.ShowDialog()
I would like to launch a non-blocking UI from a parent Powershell script and receive UI messages like button clicks from the child job. I have this kind of messaging working using WinForms, but I prefer to use ShowUI because of how much less code it takes to create a basic UI. Unfortunately, though, I haven't found a way to send messages back to the parent job using ShowUI.
[Works] Forwarding Events When Using Start-Job
Using Start-Job, forwarding events from a child to a parent job is rather straightforward. Here is an example:
$pj = Start-Job -Name "PlainJob" -ScriptBlock {
Register-EngineEvent -SourceIdentifier PlainJobEvent -Forward
New-Event -SourceIdentifier PlainJobEvent -MessageData 'My Message'
}
Wait-Event | select SourceIdentifier, MessageData | Format-List
As expected, it prints out:
SourceIdentifier : PlainJobEvent
MessageData : My Message
[Does Not Work] Forwarding Events When Using Start-WPFJob
Using Start-WPFJob, on the other hand, does not seem to forward events from the child to the parent. Consider this example:
Import-Module ShowUI
$wj = Start-WPFJob -ScriptBlock {
Register-EngineEvent -SourceIdentifier MySource -Forward
New-Button "Button" -On_Click {
New-Event -SourceIdentifier MySource -MessageData 'MyMessage'
}
}
Wait-Event | select SourceIdentifier, MessageData | Format-List
Running this example produces this window:
Clicking on the button, however, does not yield an event in the parent job.
Why doesn't the Start-WPFJob example yield events to the parent job?
Is there some other way to use ShowUI to produce a button in a non-blocking manner and receive events from it?
I can't get engineevents to forward properly so far (actually, I can't even get them to do anything, as far as I can tell), I think your best bet is to run the WPFJob, and instead of New-Event, update the $Window UIValue, and then from your main runspace, instead of Wait-Event, use Update-WPFJob in a loop.
I would stick this function into the module (actually, I will add it for the 1.5 release that's in source control but not released yet):
function Add-UIValue {
param(
[Parameter(ValueFromPipeline=$true)]
[Windows.FrameworkElement]
$Ui,
[PSObject]
$Value
)
process {
if ($psBoundParameters.ContainsKey('Value')) {
Set-UIValue $UI (#(Get-UIValue $UI -IgnoreChildControls) + #($Value))
} else {
Set-UIValue -Ui $ui
}
}
}
And then, something like this:
$job = Start-WPFJob {
Window {
Grid -Rows "1*", "Auto" {
New-ListBox -Row 0 -Name LB -Items (Get-ChildItem ~ -dir)
Button "Send" -Row 1 -On_Click { Add-UIValue $Window $LB.SelectedItem }
}
} -SizeToContent "Width" -MinHeight 800
}
Every time you click, would add the selected item to the UI output (if you run that window without the job and click the button a couple of times, then close the window, you'll get two outputs).
Then you can do something like this in the host instead of Wait-Event:
do {
Update-WPFJob -Job $job -Command { Get-UIValue $Window -IgnoreChildControls } -OutVariable Output
Start-Sleep -Mil 300
} while (!$Output)
If I run the following code, the Event Action is executed:
$Job = Start-Job {'abc'}
Register-ObjectEvent -InputObject $Job -EventName StateChanged `
-Action {
Start-Sleep -Seconds 1
Write-Host '*Event-Action*'
}
The string 'Event-Action' is displayed.
If I use a Form and start the above code by clicking a button,
the Event Action is not executed:
[System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")
$Form1 = New-Object Windows.Forms.Form
$Form1.Add_Shown({
$Form1.Activate()
})
$Button1 = New-Object System.Windows.Forms.Button
$Button1.Text = 'Test'
$Form1.Controls.Add($Button1)
$Button1.Add_Click({
Write-Host 'Test-Button was clicked'
$Job = Start-Job {'abc'}
Register-ObjectEvent -InputObject $Job -EventName StateChanged `
-Action {
Start-Sleep -Seconds 1
Write-Host '*Event-Action*'
}
})
$Form1.ShowDialog()
Only when I click the button again, the first Event Action is executed.
With the third click the second Event Action is executed and so on.
If I do multiple clicks in rapid succession, the result is unpredictable.
Furthermore when I close the form with the button in the upper right corner,
the last "open" Event Action is executed.
Note: For testing PowerShell ISE is to be preferred, because PS Console displays
the string only under certain circumstances.
Can someone please give me a clue what's going on here?
Thanks in advance!
nimizen.
Thanks for your explanation, but I don't really understand, why the StateChanged event is not fired or visible to the main script until there is some action with the Form. I'd appreciate another attempt to explain it to me.
What I want to accomplish is a kind of multithreading with PowerShell and Forms.
My plan is the following:
'
The script shows a Form to the user.
The user does some input and clicks a button.
Based on the user's input a set of Jobs are started with Start-Job and a StateChanged event is registered for each job.
While the Jobs are running, the user can perform any action on the Form (including stop the Jobs via a button) and the Form is repainted when necessary.
The script reacts to any events which are fired by the Form or its child controls.
Also the script reacts to each job's StateChanged event.
When a StateChanged event occurs, the state of each job is inspected, and if all jobs have the state 'Completed', the jobs' results are fetched with Receive-Job and displayed to the user.
'
All this works fine except that the StateChanged event is not visible to the main script.
The above is still my favorite solution and if you have any idea how to implement this, please let me know.
Otherwise I'll most likely resort to a workaround, which at least gives the user a multithreading feeling. It is illustrated in the following example:
[System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")
$Form1 = New-Object Windows.Forms.Form
$Form1.Add_Shown({
$Form1.Activate()
})
$Button1 = New-Object System.Windows.Forms.Button
$Button1.Text = 'Test'
$Form1.Controls.Add($Button1)
$Button1.Add_Click({
$Form1.Focus()
Write-Host 'Test-Button was clicked'
$Job = Start-Job {Start-Sleep -Seconds 1; 'abc'}
Do {
Start-Sleep -Milliseconds 100
Write-Host 'JobState: ' $Job.State
[System.Windows.Forms.Application]::DoEvents()
}
Until ($Job.State -eq 'Completed')
Write-Host '*Action*'
})
$Form1.ShowDialog()
There are a lot of (StackOverflow) questions and answers about this ‘enduring mystique’ of combining form (or WPF) events with .NET events (like EngineEvents, ObjectEvents and WmiEvents) in PowerShell:
Do Jobs really work in background in powershell?
WPF events not working in Powershell - Carousel like feature in multi-threaded script
is it possible to control WMI events though runspace and the main form?
Is there a way to send events to the parent job when using Start-WPFJob?
Update WPF DataGrid ItemSource from Another Thread in PowerShell
They are all come down two one point: even there are multiple threads setup, there are two different 'listeners' in one thread. When your script is ready to receive form events (using ShowDialog or DoEvents) it can’t listen to .NET events at the same time. And visa versa: if script is open for .NET events while processing commands (like Start-Sleep or specifically listen for .NET events using commands like Wait-Event or Wait-Job), your form will not be able to listen to form events. Meaning that either the .NET events or the form events are being queued simply because your form is in the same thread as the .NET listener(s) your trying to create.
As with the nimizen example, with looks to be correct at the first glans, your form will be irresponsive to all other form events (button clicks) at the moment you’re checking the backgroundworker’s state and you have to click the button over and over again to find out whether it is still ‘*Doing Stuff’. To work around this, you might consider to combine the DoEvents method in a loop while you continuously checking the backgroundworker’s state but that doesn’t look to be a good way either, see: Use of Application.DoEvents()
So the only way out (I see) is to have one thread to trigger the form in the other thread which I think can only be done with using [runspacefactory]::CreateRunspace() as it is able to synchronize a form control between the treats and with that directly trigger a form event (as e.g. TextChanged).
(if there in another way, I eager to learn how and see a working example.)
Form example:
Function Start-Worker {
$SyncHash = [hashtable]::Synchronized(#{TextBox = $TextBox})
$Runspace = [runspacefactory]::CreateRunspace()
$Runspace.ThreadOptions = "UseNewThread" # Also Consider: ReuseThread
$Runspace.Open()
$Runspace.SessionStateProxy.SetVariable("SyncHash", $SyncHash)
$Worker = [PowerShell]::Create().AddScript({
$ThreadID = [appdomain]::GetCurrentThreadId()
$SyncHash.TextBox.Text = "Thread $ThreadID has started"
for($Progress = 0; $Progress -le 100; $Progress += 10) {
$SyncHash.TextBox.Text = "Thread $ThreadID at $Progress%"
Start-Sleep 1 # Some background work
}
$SyncHash.TextBox.Text = "Thread $ThreadID has finnished"
})
$Worker.Runspace = $Runspace
$Worker.BeginInvoke()
}
[Void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")
$Form = New-Object Windows.Forms.Form
$TextBox = New-Object Windows.Forms.TextBox
$TextBox.Visible = $False
$TextBox.Add_TextChanged({Write-Host $TextBox.Text})
$Form.Controls.Add($TextBox)
$Button = New-Object System.Windows.Forms.Button
$Button.Text = "Start worker"
$Button.Add_Click({Start-Worker})
$Form.Controls.Add($Button)
$Form.ShowDialog()
For a WPF example, see: Write PowerShell Output (as it happens) to WPF UI Control
The state property of Powershell jobs is read-only; this means that you can't configure the job state to be anything before you actually start the job. When you're monitoring for the statechanged event, it doesn't fire until the click event comes around again and the state is 'seen' to change from 'running' to 'completed' at which point your script block executes. This is also the reason why the scriptblock executes when closing the form.
The following script removes the need to monitor the event and instead monitors the state. I assume you want to fire the on 'statechanged' code when the state is 'running'.
[System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")
$Form1 = New-Object Windows.Forms.Form
$Form1.Add_Shown({
$Form1.Activate()
})
$Button1 = New-Object System.Windows.Forms.Button
$Button1.Text = 'Test'
$Form1.Controls.Add($Button1)
$Button1.Add_Click({
$this.Enabled = $false
Write-Host $Job.State " - (Before job started)"
$Job = Start-Job {'abc'}
Write-Host $Job.State " - (After job started)"
If ($Job.State -eq 'Running') {
Start-Sleep -Seconds 1
Write-Host '*Doing Stuff*'
}
Write-Host $Job.State " - (After IF scriptblock finished)"
[System.Windows.Forms.Application]::DoEvents()
$this.Enabled = $true
})
$Form1.ShowDialog()
In addition, note the lines:
$this.Enabled = $false
[System.Windows.Forms.Application]::DoEvents()
$this.Enabled = $true
These lines ensure the button doesn't queue click events. You can obviously remove the 'write-host' lines, I've left those in so you can see how the state changes as the script executes.
Hope this helps.