I would like to create a GUI that shows a loading bar while a job is running in the background. For simplicity, I've made the job an infinite loop so it should always be running. I've only included necessary parts of the code:
$Label = new-object system.windows.forms.Label
$Label.Font = 'Ariel,12pt'
$Label.Text = ""
$Label.AutoSize = $True
$Label.Location = new-object system.drawing.size(50,10)
$Form.Controls.Add($Label)
$LoadingAnimation = #(".....","0....",".0...","..0..","...0.","....0",".....")
$AnimationCount = 0
$test = start-job -Name Job -ScriptBlock { for($t=1;$t -gt 0; $t++){} }
while ($test.JobStateInfo.State -eq "Running")
{
$Label.Text = $LoadingAnimation[($AnimationCount)]
$AnimationCount++
if ($AnimationCount -eq $LoadingAnimation.Count){$AnimationCount = 0}
Start-Sleep -Milliseconds 200
}
Upon testing this code in the console, just using Write-Host instead of $Label.Text, it works just fine. What needs to be done differently to get this to work in a windows form created by PowerShell?
In PowerShell, you can create all sorts of status, including multi-level, using Write-Progress. Don't forget to call with -Completed when done (a common mistake I see).
Get-Help Write-Progress
After going through the little details of the script, I found the problem. This is how I activated my form:
$Form.Add_Shown({$Form.Activate()})
[void] $Form.ShowDialog()
This caused the script to stop when the form was launched, ShowDialog stops the script to allow interaction. The fix was:
$Form.Add_Shown({$Form.Activate()})
[void] $Form.Show()
Using Form.Show lets the script to continue to run because it doesn't require interaction.
Related
I start building up a small winform with Copy button and a label under this button.
When I click on Copy button it starts to copy files from source to destination.
I would like to run this asynchroniously so I don't want form to be freezed while copy operation runs. That's why I use Job. After a successful copy I need feedback of copy and show an "OK" text with green color but it is not working.
Here is my code:
[void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")
[System.Windows.Forms.Application]::EnableVisualStyles()
Function Copy-Action{
$Computername = "testclient"
$Source_Path = "C:\temp\"
$Destination_Path = "\\$Computername\c$\temp"
$job = Start-Job -Name "Copy" -ArgumentList $Source_Path,$Destination_Path –ScriptBlock {
param($Source_Path,$Destination_Path)
Copy-Item $Source_Path -Destination $Destination_Path -Recurse -Force
}
Register-ObjectEvent $job StateChanged -MessageData $Status_Label -Action {
[Console]::Beep(1000,500)
$Status_Label.Text = "OK"
$Status_Label.ForeColor = "#009900"
$eventSubscriber | Unregister-Event
$eventSubscriber.Action | Remove-Job
} | Out-Null
}
# DRAW FORM
$form_MainForm = New-Object System.Windows.Forms.Form
$form_MainForm.Text = "Test Copy"
$form_MainForm.Size = New-Object System.Drawing.Size(200,200)
$form_MainForm.FormBorderStyle = "FixedDialog"
$form_MainForm.StartPosition = "CenterScreen"
$form_MainForm.MaximizeBox = $false
$form_MainForm.MinimizeBox = $true
$form_MainForm.ControlBox = $true
# Copy Button
$Copy_Button = New-Object System.Windows.Forms.Button
$Copy_Button.Location = "50,50"
$Copy_Button.Size = "75,30"
$Copy_Button.Text = "Copy"
$Copy_Button.Add_Click({Copy-Action})
$form_MainForm.Controls.Add($Copy_Button)
# Status Label
$Status_Label = New-Object System.Windows.Forms.Label
$Status_Label.Text = ""
$Status_Label.AutoSize = $true
$Status_Label.Location = "75,110"
$Status_Label.ForeColor = "black"
$form_MainForm.Controls.Add($Status_Label)
#show form
$form_MainForm.Add_Shown({$form_MainForm.Activate()})
[void] $form_MainForm.ShowDialog()
Copy is successful but showing an "OK" label won't. I have placed a Beep but it doesn't work too.
What am I doing wrong ? Any solution to this?
Thank you.
Let me offer alternatives to balrundel's helpful solution - which is effective, but complex.
The core problem is that while a form is being shown modally, with .ShowDialog(), WinForms is in control of the foreground thread, not PowerShell.
That is, PowerShell code - in the form's event handlers - only executes in response to user actions, which is why your job-state-change event handler passed to Register-ObjectEvent's -Action parameter does not fire (it would eventually fire, after closing the form).
There are two fundamental solutions:
Stick with .ShowDialog() and perform operations in parallel, in a different PowerShell runspace (thread).
balrundel's solution uses the PowerShell SDK to achieve this, whose use is far from trivial, unfortunately.
See below for a simpler alternative based on Start-ThreadJob
Show the form non-modally, via the .Show() method, and enter a loop in which you can perform other operations while periodically calling [System.Windows.Forms.Application]::DoEvents() in order to keep the form responsive.
See this answer for an example of this technique.
A hybrid approach is to stick with .ShowDialog() and enter a [System.Windows.Forms.Application]::DoEvents() loop inside the form event handler.
This is best limited to a single event handler applying this technique, as using additional simultaneous [System.Windows.Forms.Application]::DoEvents() loops invites trouble.
See this answer for an example of this technique.
Simpler, Start-ThreadJob-based solution:
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) 7+ and can be installed on demand in Windows PowerShell 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.
In addition to syntactic convenience, Start-ThreadJob, due to being thread-based (rather than using a child process, which is what Start-Job does), allows manipulating the calling thread's live objects.
Note that the sample code below, in the interest of brevity, performs no explicit thread synchronization, which may situationally be required.
The following simplified, self-contained sample code demonstrates the technique:
The sample shows a simple form with a button that starts a thread job, and updates the form from inside that thread job after the operation (simulated by a 3-second sleep) completes, as shown in the following screen shots:
Initial state:
After pressing Start Job (the form remains responsive):
After the job has ended:
The .add_Click() event handler contains the meat of the solution; the source-code comments hopefully provide enough documentation.
# PSv5+
using namespace System.Windows.Forms
using namespace System.Drawing
Add-Type -AssemblyName System.Windows.Forms
# Create a sample form.
$form = [Form] #{
Text = 'Form with Thread Job'
ClientSize = [Point]::new(200, 80)
FormBorderStyle = 'FixedToolWindow'
}
# Create the controls and add them to the form.
$form.Controls.AddRange(#(
($btnStartJob = [Button] #{
Text = "Start Job"
Location = [Point]::new(10, 10)
})
[Label] #{
Text = "Status:"
AutoSize = $true
Location = [Point]::new(10, 40)
Font = [Font]::new('Microsoft Sans Serif', 10)
}
($lblStatus = [Label] #{
Text = "(Not started)"
AutoSize = $true
Location = [Point]::new(80, 40)
Font = [Font]::new('Microsoft Sans Serif', 10)
})
))
# The script-level helper variable that maintains a collection of
# thread-job objects created in event-handler script blocks,
# which must be cleaned up after the form closes.
$script:jobs = #()
# Add an event handler to the button that starts
# the background job.
$btnStartJob.add_Click( {
$this.Enabled = $false # To prevent re-entry while the job is still running.
# Signal the status.
$lblStatus.Text = 'Running...'
$form.Refresh() # Update the UI.
# Start the thread job, and add the job-info object to
# the *script-level* $jobs collection.
# The sample job simply sleeps for 3 seconds to simulate a long-running operation.
# Note:
# * The $using: scope is required to access objects in the caller's thread.
# * In this simple case you don't need to maintain a *collection* of jobs -
# you could simply discard the previous job, if any, and start a new one,
# so that only one job object is ever maintained.
$script:jobs += Start-ThreadJob {
# Perform the long-running operation.
Start-Sleep -Seconds 3
# Update the status label and re-enable the button.
($using:lblStatus).Text = 'Done'
($using:btnStartJob).Enabled = $true
}
})
$form.ShowDialog()
# Clean up the collection of jobs.
$script:jobs | Remove-Job -Force
Start-Job creates a separate process, and when your form is ready to receive events, it can't listen to job events. You need to create a new runspace, which is able to synchronize thread and form control.
I adapted code from this answer. You can read much better explanation there.
[void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")
[System.Windows.Forms.Application]::EnableVisualStyles()
Function Copy-Action{
$SyncHash = [hashtable]::Synchronized(#{TextBox = $Status_Label})
$Runspace = [runspacefactory]::CreateRunspace()
$Runspace.ThreadOptions = "UseNewThread"
$Runspace.Open()
$Runspace.SessionStateProxy.SetVariable("SyncHash", $SyncHash)
$Worker = [PowerShell]::Create().AddScript({
$SyncHash.TextBox.Text = "Copying..."
# Copy-Item
$Computername = "testclient"
$Source_Path = "C:\temp\"
$Destination_Path = "\\$Computername\c$\temp"
Copy-Item $Source_Path -Destination $Destination_Path -Recurse -Force
$SyncHash.TextBox.ForeColor = "#009900"
$SyncHash.TextBox.Text = "OK"
})
$Worker.Runspace = $Runspace
$Worker.BeginInvoke()
}
# DRAW FORM
$form_MainForm = New-Object System.Windows.Forms.Form
$form_MainForm.Text = "Test Copy"
$form_MainForm.Size = New-Object System.Drawing.Size(200,200)
$form_MainForm.FormBorderStyle = "FixedDialog"
$form_MainForm.StartPosition = "CenterScreen"
$form_MainForm.MaximizeBox = $false
$form_MainForm.MinimizeBox = $true
$form_MainForm.ControlBox = $true
# Copy Button
$Copy_Button = New-Object System.Windows.Forms.Button
$Copy_Button.Location = "50,50"
$Copy_Button.Size = "75,30"
$Copy_Button.Text = "Copy"
$Copy_Button.Add_Click({Copy-Action})
$form_MainForm.Controls.Add($Copy_Button)
# Status Label
$Status_Label = New-Object System.Windows.Forms.Label
$Status_Label.Text = ""
$Status_Label.AutoSize = $true
$Status_Label.Location = "75,110"
$Status_Label.ForeColor = "black"
$form_MainForm.Controls.Add($Status_Label)
#show form
$form_MainForm.Add_Shown({$form_MainForm.Activate()})
[void] $form_MainForm.ShowDialog()
EDIT : I was able to get it working, see below for my solution. The commenters below are correct that Powershell isn't really ideal for GUI's and threading, but it can be done.
I've got a form in Powershell that uses Start-Job to run functions in the background without freezing the GUI. My goal is to continuously check the status of those jobs for their output. I managed to use the Windows Forms Timer to check the results of the job and update the GUI accordingly.
It's all working fine, but it seems sloppy. Is this the best way to accomplish a GUI refresh? I'm relatively new to Powershell and I want to improve my coding.
Example of what I'm doing:
$jobScript = {
Start-Sleep 5
Write-Output "The job has finished running"
}
$timerScript = {
$timer.Stop()
$jobResult = Get-Job | Receive-Job -Keep
if ($jobResult) {
$btn.text = $jobResult
} else {
$timer.Start()
}
}
Add-Type -AssemblyName System.Windows.Forms
$form = New-Object System.Windows.Forms.Form
$form.ClientSize = '300,300'
$form.topmost = $true
$btn = New-Object System.Windows.Forms.Button
$btn.Text = "The job is still running"
$btn.Width = 300
$btn.Height = 300
$form.Controls.Add($btn)
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 100
$timer.add_Tick($timerScript)
$timer.Start()
Start-Job -ScriptBlock $jobScript
$form.ShowDialog()
Update: My solution
Using Register-ObjectEvent did not work, it seemed like it was fighting with the GUI for the thread. Instead I was able to use [System.Windows.Forms.Application]::DoEvents(). This allows the GUI to be moved around, and once it's done being moved, the thread will resume. The big caveat here is that execution is paused as long as the GUI is being moved, so if your code needs to react to the background job on a time limit, this could cause errors.
Example code block:
$jobScript =
{
Start-Sleep 5
Write-Output "The job is completed"
}
Add-Type -AssemblyName System.Windows.Forms
$form = New-Object System.Windows.Forms.Form
$form.ClientSize = '300,300'
$form.topmost = $true
$btn = New-Object System.Windows.Forms.Button
$btn.Text = "..."
$btn.Width = 300
$btn.Height = 300
$form.Controls.Add($btn)
$btn.add_click({
$btn.Text = "Starting job"
$jobby = Start-Job -ScriptBlock $jobScript
Do {[System.Windows.Forms.Application]::DoEvents()} Until ($jobby.State -eq "Completed")
$btn.Text = Get-Job | Receive-Job
})
$form.ShowDialog()
You can use events:
$job = Start-Job {sleep 3; Write-Output "Dummy job completed"}
$callback = {
Write-Host "Event Fired"
Unregister-Event -SourceIdentifier "DummyJob"
Write-Host ($job | Receive-Job)
}
$evt = Register-ObjectEvent -InputObject $job -EventName StateChanged -SourceIdentifier "DummyJob" -Action $callback
# to remove all
Get-Job | Remove-Job -Force # jobs and events
Get-EventSubscriber | Unregister-Event # events
You might want to post this on CodeReview.StackExchange.com.
I sort of hate it when people build UI's in Powershell. If you want a proper Windows forms app, just write it in C#. So I disagree with the design at its premise.
I like your impulse to move away from the polling design; you initiate the job then poll to see if it's completed. I think an event handler might be a better choice. Check out the section "Monitor a Background Job" in the article PowerShell and Events: Object Events. It's an oldie but a goodie.
I'm using a Datagridview inside a windows form to display some data.
The data is loaded in the background after pressing a button.
This works fine when I press the button for the first time.
But I need to be able, to do this again and again without closing the form.
No matter what I try, after pressing ok a second time, my datagridview turns into white ground with big red x across it. I would like to empty/reset everything.
Here is a cut down version of my code:
Add-Type -AssemblyName System.Windows.Forms
$sync = [Hashtable]::Synchronized(#{})
$backgroundTask = {
$task1 = [PowerShell]::Create().AddScript({
#here I would like to clear all data from a previous execution. But nothing worked.
$softwareQuery = Get-WMIObject -ComputerName localhost -Class Win32_Product | Select Name
$softwareList = New-Object System.collections.ArrayList
$softwareList.AddRange($softwareQuery)
$sync.softwareTable.DataSource = $softwareList
})
$runspace = [RunspaceFactory]::CreateRunspace()
$runspace.ApartmentState = "STA"
$runspace.ThreadOptions = "ReuseThread"
$runspace.Open()
$runspace.SessionStateProxy.SetVariable("sync", $sync)
$task1.Runspace = $runspace
$task1.BeginInvoke()
}
$mainForm = New-Object system.Windows.Forms.Form
$mainForm.Size = New-Object System.Drawing.Size(900,600)
$okButton = New-Object System.Windows.Forms.Button
$okButton.Location = New-Object System.Drawing.Point(280,5)
$okButton.Size = New-Object System.Drawing.Size(75,20)
$okButton.Text = "OK"
$okButton.Add_Click($backgroundTask) ;
$mainForm.Controls.Add($okButton)
$softwareTable = New-Object System.Windows.Forms.DataGridView -Property #{
Location=New-Object System.Drawing.Point(5,30)
Size=New-Object System.Drawing.Size(840,360)
ColumnHeadersVisible = $true
}
$mainForm.Controls.Add($softwareTable)
$sync.softwareTable = $softwareTable
$mainForm.ShowDialog()
I see two issues, the first that you need to clear the datasource, and the second is that you're trying to call the action too soon after clicking the button and opening the runspace. Put a simple start-sleep in there and that issue should be resolved. I'd recommend playing with the time that you sleep it for to get the fastest execution. The second is also an easy fix, just clear the datasource before you declare what task1 is. This worked for me.
$backgroundTask = {
$sync.softwareTable.DataSource = $null
$task1 = [PowerShell]::Create().AddScript({
#here I would like to clear all data from a previous execution. But nothing worked.
$softwareQuery = Get-WMIObject -ComputerName virinfpshd001w -Class Win32_Product | Select Name
$softwareList = New-Object System.collections.ArrayList
$softwareList.AddRange($softwareQuery)
$sync.softwareTable.DataSource = $softwareList
})
$runspace = [RunspaceFactory]::CreateRunspace()
$runspace.ApartmentState = "STA"
$runspace.ThreadOptions = "ReuseThread"
$runspace.Open()
$runspace.SessionStateProxy.SetVariable("sync", $sync)
$task1.Runspace = $runspace
$handle = $task1.BeginInvoke()
Start-Sleep -seconds 5
}
I think one of the issues might be that you are trying to update a form control from a different thread. I am not an expert but in my experience that could go wrong in some ways.
To clear the datagridview, have you tried to set the Datasource property of the datagridview to $null before assigning it a new value? That is how I usually get mine done.
OK so I am newer to using windows forms inside Powershell, and I am having a bit of trouble with the form being all laggy since there is a continuous ping running. Fundamentally all I need to do is display the IP address each time it runs a ping. I tried to search around and it seems like the proper way to do this is with a running job in the background?
I think the way this is currently written that the ping happens in the background but I'm not sure how to update the form (or if its even possible to make it visible to it?)
Here is a sample, any guidance with this would be greatly appreciated.
function global:ContinuousPing{
$global:job = start-job {
while($true){
$pingStatus = Test-Connection google.com -Count 1
$label.text = $pingStatus.IPV4Address.IPAddressToString
#[System.Windows.Forms.Application]::DoEvents()
start-sleep 1
}
}
}
Add-Type -AssemblyName System.Windows.Forms
$pingForm = New-Object System.Windows.Forms.Form
$label = New-Object System.Windows.Forms.Label
$button1 = New-Object System.Windows.Forms.Button
$ping = {
ContinuousPing
while($true){
Receive-Job $job
}
}
$label.Text = "Ping Status"
$button1.Location = New-Object System.Drawing.Point(100,10)
$button1.add_Click($ping)
$pingForm.Controls.Add($label)
$pingForm.Controls.Add($button1)
$pingForm.ShowDialog() | Out-Null
remove-job $job
You need to separate the GUI updating and the ping task. This is easy to in languages that are designed to have GUIs like C# winforms, If you tried it you would be surprised how much easier it is than trying to bend a GUI over PowerShell.
You can try the link Mathias posted, It will do what you require.
I want to see a little notification icon to indicate that the script I wrote is still active (both the script and displying the icon works). But I need a button within the context menu of the icon to stop the script immediately. And that's the part where my problem is:
$objNotifyIcon = New-Object System.Windows.Forms.NotifyIcon
$objContextMenu = New-Object System.Windows.Forms.ContextMenu
$ExitMenuItem = New-Object System.Windows.Forms.MenuItem
$ExitMenuItem.add_Click({
echo stoped
$continue = $False
$objNotifyIcon.visible = $False
})
$objContextMenu.MenuItems.Add($ExitMenuItem) | Out-Null
$objNotifyIcon.ContextMenu = $objContextMenu
$objNotifyIcon.Visible = $True
The script itself is longer, this is just the relevant part. If I run it from PowerShell ISE it works just fine. When I run it from a .bat file with
powershell .\myscript.ps1
the context menu is not working anymore.
This is just a wild guess, but try running the script in Single Thread Apartment mode:
powershell -STA -File .\myscript.ps1