2

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
2

3 Answers 3

3

To avoid blocking the UI thread you will need to use a Runspace or ThreadJob Module (a Runspace based abstraction), this is the closest you can get to the async / await pattern which C# accomplishes very well; which is also why PowerShell isn't a recommended language for UI development... I agree with what you say, that for very simple UIs it can be useful; but it becomes increasingly harder as soon as you add more complexity, e.g.: the .Click callback in $UI.bModify that performs some "heavy work".

So, to fix the blocking problem, step by step:

Right after creating the $UI object we can set-up the Runspace, we will also need to add the .Dispatcher to the $UI object since we require it to update the UI from a different thread. Then we add the $UI and $ListItemCollection objects to the new runspace, so they're available / exist in that context:

$UI | Add-Member -MemberType NoteProperty -Name Dispatcher -Value $Window.Dispatcher
$ListItemCollection = [System.Collections.ObjectModel.ObservableCollection[ListItem]]::new()
$UI.lbStatus.ItemsSource = $ListItemCollection
$rs = [runspacefactory]::CreateRunspace()
$rs.Open()
$rs.SessionStateProxy.SetVariable('UI', $UI)
$rs.SessionStateProxy.SetVariable('ListItemCollection', $ListItemCollection)

Then we need to update the callback for the .Click event on the bModify button, the code is for the most part similar to what you have except that the logic happens on a PowerShell instance that is invoked asynchronously using our Runspace created in the previous step:

$UI.bModify.Add_Click({
    # while performing this task, disable the button so can only run once
    $UI.bModify.IsEnabled = $false
    # start a thread to perform this action so we don't block the UI
    $ps = [powershell]::Create().AddScript({
        # logic remains pretty much the same since
        # $ListItemCollection and $UI are passed-in to this runspace
        $currentIndex = 0
        $totalItems = $ListItemCollection.Count

        foreach ($item in $ListItemCollection) {
            # do heavy work only if the Status is not Done...
            if ($item.Status -ne 'DONE') {
                # Do some heavy work here...
                Start-Sleep 0.5
                $item.Status = "DONE"
            }

            $currentIndex++
            $progress = [math]::Round($currentIndex / $totalItems * 100)
            # the only difference here, we need to use the Dispatcher otherwise we would get
            # errors related to trying to update the UI from a different thread
            $UI.Dispatcher.Invoke([System.Action] {
                $UI.pbProgress.Value = $progress
                $UI.lbStatus.Items.Refresh()
            })
        }

        # after we are done here we can re-enable the button
        $UI.Dispatcher.Invoke([System.Action] { $UI.bModify.IsEnabled = $true })
    })

    $ps.Runspace = $rs
    $ps.BeginInvoke()
})

Last step, right after .ShowDialog() you should dispose the Runspace when done:

$Window.ShowDialog() | Out-Null
$rs.Dispose()

It's important to note here, with these changes your form will no longer be blocked while the callback runs and you will also see the UI changing while items are being processed however a step missing is to .Dispose() each newly created PowerShell instance after it finishes processing, for that you should await the IAsyncResult in the same callback and then dispose the PowerShell instance, however that would be blocking the thread and you're back to the same problem so one solution can be to subscribe to InvocationStateChanged event and set-up an auto-dispose mechanism as shown in this answer... As you may note your code will become quite more complicated thus again, this isn't an ideal language for UI development.

Sign up to request clarification or add additional context in comments.

Comments

2

To add to Santiago's helpful answer by spelling out an equivalent Start-ThreadJob-based solution, which illustrates the convenience this cmdlet affords compared to direct use of runspaces via the PowerShell SDK (as shown in Santiago's answer):

  • Note:

    • The Start-ThreadJob cmdlet offers a lightweight, much faster thread-based (runspace-based) alternative to the child-process-based parallelism offered by regular background jobs created with Start-Job.

    • It is preinstalled in PowerShell (Core) 7 and in Windows PowerShell can be installed on demand as part of the ThreadJob module, using, e.g., Install-Module ThreadJob -Scope CurrentUser.


Two modifications to your code are needed:

  • Replace the $UI.bModify.Add_Click({ ... }) call with the following, which uses Start-ThreadJob to create a parallel thread (runspace), which uses the $using: scope to reference the caller's variables (note that, for brevity, no attempt is made to synchronize object access between the calling and the parallel thread).[1]
