- To write a GUI in Windows Powershell and Pwsh 7.5+
- No custom written C# classes through
Add-Type. - Limited to resources that come natively with Windows 11.
An Asynchronous PowerShell UI! Supported by a ViewModel and Command Bindings. Bonus updated theming with Pwsh 7.5+ that came with .NET 9.
Revisited and simplified for Pwsh. Previous version is in the archive for those that followed it and for some obscure findings.
. '.\Demo.ps1'Import-Module .\PsModelUI
$ViewModel = New-ViewModel -ClassName 'ViewModel' -Methods @(
New-ViewModelMethod -Name 'MethodName' -Body {
$Random = Get-Random -Min 1 -Max 3000
Start-Sleep -Milliseconds $Random
$this.BoundViewProperty = $Random
} -Throttle 3
)
# Remove ThemeMode="Dark" for Windows Powershell.
$Xaml = @'
<Window
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
Title="PsModelUI" ThemeMode="Dark" WindowStartupLocation="CenterScreen" Width="640" Height="480">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="{Binding BoundViewProperty}" />
<Button Content="Async" Command="{Binding MethodNameCommand}" />
</StackPanel>
</Window>
'@
$Window = New-WpfObject -Xaml $Xaml -DataContext $ViewModel
$Window.ShowDialog()
Say hello to automatic view updates. Xaml is able to bind to class properties thanks to classes inheriting INotifyPropertyChanged.
Most bindings are straight forward, just set the path to the ScriptProperty name added by Add-Member
<TextBlock Text="{Binding TextAboveButton, Mode=OneTime}" />
<Button Content="{Binding ButtonText, Mode=OneTime}"
Command="{Binding Command}" />
<!-- The command can call $this.UpdatableContent = 'Content' to update the below control. -->
<TextBlock Text="{Binding UpdatableContent}" />When you want text to also update another control, the text must be bound to a ScriptProperty and NOT have the same backing field name. Set the other control to bind to ElementName of the first control.
<!-- Who needs converters? -->
<TextBox Name="BinderForSlider"
Text="{Binding ViewModel.NumberProperty, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Slider Minimum="0"
Maximum="255"
Value="{Binding ElementName=BinderForSlider, Path=Text}" />Building a class has never been more verbose! Ever wanted to build your own class through functions? No? Now you can!
Build parts of a class until you're ready to piece it together.
$ClassMethod = New-ViewModelMethod -Name 'ClassMethod' -Body {return 'Hello World'}
$ClassProperty = New-ClassProperty -Name 'ClassProperty' -Type ([string]) -Init {'I have default a value'}
$DynamicClass = New-ViewModel -ClassName 'DynamicClass' -PropertyInit @(
$ClassProperty
) -Methods @(
$ClassMethod
)
$DynamicClass
ClassProperty ClassMethodCommand
------------- ------------------
I have default a value @{Workers=System.Management.Automation.PSScriptProperty}
$DynamicClass.psobject.ClassMethod()
Hello World
# Class definition as a string
$DynamicClassDefinition = New-ViewModel -ClassName 'DynamicClass' -PropertyInit @(
$ClassProperty
) -Methods @(
$ClassMethod
) -AsString
$DynamicClassDefinition
class DynamicClass : ViewModelBase {
[System.String]$_ClassProperty
DynamicClass(){
$this.ClassProperty = [scriptblock]::Create(
@'
,('I have default a value')
'@
).InvokeReturnAsIs()
}
$ClassMethodCommand
[object]ClassMethod() {
return 'Hello World'
}
}
For quick prototyping.
New-ViewModel detects class properties used in class methods but not defined by New-ClassProperty and automatically includes it in the class as property of type object. Set -AutomaticProperties $true to turn this feature on.
$DynamicClass = New-ViewModel -ClassName 'DynamicClass' -Methods @(
New-ViewModelMethod -Name 'ClassMethod' -Body {$this.AutoClassProperty = 'Hello World'}
) -CreateMethodCommand $false -AutomaticProperties $true
$DynamicClass.psobject.ClassMethod()
$DynamicClass
AutoClassProperty
-----------------
Hello WorldHere are the classes if you want nothing to do with the module and want to use the ViewModel class yourself:
# Pwsh7.5 - copy paste into the terminal and check it out.
# Make sure to load Add-Types, ViewModelBase and ActionCommand first
Add-Type -AssemblyName PresentationFramework, WindowsBase -ErrorAction Stop
[NoRunspaceAffinity()]
class ViewModelBase : PSCustomObject, System.ComponentModel.INotifyPropertyChanged {
# INotifyPropertyChanged Implementation
[ComponentModel.PropertyChangedEventHandler]$PropertyChanged
add_PropertyChanged([System.ComponentModel.PropertyChangedEventHandler]$handler) {
$this.psobject.PropertyChanged = [Delegate]::Combine($this.psobject.PropertyChanged, $handler)
}
remove_PropertyChanged([System.ComponentModel.PropertyChangedEventHandler]$handler) {
$this.psobject.PropertyChanged = [Delegate]::Remove($this.psobject.PropertyChanged, $handler)
}
RaisePropertyChanged([string]$propname) {
if ($this.psobject.PropertyChanged) {
$evargs = [System.ComponentModel.PropertyChangedEventArgs]::new($propname)
$this.psobject.PropertyChanged.Invoke($this, $evargs)
}
}
# End INotifyPropertyChanged Implementation
ViewModelBase() {}
[void]StartAsync($ClassMethod) {
$Powershell = [powershell]::Create()
$ScriptBlock = {
param($Delegate)
$Delegate.Invoke()
}.Ast.GetScriptBlock()
$null = $Powershell.AddScript($ScriptBlock)
$null = $Powershell.AddParameter('Delegate', $ClassMethod)
$Handle = $Powershell.BeginInvoke()
}
}
class ActionCommand : ViewModelBase, System.Windows.Input.ICommand {
# ICommand Implementation
[System.EventHandler]$InternalCanExecuteChanged
add_CanExecuteChanged([EventHandler] $value) {
$this.psobject.InternalCanExecuteChanged = [Delegate]::Combine($this.psobject.InternalCanExecuteChanged, $value)
}
remove_CanExecuteChanged([EventHandler] $value) {
$this.psobject.InternalCanExecuteChanged = [Delegate]::Remove($this.psobject.InternalCanExecuteChanged, $value)
}
[bool]CanExecute([object]$CommandParameter) {
return $true
}
[void]Execute([object]$CommandParameter) {
if ($this.psobject.IsAsync) {
$null = $this.psobject.StartAsync($this.psobject.Action)
} else {
$this.psobject.Action.Invoke()
}
}
# End ICommand Implementation
ActionCommand([System.Management.Automation.PSMethod]$Action, $IsAsync) {
$this.psobject.Action = $Action
$this.psobject.IsAsync = $IsAsync
}
$Action
$IsAsync
}
$xaml = [xml]::new()
$xaml.LoadXml(@'
<Window
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
Title="PsModelUI 7.5" ThemeMode="Dark" WindowStartupLocation="CenterScreen" Width="640" Height="480">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="{Binding CustomerNameValue}" />
<Button Content="Async" Command="{Binding AsyncCustomerNameCommand}" />
<Button Content="Freeze" Command="{Binding CustomerNameCommand}" />
</StackPanel>
</Window>
'@)
class ViewModel : ViewModelBase {
$AsyncCustomerNameCommand
$CustomerNameCommand
$CustomerNameValue = 'Hello World'
ViewModel() {
$Splat = @{
Name = 'CustomerNameValue'
MemberType = 'ScriptProperty'
Value = [scriptblock]::Create('return ,$this.psobject.{0}' -f 'CustomerNameValue')
SecondValue = [scriptblock]::Create('param($value)
$this.psobject.{0} = $value
$this.psobject.RaisePropertyChanged("{0}")' -f 'CustomerNameValue'
)
}
$this | Add-Member @Splat
}
[void]CustomerName() {
Start-Sleep -Seconds 2
$this.CustomerNameValue = Get-Random
}
}
$ViewModel = [ViewModel]::new()
$ViewModel.psobject.AsyncCustomerNameCommand = [ActionCommand]::new($ViewModel.psobject.CustomerName, $true)
$ViewModel.psobject.CustomerNameCommand = [ActionCommand]::new($ViewModel.psobject.CustomerName, $false)
$Window = [System.Windows.Markup.XamlReader]::Load(([System.Xml.XmlNodeReader]::new($xaml)))
$Window.DataContext = $ViewModel
$Window.ShowDialog()
Since we're able to extract logic from the view, you can test logic separately. If needed, errors from the async call can be retrieved from $ViewModelBase.psobject.LastAction.GetAwaiter().GetResult(). Which would be the from the [ActionCommand]$Command since the button is the caller.
Since classes inherit PSCustomObject, properties are accessed $Class.psobject.Property. It functions as an accessible hidden (but not private or protected) property that will show intellisense after .psobject.
While the xaml can bind to $Class.psobject.Property, there won't be a way to call PropertyChanged in property setter. The xaml can actually find and bind to Add-Member -MemberType ScriptProperty. The backing field must not have the same name as the script property in order for the binding to properly call the scriptblock setter.
[NoRunspaceAffinity()]
class DemoClass : pscustomobject {
$Prop = 'Foo'
Method() {$this.Prop = 'bar'}
}
$DemoClass = [DemoClass]::new()
$DemoClass.Prop # nothing
$DemoClass.psobject.Prop # returns FooNow when the property is set with $Class.Property = $Value, it can properly call PropertyChanged on the backing property! You can use $Class.Property for notifying or access the backing field directly by $Class.psobject.Property similar to csharp $Class.property and internal $Class._property.
Methods will have to be called with $Class.psobject.ClassMethod(). This isn't a problem for the ui as buttons are bound to commands that invoke the method. I have not explored adding via Add-Member -MemberType ScriptMethod.
Because we execute class methods in a runspace, the class has to be unbound with NoRunspaceAffinity or New-UnboundClassInstance. For windows powershell compatibility, New-UnboundClassInstance is used internally to create the viewmodels. Being unbound allows class methods to be invoked accross runspaces.
$DemoClass | Add-Member -Name 'Prop' -MemberType ScriptProperty -Value ([scriptblock]::Create('return ,$this.psobject.Prop')) -SecondValue ([scriptblock]::Create('param($value)
$this.psobject.Prop = $value
#$this.psobject.NotifyPropertyChanged("Prop")'))
$DemoClass.psobject.Method() # sets new value and calls property changed
$DemoClass.Prop # returns bar-
The main difference is the ViewModel setup.
-
Pwsh has access to the attribute
[NoRunspaceAffinity()]. Powershell 5.1 needs to create the class without a runspace.
# Pwsh
$ViewModel = [MyViewModel]::new()
$ViewModel.psobject.WriteVerboseCommand = [ActionCommand]::new($ViewModel.psobject.WriteVerboseMethod, $true, $Target, 0)
$ViewModel.WriteVerboseMethod()
PS> VERBOSE: Prints to host
# Powershell
$ViewModel = New-UnboundClassInstance MyViewModel
$ViewModel.psobject.Dispatcher = [System.Windows.Threading.Dispatcher]::CurrentDispatcher
$ViewModel.psobject.WriteVerboseCommand = [ActionCommand]::new($ViewModel.psobject.WriteVerboseMethod, $true, $Target, 0)
$ViewModel.WriteVerboseMethod()
PS> VERBOSE: Prints to hostFor times where you need to use the dispatcher:
# Pwsh can implicitly convert methods to delegates so this is possible.
$Dispatcher.InvokeAsync($Class.Method)
# But has no room for a parameter.
$Dispatcher.InvokeAsync($Class.Method($Parameter))
# So we have to rely on BeginInvoke for method and its delegate with a parameter.
$Dispatcher.BeginInvoke(9, $Class.MethodDelegate, $Parameter)As long as methods do not update the same view property at the same time there is nothing to worry about.
ActionCommand.Execute invokes the provided PSMethod in another runspace. It will not run and block on the GUI thread because it is not bound to the runspace it was created in with attribute [NoRunspaceAffinity()] and Windows Powershell equivalent.
# Update the view by setting property in class method.
# Provided that the property was setup with AddPropertyChangedToProperties() and bound in the xaml.
# Method works as is. Create an ActionCommand.IsAsync = $true to run in a runspace or false to run on the GUI thread.
[void]Method() {
$NewValue = Invoke-RestMethod
$this.Property = $NewValue.Result
}
# Use the internal method `$this.psobject.UpdateView` if somehow mutliple methods update the same class property at the same time.
# #Alternatively, use locks.
[void]Method() {
$NewValue = Invoke-RestMethod
$this.psobject.UpdateView([pscustomobject]@{
Property = $NewValue.Result
})
}Start-ThreadJob alternative feels slower and you'll want something to clean up the jobs. Also does not come default in Windows Powershell.
# The entirety of StartAsync() can be replaced with this but won't be able to run custom functions within the class method unless you pass an initialization script to define the function.
# And something else to clean up the job since Receive-Job with -AutoRemoveJob requires -Wait.
Start-ThreadJob -Scriptblock {
param($MethodToRunAsync)
$MethodToRunAsync.Invoke()
} -ArgumentList $MethodToRunAsync
