I'm currently building a GUI application using PowerShell and WPF, and overall, I'm quite pleased with how easy it is to spin up a user interface for small tools without having to dive into full-blown C# development.
However, I'm running into issues when trying to interact with the UI during background processing. In my sample application, I have a ListItem class used as the ItemTemplate, and the items are stored in an ObservableCollection[ListItem]. The goal is to process these list items in a loop and report progress back to the UI without blocking it.
Basic operations like adding, removing, or clearing items from the collection work fine synchronously. The problem arises when I try to update the collection from a background thread. I've experimented with Start-Job and System.ComponentModel.BackgroundWorker, but neither approach successfully interacts with the UI elements.
Has anyone managed to get background processing working with PowerShell and WPF in a way that allows safe updates to the UI? Any tips or patterns would be greatly appreciated!
Here's a simplified version of what I'm trying to achieve:
MWE:
Add-Type -AssemblyName PresentationFramework
Add-Type -AssemblyName PresentationCore
Add-Type -AssemblyName WindowsBase
class ListItem {
[string]$FileName
[string]$FileType
[string]$Status
ListItem() { }
ListItem([hashtable]$Properties) { $this.Init($Properties) }
ListItem([string]$fileName, [string]$fileType, [string]$status) {
$this.Init(@{
FileName = $fileName; FileType = $fileType; Status = $status
})
}
[void] Init([hashtable]$Properties) {
foreach ($Property in $Properties.Keys) {
$this.$Property = $Properties.$Property
}
}
}
[xml]$XAML = @"
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" Title="Start-SampleApplication"
Height="320" Width="720" MinHeight="320" MinWidth="720" ResizeMode="CanResize">
<Grid Margin="5">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<ListBox Name="lbStatus" Grid.Row="0" Grid.Column="0" Margin="5" Focusable="False">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Status}" FontSize="16" Width="64" Margin="5,0,5,0"/>
<TextBlock Text="{Binding FileType}" FontSize="16" Width="64" Margin="5,0,5,0"/>
<TextBlock Text="{Binding FileName}" FontSize="16" Margin="5,0,5,0"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<ProgressBar Name="pbProgress" Grid.Row="1" Grid.Column="0" Height="20" Margin="5" Value="0" Maximum="100"/>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="5">
<Button Name="bAdd" Content="Add" Width="120" Margin="0,0,5,0"/>
<Button Name="bRemove" Content="Remove" Width="120" Margin="0,0,5,0"/>
<Button Name="bModify" Content="Modify" Width="120" Margin="0,0,5,0"/>
<Button Name="bClear" Content="Clear" Width="120" Margin="0,0,5,0"/>
<Button Name="bClose" Content="Close" Width="120" Margin="0"/>
</StackPanel>
</Grid>
</Window>
"@
$Reader = (New-Object System.Xml.XmlNodeReader $XAML)
$Window = [Windows.Markup.XamlReader]::Load($Reader)
$UI = New-Object PSObject
# Populate UI object with XAML nodes
$XAML.SelectNodes("//*[@Name]") | ForEach-Object {
Add-Member -InputObject $UI -MemberType NoteProperty -Name $_.Name -Value $Window.FindName($_.Name)
}
$ListItemCollection = [System.Collections.ObjectModel.ObservableCollection[ListItem]]::new()
$UI.lbStatus.ItemsSource = $ListItemCollection
function Get-RandomData {
$extensions = @("png", "jpeg", "txt", "docx", "pdf")
$randomExtension = Get-Random -InputObject $extensions
$randomFileType = $randomExtension.ToUpper()
$randomFileName = (New-Guid).ToString().Substring(0, 8) + ".$randomExtension"
return [PSCustomObject]@{
FileName = $randomFileName
FileType = $randomFileType
}
}
$UI.bAdd.Add_Click({
$randomData = Get-RandomData
$ListItemCollection.Add([ListItem]::new(@{
FileName = $randomData.FileName; FileType = $randomData.FileType; Status = "READY";
}))
})
$UI.bRemove.Add_Click({
if ($ListItemCollection.Count -gt 0) {
$ListItemCollection.RemoveAt($ListItemCollection.Count - 1)
}
})
$UI.bModify.Add_Click({
$currentIndex = 0
$totalItems = $ListItemCollection.Count
foreach ($item in $ListItemCollection) {
# Do some heavy work here...
Start-Sleep 0.5
$item.Status = "DONE"
$currentIndex++
$progress = [math]::Round(($currentIndex / $totalItems) * 100)
$UI.pbProgress.Value = $progress
# This already seems like a dirty hack...
$UI.lbStatus.Items.Refresh()
}
})
$UI.bClear.Add_Click({
$ListItemCollection.Clear()
$UI.pbProgress.Value = 0
})
$UI.bClose.Add_Click({
$Window.Close()
})
$Window.ShowDialog() | Out-Null
Start-ThreadJob(Runspace based). See examples: stackoverflow.com/questions/72724246/…, stackoverflow.com/questions/79626493/…, stackoverflow.com/questions/77446632/…