$UI.bModify.Add_Click({

    # Remove a previous job, if any.
    if ($script:jb) { $script:jb | Remove-Job -Force }

    # Start a (new) thread job for background processing
    # and store it in a script-level variable.
    # Note the use of $using: to reference the caller's variables.
    $script:jb = Start-ThreadJob {

      $currentIndex = 0
      $totalItems = ($using:ListItemCollection).Count

      foreach ($item in $using:ListItemCollection) {
        # Do some heavy work here...
        Start-Sleep -Milliseconds 500
        $item.Status = "DONE"

        $currentIndex++
        $progress = [math]::Round(($currentIndex / $totalItems) * 100)
        
        # Update the status bar and the list box.  
        ($using:Window).Dispatcher.Invoke([System.Action] { 
            ($using:UI).pbProgress.Value = $progress
            ($using:UI).lbStatus.Items.Refresh()
          })
      }
    }

  })
  • Then replace $Window.ShowDialog() | Out-Null with the following, which initializes the script-level variable ($script:jb) that stores the (most recently launched) thread job, if any, and then cleans it up after closing the dialog:
$script:jb = $null
$null = $Window.ShowDialog()
if ($script:jb) { $script:jb | Remove-Job -Force }

See the bottom section for the full code with additional enhancements discussed in the next section.


As an aside, re:

# This already seems like a dirty hack...
$UI.lbStatus.Items.Refresh()

Even though you're using a System.Collections.ObjectModel.ObservableCollection<T> instance that is data-bound to your list box (.lbStatus), the latter is only notified of changes to the bound collection itself (adding, removing, or replacing elements), not also of changes to the properties of existing elements, which explains the need to manually refresh the list box.

In order for automatic refreshing in response to property changes to occur too, the collection elements would have to be instances of a type that implements the System.ComponentModel.INotifyPropertyChanged interface.

Making your PowerShell-implemented [ListItem] class implement this interface is not an option, because PowerShell classes lack support for property setters (as well as getters).[2]

However, given PowerShell's support for ad-hoc compiling C# code via the Add-Type cmdlet, a solution is possible, as shown in the context of the self-contained code in the next section.


The following is a streamlined reformulation of your self-contained code that shows both the Start-ThreadJob technique and a C#-based implementation of your ListItem class that implements the INotifyPropertyChanged interface and therefore obviates the need for manually refreshing the list box:

Add-Type -AssemblyName PresentationFramework
Add-Type -AssemblyName PresentationCore
Add-Type -AssemblyName WindowsBase

# Implement custom class [ListItem] as implementing the INotifyPropertyChanged interface.
# Since PowerShell classes don't support property setters, this must be done via C#
# Note: Doing so incurs a one-time compilation penalty per session.
Add-Type -ErrorAction Stop  @'
    using System.ComponentModel;
    using System.Runtime.CompilerServices;

    public class ListItem : INotifyPropertyChanged  
    {  
        private string _fileName;
        private string _fileType;
        private string _status;

        // Define the event that notifies observers of a property change.
        public event PropertyChangedEventHandler PropertyChanged;  

        // Property getters and setters that notify observers when a property is changed.
        public string FileName { get { return _fileName; } set { _fileName = value; NotifyPropertyChanged(); } }
        public string FileType { get { return _fileType; } set { _fileType = value; NotifyPropertyChanged(); } }
        public string Status   { get { return _status; } set { _status = value; NotifyPropertyChanged(); } }

        // A parameterless public constructor is enough to allow initialization by hashtable in PowerShell.
        public ListItem() {}

        // This method is called by the `set` accessor of each property.  
        // The CallerMemberName attribute that is applied to the optional propertyName  
        // parameter causes the property name of the caller to be substituted as an argument.
        // The parameter *must* be optional.
        private void NotifyPropertyChanged([CallerMemberName] string propertyName = null)  
        {  
          if (PropertyChanged != null) {
            PropertyChanged.Invoke(this, new PropertyChangedEventArgs(propertyName));
          }  
        }  

    }  
'@

