This document will provide technical details about how this module is designed. You might use this information to learn how to build your own PowerShell modules. Or you might use this information to troubleshoot a problem with the PSClock module.
The WPF form is very simple. In the parent form is a Stack Panel which contains a Label control. The clock is nothing more than the text of the label control. Most PowerShell-based WPF scripts import some time of XAML content to define the form. The form in this module is so simple that it can be defined in code.
$form = New-Object System.Windows.Window
$form.Title = "PSTimer"
$form.Height = 200
$form.Width = 400
$form.SizeToContent = "WidthAndHeight"
$form.AllowsTransparency = $True
$form.Background = "Transparent"
$stack = New-Object System.Windows.Controls.StackPanel
$label = New-Object System.Windows.Controls.label
$label.Content = <Datetime string>
$stack.AddChild($label)
$form.AddChild($stack)
The values to customize the appearance such as font style and color are passed as function parameters and then on to the form. Note that the code is defining some settings that aren't visible. But they remain for troubleshooting or debug purposes should I need them.
Because this is a clock, I wanted it to update once a second. To make this work in my WPF form, I create a DispatcherTimer
object and configure the tick interval to be 1 second.
$timer = New-Object System.Windows.Threading.DispatcherTimer
$timer.Interval = [TimeSpan]"0:0:1.00"
Next, I need to create an event to run every time the timer ticks off another second.
$timer.Add_Tick({
#update the label contents with the current date and time
})
The WPF form also includes a few other events. Because the form is transparent, there's nothing visible to grab and move the clock. I added an event to move the form when it was clicked and dragged with the left mouse button.
$form.Add_MouseLeftButtonDown({ $form.DragMove() })
Even though there is a Stop-PSClock
command to close the form, I also added another event to close the form with a right mouse click.
$form.Add_MouseRightButtonUp({ _QuitClock })
The action is calling an internal private function that cleans up related files.
Finally, I added shortcuts to make it easy to re-size the clock using the + and - keys.
$form.Add_KeyDown({
switch ($_.key) {
{ 'Add', 'OemPlus' -contains $_ } {
If ( $PSClockSettings.fontSize -ge 8) {
$PSClockSettings.fontSize++
$form.UpdateLayout()
}
}
{ 'Subtract', 'OemMinus' -contains $_ } {
If ($PSClockSettings.FontSize -ge 8) {
$PSClockSettings.FontSize--
$form.UpdateLayout()
}
}
{ 'P' -contains $_ } {
#show the preview settings form
Show-PSClockSettingPreview
}
}
})
When you create a WPF form and run it in PowerShell, because PowerShell is single-threaded, your prompt is blocked until the script creating the form completes. In this module the whole point is to keep the clock running on the desktop. The solution is to create a new runspace and run the WPF PowerShell code in it.
$rs = [RunspaceFactory]::CreateRunspace()
$rs.ApartmentState = "STA"
$rs.ThreadOptions = "ReuseThread"
$rs.Open()
All of the code that creates the form is added to the runspace.
PSCmd = [PowerShell]::Create().AddScript({
#PowerShell code
})
#assign the command to the runspace
PSCmd.runspace = $rs
#launch the runspace
[void]PSCmd.BeginInvoke()
During development, I ran the WPF code directly in PowerShell. Only after I was satisfied with the results did I move it to the runspace.
Normally, you can't easily interact with anything running in a separate PowerShell runspace. However, you can take advantage of a synchronized hashtable. This is a special type of hashtable. In Start-PSClock
I define a hashtable for the global scope.
$global:PSClockSettings = [hashtable]::Synchronized(@{
DateFormat = $DateFormat
FontSize = $FontSize
FontStyle = $FontStyle
FontWeight = $FontWeight
Color = $Color
FontFamily = $FontFamily
OnTop = $OnTop
StartingPosition = $Position
CurrentPosition = $Null
})
The keys and values are the function's parameters. The synchronized part means I can create the same hashtable in the runspace.
$rs.SessionStateProxy.SetVariable("PSClockSettings", $global:PSClockSettings)
The runspace now has a variable called $PSClockSettings
. If I make a change to the hashtable in either the global scope or the runspace, the other variable will be updated.
This means that I can use the global hashtable to control the runspace.
if ($PSClockSettings.StartingPosition) {
$form.left = $PSClockSettings.StartingPosition[0]
$form.top = $PSClockSettings.StartingPosition[1]
}
else {
$form.WindowStartupLocation = "CenterScreen"
}
# ...
$label = New-Object System.Windows.Controls.label
$label.Content = Get-Date -Format $PSClockSettings.DateFormat
$label.HorizontalContentAlignment = "Center"
$label.Foreground = $PSClockSettings.Color
$label.FontStyle = $PSClockSettings.FontStyle
$label.FontWeight = $PSClockSettings.FontWeight
$label.FontSize = $PSClockSettings.FontSize
$label.FontFamily = $PSClockSettings.FontFamily
In the Tick
event handler, I update the form and hashtable.
if ($PSClockSettings.Running) {
$label.Foreground = $PSClockSettings.Color
$label.FontStyle = $PSClockSettings.FontStyle
$label.FontWeight = $PSClockSettings.FontWeight
$label.FontSize = $PSClockSettings.FontSize
$label.FontFamily = $PSClockSettings.FontFamily
$label.Content = Get-Date -Format $PSClockSettings.DateFormat
$form.TopMost = $PSClockSettings.OnTop
$form.UpdateLayout()
$PSClockSettings.CurrentPosition = $form.left,$form.top
}
You can always view the "raw" hashtable from a PowerShell prompt.
PS C:\> $PSClockSettings
Name Value
---- -----
FontStyle Normal
Started 11/9/2021 8:22:57 AM
Running True
FontWeight Normal
CurrentPosition {1660, 1081}
Color yellow
FontSize 30
StartingPosition {1660, 1081}
FontFamily gadugi
Runspace System.Management.Automation.Runspaces.LocalRunspace
OnTop False
DateFormat F
Because I didn't want to have to manually update the hashtable if I wanted to modify the clock, I Set-PSClock
updates the hashtable. Using a function means I can document it and include features like -WhatIf
.
$settings = "FontSize", "FontStyle", "FontWeight", "Color", "OnTop", "DateFormat", "FontFamily"
if ($PSClockSettings -And $PSClockSettings.Running) {
Foreach ($setting in $settings) {
if ($PSBoundParameters.ContainsKey($setting)) {
$value = $PSBoundParameters[$setting]
$action = "Setting value to $value"
Write-Verbose "Setting $setting to $value"
if (PSCmdlet.ShouldProcess($setting, $action)) {
$Global:PSClockSettings[$setting] = $Value
}
}
} #foreach setting
When a new PSClock is created, it also creates a new runspace. You can see this by running Get-Runspace
. Runspace information is stored in the $PSClockSettings
synchronized hashtable.
PS C:\> Get-PSClock
Running Format FontFamily Size Weight Color Style OnTop RunspaceID
------- ------ ---------- ---- ------ ----- ----- ----- ----------
True F gadugi 30 Normal yellow Normal False 11
PS C:\> Get-Runspace -id 11
Id Name ComputerName Type State Availability
-- ---- ------------ ---- ----- ------------
11 Runspace11 localhost Local Opened Busy
When the clock is closed, the runspace will remain. This is not necessarily a bad thing. Once you close your PowerShell session, the runspaces will be removed. But I wanted a cleaner way to clean up when a clock is closed.
In my event handler connected to quitting, I create a ThreadJob. This is similar to a PowerShell background job excepts it runs in the current thread. It doesn't create a new runspace. I have the runspace ID from the synchronized hashtable so I can start a new ThreadJob like this:
#define a thread job to clean up the runspace
$cmd = {
Param([int]$ID)
$r = Get-Runspace -Id $id
$r.close()
$r.dispose()
}
Start-ThreadJob -ScriptBlock $cmd -ArgumentList $PSClockSettings.runspace.id
When the clock closes, either with Stop-PSClock
or a right-click, this code will run.
The main function, Start-PSClock
, generates the runspace, creates the synchronized hashtable and launches the WPF form. Once the clock is running, I can use Set-PSClock
to modify it.
I tend to run multiple PowerShell windows and versions at the same time. While I could technically run the module in two different PowerShell sessions, it wouldn't make sense to have duplicate clocks.
When a new clock is created, Start-PSClock
creates a flag file in the user's TEMP folder. This is nothing more than a text file.
"[{0}] PSClock started by {1} under PowerShell process id $pid" -f (Get-Date), $env:USERNAME | Out-File $env:temp\psclock-flag.txt
The Start-PSClock
command checks for this file, and if it is found, writes a warning message and then quits.
if (Test-Path $env:temp\psclock-flag.txt) {
$msg = @"
A running clock has been detected from another PowerShell session:
$(Get-Content $env:temp\psclock-flag.txt)
If this is incorrect, delete $env:temp\psclock-flag.txt and try again.
"@
Write-Warning $msg
Return
}
In a different PowerShell session you'll get a result like this:
PS C:\> Start-PSClock
WARNING:
A running clock has been detected from another PowerShell session:
[11/9/2021 8:22:57 AM] PSClock started by Jeff under PowerShell process id 33316
If this is incorrect, delete C:\Users\Jeff\AppData\Local\Temp\psclock-flag.txt and try again.
The _Quit
private function in Start-PSClock
should delete this file.
if (Test-Path $env:temp\psclock-flag.txt) {
Remove-Item $env:temp\psclock-flag.txt
}
But, if the PowerShell session closes without stopping the clock, this code won't run and the flag file will remain. In this situation, you need to manually delete the file, as the warning instructs.
If you try to start another clock in the same session, Start-PSClock
tests to see if one is already running.
if ($PSClockSettings.Running) {
Write-Warning "You already have a clock running. You can only have one clock running at a time."
Return
}
One of the design goals was to make it easy to start a clock using the last used settings. Granted, it wouldn't be that difficult to add a Start-PSClock
command to a PowerShell profile script with all the parameters. But, I'd have to modify the profile script every time I decided to tweak a setting. Instead, Save-PSClock
will export key properties to an XML file using Export-Clixml
.
In the module file, a module-scoped variable is defined for the export path.
$SavePath = Join-Path -Path $home -ChildPath PSClockSettings.xml
The PSClockSettings.xml
will be stored in $HOME
. Because I'm going to use the values from the $PSClockSettings
synchronized hashtable with Start-PSClock
, I need to "align" the property names.
$props = @{Name="DateFormat";Expression={$_.Format}},"Color",
@{Name="FontSize";Expression={$_.Size}},
@{Name="FontWeight";Expression={$_.weight}},"FontFamily",
@{Name="FontStyle";Expression={$_.Style}},"OnTop",
@{Name="Position";Expression = {$_.CurrentPosition}}
This array is then used with Select-Object
to export the custom properties.
Get-PSClock | Select-Object -property $props | Export-Clixml -Path $SavePath
In Start-PSClock
, there is a test for the xml file. If found, it is imported and the values assigned to the function's parameters.
if ((Test-Path $SavePath)-AND (-not $Force)) {
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Using saved settings"
$import = Import-Clixml -Path $SavePath
foreach ($prop in $import.PSObject.properties) {
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Using imported value for $($prop.name)"
Set-Variable -name $prop.name -value $prop.Value
}
}
In a function, each parameter becomes an internal variable. I can use Set-Variable
to assign values to the parameters.
The initial design concept was that if the user specified any parameter value, then the saved file would be skipped. This proved to be too cumbersome. Instead, I opted for a -Force
parameter. This allows the user to specify parameter values, or use the defaults and ignore any saved settings. The saved settings file will not be deleted in this situation. If you no longer wish to use the saved settings file, you can manually delete it.
The only true object that this module generates is from Get-PSClock
. This function makes it easier to interact with the $PSClockSettings
synchronized hashtable. Instead of writing a hashtable to the pipeline, the command creates a custom object.
[PSCustomObject]@{
PSTypeName = "PSClock"
Started = $global:PSClockSettings.Started
Format = $global:PSClockSettings.DateFormat
Output = (Get-Date -Format $global:PSClockSettings.DateFormat)
Running = $global:PSClockSettings.Running
FontFamily = $global:PSClockSettings.FontFamily
Size = $global:PSClockSettings.fontSize
Weight = $global:PSClockSettings.FontWeight
Color = $global:PSClockSettings.Color
Style = $global:PSClockSettings.FontStyle
OnTop = $global:PSClockSettings.OnTop
CurrentPosition = $global:PSClockSettings.CurrentPosition
RunspaceID = $global:PSClockSettings.Runspace.id
}
The PSTypeName
entry defines an object type.
PS C:\S> Get-PSClock | Get-Member
TypeName: PSClock
Name MemberType Definition
---- ---------- ----------
Equals Method bool Equals(System.Object obj)
GetHashCode Method int GetHashCode()
GetType Method type GetType()
ToString Method string ToString()
Color NoteProperty string Color=yellow
CurrentPosition NoteProperty Object[] CurrentPosition=System.Object[]
FontFamily NoteProperty string FontFamily=gadugi
Format NoteProperty string Format=F
OnTop NoteProperty switch OnTop=False
Output NoteProperty System.String Output=Tuesday, November 9, 2021 9:2…
Running NoteProperty bool Running=True
RunspaceID NoteProperty int RunspaceID=11
Size NoteProperty int Size=30
Started NoteProperty System.DateTime Started=11/9/2021 8:22:57 AM
Style NoteProperty string Style=Normal
Weight NoteProperty string Weight=Normal
The object is defined with all the properties I might ever want to use. However, I only need to see a subset in most cases. This is no different than a command like Get-Process
. The process object is very rich, but the command output provide a formatted view. I can do the same thing, but only if the object has a defined name, like PSClock
.
I used the New-PSFormatXML command from the PSScriptTools module. This generated a format ps1xml file which is exported in the module manifest.
FormatsToProcess = @('formats\psclock.format.ps1xml')
I tweaked the file to get the output I wanted. One change I made was to use ANSI formatting to highlight if a clock was not running.
<TableColumnItem>
<ScriptBlock>
if ($host.name -match "console|code" -AND (-Not $_.Running)) {
"$([char]27)[91m$($_.Running)$([char]27)[0m"
}
else {
$_.Running
}
</ScriptBlock>
</TableColumnItem>
The last major design element is the use of auto-completion. I wanted tab completion for the font family and color parameters in Start-PSClock
and Set-PSClock
.
Register-ArgumentCompleter -CommandName 'Start-PSClock', 'Set-PSClock' -ParameterName 'FontFamily' -ScriptBlock {
param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter)
[System.Drawing.Text.InstalledFontCollection]::new().Families.Name |
Where-Object { $_ -match "^$($WordToComplete)" } |
ForEach-Object {
# completion text,listitem text,result type,Tooltip
[System.Management.Automation.CompletionResult]::new("'$($_)'", $_, 'ParameterValue', $_)
}
}
Register-ArgumentCompleter -CommandName 'Start-PSClock', 'Set-PSClock' -ParameterName 'Color' -ScriptBlock {
param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter)
[System.Drawing.Brushes].GetProperties().name | Select-Object -Skip 1 |
Where-Object { $_ -match "^$($WordToComplete)" } |
ForEach-Object {
# completion text,listitem text,result type,Tooltip
[System.Management.Automation.CompletionResult]::new("'$($_)'", $_, 'ParameterValue', $_)
}
}
The script block gets values from the .NET Framework and pipes to Where-Object
so that I can use the $WordToComplete
variable which allows me to start typing part of a value and then press tab.
For example, I can type r
as a value for FontFamily
then press tab to see all the possible values.
Or, I can press tab and cycle through all of the settings.
Some of the parameters in Start-PSClock
have parameter validation using ValidateSet()
[Parameter(HelpMessage = "Specify a font style.", ValueFromPipelineByPropertyName)]
[ValidateSet("Normal", "Italic", "Oblique")]
[alias("style")]
[string]$FontStyle = "Normal",
This too, provides an auto-completion experience.
If you feel that your PowerShell scripting skills need a boost, I'm assuming you've first read Learn PowerShell Scripting in a Month of Lunches. If so, then I suggest getting a copy of the PowerShell Scripting and Toolmaking Book. Don Jones and I wrote this book intending it to be the definitive guide on everything you need to know to be a better PowerShell scripter and toolmaker.