[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 = [System.Xml.XmlNodeReader] $XAML
$Window = [Windows.Markup.XamlReader]::Load($Reader)

# Populate a custom UI object with XAML controls as properties for convenient access.
$UI = [psobject]::new()
$XAML.SelectNodes("//*[@Name]") | ForEach-Object {
  Add-Member -InputObject $UI -MemberType NoteProperty -Name $_.Name -Value $Window.FindName($_.Name)
}

# Create an observable collection...
$ListItemCollection = [System.Collections.ObjectModel.ObservableCollection[ListItem]]::new()
# ... and make it the list-box data source.
$UI.lbStatus.ItemsSource = $ListItemCollection

function Get-RandomListItem {
  $extensions = @("png", "jpeg", "txt", "docx", "pdf")

  $randomExtension = Get-Random -InputObject $extensions
  $randomFileType = $randomExtension.ToUpper()
  $randomFileName = (New-Guid).ToString().Substring(0, 8) + ".$randomExtension"

  return [ListItem] @{
    FileName = $randomFileName
    FileType = $randomFileType
    Status = "READY"
  }
}

$UI.bAdd.Add_Click({
    $ListItemCollection.Add((Get-RandomListItem))
  })

$UI.bRemove.Add_Click({
    if ($ListItemCollection.Count -gt 0) {
      $ListItemCollection.RemoveAt($ListItemCollection.Count - 1)
    }
  })

$UI.bModify.Add_Click({
  
    # Remove a previous job, if any.
    if ($script:jb) { $script:jb | Remove-Job -Force }
    # Start a (new) thread job for background processing.
    $script:jb = Start-ThreadJob {
      $currentIndex = 0
      $totalItems = ($using:ListItemCollection).Count
      foreach ($item in $using:ListItemCollection) {
        # Do some heavy work here...
        Start-Sleep -Milliseconds 500
        # Update the item status.
        # Note: Thanks to [ListItem] now implementing INotifyPropertyChanged,
        #       the corresponding list-box item is automatically updated.
        $item.Status = "DONE"

        $currentIndex++
        $progress = [math]::Round(($currentIndex / $totalItems) * 100)
          
        ($using:Window).Dispatcher.Invoke([System.Action] { 
            ($using:UI).pbProgress.Value = $progress
          })
      }
    }

  })

$UI.bClear.Add_Click({
    $ListItemCollection.Clear()
    $UI.pbProgress.Value = 0
  })

$UI.bClose.Add_Click({
    $Window.Close()
  })

$script:jb = $null
$null = $Window.ShowDialog()
if ($script:jb) { $script:jb | Remove-Job -Force }

[1] For proper synchronization of access to the observable collection between the UI thread and the background thread, the static BindingOperations.EnableCollectionSynchronization method must be called from the UI thread. In the simplest case: [System.Windows.Data.BindingOperations]::EnableCollectionSynchronization($ListItemCollection, $yourLockObject). The background thread must then temporarily lock $yourLockObject (which can be as simple as $yourLockObject = [object]::new()) while modifying the collection, using System.Threading.Monitor, namely, from the background thread, [System.Threading.Monitor]::Enter($using:yourLockObject) and [System.Threading.Monitor]::Exit($using:yourLockObject).
In the simple case at hand - modifying only properties of existing collection elements, only from the background thread - we can get away without this.

[2] GitHub issue #2219 suggests adding support for property getters and setters to future versions of PowerShell (Core) 7.

Comments

0

Just to not get rusty myself...

The other answers have you covered on the runspace approach since thats what you need. Just wanted to add a couple things that might help smooth out the rest.

To piggy back off mklement0's mention pf "proper synchronization of access to the observable collection between the UI thread and the background thread". by default, WPF's CollectionView has thread affinity and won't let you modify the collection from background threads at all. So, by using EnableCollectionSynchronization it enables cross-thread modification by telling WPF "heres the lock object, use it when you access the collection, and I'll use it too." Almost like a talking stick where only the person holding it can speak:)

$lock = [object]::new()
[System.Windows.Data.BindingOperations]::EnableCollectionSynchronization($ListItemCollection, $lock)

Then your code uses the same lock for any collection operations. This way both threads coordinate through the same lock object:

try 
{
    [System.Threading.Monitor]::Enter($lock)
    $ListItemCollection.Add($item)
} 
finally 
{
    [System.Threading.Monitor]::Exit($lock)
}

To share objects between threads, use a synchronized hashtable to create a shared state that both threads can safely read and write:

$state = [hashtable]::Synchronized(@{
    Items = $ListItemCollection
    Lock = $lock
    Updates = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
    Progress = 0
    Total = 0
    IsRunning = $false
    Job = $null
})

$runspace = [runspacefactory]::CreateRunspace()
$runspace.ApartmentState = "STA"
$runspace.Open()
$runspace.SessionStateProxy.SetVariable("state", $state)

The background thread can queue completed items in $state.Updates, update $state.Progress, and set $state.IsRunning flags. The UI timer (below) reads from this shared state to know what to update. The synchronized hashtable makes sure both threads can safely modify these shared variables without race conditions. This keeps the background thread non-blocking. It just updates counters and queues items, never waiting for the UI thread.

As for the current need for $Items.Refresh(), the problem is ListItem doesn't implement INotifyPropertyChanged and has its own limitations when doing so in PowerShell. Because in PowerShell classes, you can't write custom property setters. When you declare [string]$Status, PowerShell compiles it down to a .NET property with auto-generated get_Status() and set_Status() methods that just store/retrieve the value. The compiled setter IL is essentially just "load this, load value, store to backing field, return" - theres no hook point for custom logic.
The workaround is to use PSScriptProperty at the PSObject layer. Every PowerShell object has a PSObject wrapper that sits on top of the base .NET object. When you access a property from PowerShell code, it checks the PSObject layer first, then falls back to the compiled .NET property. By adding a PSScriptProperty with the same name, we intercept writes at the PSObject layer like so:

class ListItem : System.ComponentModel.INotifyPropertyChanged { 
    [System.ComponentModel.PropertyChangedEventHandler]$PropertyChanged 
    [string]$Status 
     
    ListItem() { 
        # Add PSScriptProperty wrapper that intercepts at the PSObject layer 
        $this.PSObject.Properties.Add([PSScriptProperty]::new( 
            'Status', 
            { $this.get_Status() },  # Calls the compiled getter 
            { param($v) $this.set_Status($v); $this.OnPropertyChanged('Status') }  # Calls compiled setter + fires event 
        )) 
    } 
    #... rest of code 
} 

When you write $item.Status = "DONE" from PowerShell, it hits our PSScriptProperty wrapper, which calls the real compiled set_Status() method and then fires PropertyChanged. WPF sees the event and updates the UI.

For an alternative approach to the Dispatcher.Invoke method - Santiago's solution with Dispatcher.Invoke inside the loop works well, btw. The background thread does the heavy work, then pauses briefly while the UI thread processes each update. If you're processing a lot of items though, those pauses add up. A queue + timer pattern keeps things runing a bit smoother if not using BeginInvoke() or the newer InvokeAsync():

#Background thread - just queues completed items
foreach ($item in $state.Items) 
{
    Start-Sleep -Milliseconds 500
    $queue.Enqueue($item)  # Non-blocking, keep moving
}

#UI timer - processes the queue on its own schedule
$timer.Add_Tick({
    $item = $null
    while ($queue.TryDequeue([ref]$item)) 
    { 
        $item.Status = "DONE" # PropertyChanged
    }
})

The background thread runs at full speed without waiting. The UI timer polls every 50ms and applies any queued updates. This decoupling makes the UI feel more responsive, especially when dragging the window around during processing.

Finally, just tie it all together with:

Add-Type -AssemblyName PresentationFramework

class ListItem : System.ComponentModel.INotifyPropertyChanged {
    [System.ComponentModel.PropertyChangedEventHandler]$PropertyChanged
    
    [string]$FileName
    [string]$FileType
    [string]$Status
    
    ListItem() { $this.InitializeNotify() }
    ListItem([hashtable]$Properties) {
        foreach ($key in $Properties.Keys) 
        {
            $this.$key = $Properties[$key]
        }
        $this.InitializeNotify()
    }
    
    hidden [void] InitializeNotify() {
        $type = $this.GetType()
        $props = @('FileName', 'FileType', 'Status')
        
        foreach ($name in $props) 
        {
            $getter = $type.GetMethod("get_$name")
            $setter = $type.GetMethod("set_$name")
            
            $this.PSObject.Properties.Add([PSScriptProperty]::new(
                $name,
                { $getter.Invoke($this, @()) }.GetNewClosure(),
                { param($v) $setter.Invoke($this, @($v)); $this.OnPropertyChanged($name) }.GetNewClosure()
            ))
        }
    }
    
    [void] add_PropertyChanged([System.ComponentModel.PropertyChangedEventHandler]$h) {
        $this.PropertyChanged = [Delegate]::Combine($this.PropertyChanged, $h)
    }
    [void] remove_PropertyChanged([System.ComponentModel.PropertyChangedEventHandler]$h) {
        $this.PropertyChanged = [Delegate]::Remove($this.PropertyChanged, $h)
    }
    [void] OnPropertyChanged([string]$name) {
        if ($this.PropertyChanged) 
        {
            $this.PropertyChanged.Invoke($this, [System.ComponentModel.PropertyChangedEventArgs]::new($name))
        }
    }
}

[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>
        <ListBox Name="lbStatus" Grid.Row="0" Margin="5" Focusable="False">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <StackPanel Orientation="Horizontal">
                        <TextBlock Text="{Binding Status}" FontSize="16" Width="64" Margin="5,0"/>
                        <TextBlock Text="{Binding FileType}" FontSize="16" Width="64" Margin="5,0"/>
                        <TextBlock Text="{Binding FileName}" FontSize="16" Margin="5,0"/>
                    </StackPanel>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>
        <ProgressBar Name="pbProgress" Grid.Row="1" Height="20" Margin="5" 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"/>
        </StackPanel>
    </Grid>
</Window>
"@

$window = [Windows.Markup.XamlReader]::Load([System.Xml.XmlNodeReader]::new($xaml))

$ui = @{}
$xaml.SelectNodes("//*[@Name]") | ForEach-Object { $ui[$_.Name] = $window.FindName($_.Name) }

$items = [System.Collections.ObjectModel.ObservableCollection[ListItem]]::new()
$lock = [object]::new()
[System.Windows.Data.BindingOperations]::EnableCollectionSynchronization($items, $lock)
$ui.lbStatus.ItemsSource = $items

$state = [hashtable]::Synchronized(@{
    Items = $items
    Lock = $lock
    Updates = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
    Progress = 0
    Total = 0
    IsRunning = $false
    Job = $null
})

function Invoke-Locked {
    param([scriptblock]$Action)
    [System.Threading.Monitor]::Enter($state.Lock)
    try { & $Action } finally { [System.Threading.Monitor]::Exit($state.Lock) }
}

$timer = [System.Windows.Threading.DispatcherTimer]::new()
$timer.Interval = [TimeSpan]::FromMilliseconds(50)
$timer.Add_Tick({
    $item = $null
    while ($state.Updates.TryDequeue([ref]$item)) 
    { 
        $item.Status = "DONE"
    }
    
    if ($state.Total -gt 0) 
    {
        $ui.pbProgress.Value = ($state.Progress / $state.Total) * 100
    }
    
    if (-not $state.IsRunning) 
    {
        $timer.Stop()
        $ui.bModify.IsEnabled = $true
    }
})

$ui.bAdd.Add_Click({
    Invoke-Locked {
        $ext = Get-Random -InputObject @("png", "jpeg", "txt", "docx", "pdf")
        $guid = (New-Guid).Guid.Substring(0, 8)
        $items.Add([ListItem]::new(@{
            FileName = "$guid.$ext"
            FileType = $ext.ToUpper()
            Status = "READY"
        }))
    }
})

$ui.bRemove.Add_Click({
    Invoke-Locked {
        $selected = $ui.lbStatus.SelectedItem
        if ($selected) 
        { 
            [void]$items.Remove($selected) 
        } 
        elseif ($items.Count -gt 0) 
        { 
            $items.RemoveAt($items.Count - 1) 
        }
    }
})

$ui.bModify.Add_Click({
    if ($state.Job) 
    {
        $state.Job.PowerShell.Dispose()
        $state.Job.Runspace.Close()
        $state.Job.Runspace.Dispose()
    }
    
    $ui.bModify.IsEnabled = $false
    $state.IsRunning = $true
    $state.Progress = 0
    $state.Total = $items.Count
    $ui.pbProgress.Value = 0
    
    $timer.Start()
    
    $runspace = [runspacefactory]::CreateRunspace()
    $runspace.ApartmentState = "STA"
    $runspace.Open()
    $runspace.SessionStateProxy.SetVariable("state", $state)
    
    $ps = [powershell]::Create()
    $ps.Runspace = $runspace
    
    $state.Job = @{ PowerShell = $ps; Runspace = $runspace }
    
    [void]$ps.AddScript({
        foreach ($item in $state.Items) 
        {
            Start-Sleep -Milliseconds 500
            [void]$state.Updates.Enqueue($item)
            $state.Progress++
        }
        $state.IsRunning = $false
    }).BeginInvoke()
})

$ui.bClear.Add_Click({
    Invoke-Locked {
        $items.Clear()
        $ui.pbProgress.Value = 0
    }
})

$ui.bClose.Add_Click({ $window.Close() })

$window.Add_Closing({
    $timer.Stop()
    if ($state.Job) 
    {
        $state.Job.PowerShell.Dispose()
        $state.Job.Runspace.Close()
        $state.Job.Runspace.Dispose()
    }
})

$window.ShowDialog() | Out-Null

The UI should stay fully responsive during processing. For production applications though, I'd still recommend mklement0's C# approach. this PowerShell solution works well for utility scripts but you're definitely pushing the boundaries of what PowerShell classes were designed for. Although, I think it's a shame as it seems like it was just rushed and not implemented properly. I digress, tho:)

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.