PowerShell GUI Tutorial: Building Whiteboard Apps With WPFPowerShell GUI Tutorial: Building Whiteboard Apps With WPF

Discover how to use PowerShell with WPF to create a simple inking canvas capable of saving drawings as JPEG files, undoing ink strokes, and resetting the canvas.

Brien Posey

February 19, 2025

13 Min Read
a dry erase boards with doodles, powershell code snippet, powershell logo, and a hand drawing with a market

A few months ago, I created a video demonstrating using PowerShell with Windows Presentation Foundation to build a simple inking canvas. The script I developed allowed users to draw on a large canvas using a mouse, Surface Pen, or touch screen (if supported by their device). The only extra feature was a menu for changing the ink color.

That project only intended to show how PowerShell can use Windows Presentation Foundation (WPF) to create more advanced graphical environments than with Windows Forms. I never aimed for the script to be a full-fledged whiteboard or drawing app. 

However, after publishing the video, I received a viewer request. They asked for a feature that saves drawings as JPEG files. What initially seemed like a simple task ended up more complicated than expected. Determined to improve the script, I added the Save As functionality and a few other menu options: New Drawing, Undo, and Exit. 

Figures 1 and 2 show the updated File and Edit menus. 

PowerShell whiteboard app's file menu showing New Drawing, Save As, and Exit menu options

Figure 1. Here is the updated File menu.

PowerShell whiteboard app's edit menu showing Undo and Choose Ink Colormenu options

Figure 2. Here is the updated Edit menu.

This article focuses on the script’s architecture rather than providing a line-by-line walkthrough.

Source Code Overview

The full source code is included below for reference. It begins with loading the necessary assemblies and creating a WPF window.

Related:How To Create an Interactive PowerShell Menu


# Load required assemblies
Add-Type -AssemblyName PresentationFramework 
Add-Type -AssemblyName PresentationCore
Add-Type -AssemblyName WindowsBase
Add-Type -AssemblyName System.Windows.Forms

# Create a WPF window
$Window = New-Object System.Windows.Window
$Window.Title = "Poseys PowerShell Whiteboard"
$Window.Width = 1024
$Window.Height = 768

# Create a Grid and define rows with adjusted heights
$Grid = New-Object System.Windows.Controls.Grid
$Grid.RowDefinitions.Add((New-Object System.Windows.Controls.RowDefinition -Property @{ Height = "Auto" }))  # Row for the Menu (Auto size)
$Grid.RowDefinitions.Add((New-Object System.Windows.Controls.RowDefinition -Property @{ Height = "*" }))     # Row for the InkCanvas (fill remaining space)
$Window.Content = $Grid


###############################
###  Initial Menu Creation  ###
###############################


# Create a Menu Object and add it to the grid
$Menu = New-Object System.Windows.Controls.Menu
$Menu.SetValue([System.Windows.Controls.Grid]::RowProperty, 0)  # Place menu in the first row
$Grid.Children.Add($Menu) | Out-Null

# Create File Menu
$FileMenu = New-Object System.Windows.Controls.MenuItem
$FileMenu.Header = "File"  # File Menu Title

# Create Edit Menu
$EditMenu = New-Object System.Windows.Controls.MenuItem
$EditMenu.Header = "Edit"  # Edit Menu Title


#######################################
###  Code for Selecting Ink Color   ###
#######################################

# Create a menu item for choosing ink color
$ColorItem = New-Object System.Windows.Controls.MenuItem
$ColorItem.Header = "Choose Ink Color"
$ColorItem.Add_Click({
    # Open the Color dialog box to select a color
    $ColorDialog = New-Object System.Windows.Forms.ColorDialog
    if ($ColorDialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {

        # Convert System.Drawing.Color to System.Windows.Media.Color
        $DrawingColor = $ColorDialog.Color
        $MediaColor = [System.Windows.Media.Color]::FromArgb($DrawingColor.A, $DrawingColor.R, $DrawingColor.G, $DrawingColor.B)

        # Apply the color to the InkCanvas's DefaultDrawingAttributes
        $InkCanvas.DefaultDrawingAttributes.Color = $MediaColor
    }
})


###############################
### Code for Saving Drawing ###
###############################

# Create a menu item for saving the drawing
$SaveItem = New-Object System.Windows.Controls.MenuItem
$SaveItem.Header = "Save As"
$SaveItem.Add_Click({

## Create Save Dialog Box 
$SaveDialog = New-Object Microsoft.Win32.SaveFileDialog
$SaveDialog.Filter = "JPG Image|*.jpg"
If ($SaveDialog.ShowDialog()) {

## Click Action ##


$FilePath = $SaveDialog.FileName

# Define the Ink Canvas Width and Height

$Width = 1024
$Height = 768

# Create a Visual Brush object to capture the ink canvas

$Visual = New-Object Windows.Media.DrawingVisual
$Context = $visual.RenderOpen()
$Context.DrawRectangle(
    (New-Object Windows.Media.VisualBrush($InkCanvas)), 
    $Null, 
    (New-Object Windows.Rect(0, 0, $Width, $Height))
)
$Context.Close()

# Define the pixel format
$PixelFormat = [Windows.Media.PixelFormats]::Default

# Create a Render Target Bitmap
$RTB = New-Object Windows.Media.Imaging.RenderTargetBitmap $Width, $Height, 96, 96, $PixelFormat
$RTB.Render($Visual)

# Convert render target bitmap to a bitmap frame
$Frame = [Windows.Media.Imaging.BitmapFrame]::Create([System.Windows.Media.Imaging.BitmapSource]$RTB)

# Encode the bitmap frame as a JPG
$Encoder = New-Object Windows.Media.Imaging.JpegBitmapEncoder
$Encoder.Frames.Add($Frame) | Out-Null

# Save to file
$FS = [System.IO.FileStream]::New($FilePath, [System.IO.FileMode]::Create)
$Encoder.Save($FS)
$FS.Close()
}
})


#####################
### Code for Undo ###
#####################

$UndoItem = New-Object System.Windows.Controls.MenuItem
$UndoItem.Header = "Undo"
$UndoItem.Add_Click({
if ($InkCanvas.Strokes.Count -gt 0) 
     {
     $InkCanvas.Strokes.RemoveAt($inkCanvas.Strokes.Count - 1)  # Removes the last stroke
     }
})

#####################
### Code for Exit ###
#####################

$ExitItem = New-Object System.Windows.Controls.MenuItem
$ExitItem.Header = "Exit"
$ExitItem.Add_Click({
   $Window.Close()
   })


############################
### Code to Clear Canvas ###
############################


$NewItem = New-Object System.Windows.Controls.MenuItem
$NewItem.Header = "New Drawing"
$NewItem.Add_Click({
$inkCanvas.Strokes.Clear() 
})

##################################
### Code to Add Items to Menus ###
##################################


# Add New Drawing option to the File menu
$FileMenu.Items.Add($NewItem) | Out-Null

# Add Save option to the File menu
$FileMenu.Items.Add($SaveItem) | Out-Null

# Add Exit option to the File menu
$FileMenu.Items.Add($ExitItem) | Out-Null

# Add Undo option to the Edit menu
$EditMenu.Items.Add($UndoItem) | Out-Null

# Add Color option to the Edit menu
$EditMenu.Items.Add($ColorItem) | Out-Null

############################################
### Code to Add Menus to the Menu Object ###
############################################

# Add the File menu to the Menu object
$Menu.Items.Add($FileMenu) | Out-Null

# Add the Edit menu to the Menu object
$Menu.Items.Add($EditMenu) | Out-Null



######################################
### Code to Create the Ink Canvas  ###
######################################

# Create an InkCanvas
$InkCanvas = New-Object System.Windows.Controls.InkCanvas
$InkCanvas.Background = [System.Windows.Media.Brushes]::White
$InkCanvas.HorizontalAlignment = "Stretch"
$InkCanvas.VerticalAlignment = "Stretch"
$InkCanvas.Margin = "10"
$InkCanvas.SetValue([System.Windows.Controls.Grid]::RowProperty, 1)  # Place InkCanvas in the second row

# Add the InkCanvas to the Grid
$Grid.Children.Add($InkCanvas) | Out-Null


############################
###    Display the GUI   ###
############################

$Window.ShowDialog() | Out-Null

######### End of Script #################################################

Understanding Windows Ink

Now that you have seen the source code, let’s briefly explore how inking works. Although it might seem like an odd place to start, understanding one key concept about inking will clarify much of what the script does.

Windows Ink doesn’t treat drawing as a simple image where the color of each pixel is changed. Instead, it likely uses a multi-dimensional array (though I haven’t found this officially documented). If that’s the case, one array dimension tracks the individual pen strokes and their associated data. Every time you mark the canvas, Windows Ink registers it as a separate action. In other words, Windows Ink views an ink drawing as a collection of discrete strokes, not a single static image.

The second dimension of the array would track the pixels altered by each stroke (again, assuming Windows Ink uses a multi-dimensional array).

Why This Inking Concept Matters

Understanding this model is key to how the script handles inking and its functionality. 

  • Undo Option: The Undo feature counts the number of strokes made. If the stroke count is higher than zero (which means the canvas isn’t empty), the script decreases the count by one, effectively removing the most recent stroke. You can use the Undo action repeatedly because it removes the most recent stroke each time it is applied.

  • New Drawing Option: When selected, it clears all existing strokes and resets the canvas, giving you a fresh start.

  • Saving Drawings as JPEGs: To save the drawing, the script essentially “captures” the canvas by defining a rectangle around it, creating a render target bitmap, and converting that into a bitmap frame. Finally, the bitmap frame is encoded into a JPEG image. 

Related:Getting Started With Custom Shortcut Menus in PowerShell

I will admit that I struggled with the “Save As” portion of the script and resorted to ChatGPT for assistance. However, ChatGPT did not get it right, either. As a result, the Save As section is a mashup of my code and ChatGPT’s suggestions.

Developing the code to save the Windows Ink canvas as a JPEG was the most challenging part of this project, but a lot of work also went into building the menus. I briefly touched on menus in my previous WPF video, but because this script includes a more detailed collection of menu items, I would like to revisit the topic here.

Grid layout basics

When building a WPF-based GUI, one of the first things you need to know is the grid concept. Think of the grid as a layout system similar to the rows and columns in a spreadsheet. In WPF, you place UI items by defining their position within the grid. If you’re new to the concept, I have published an introductory article about the WPF grid, which provides further details. 

Related:How To Use PS2EXE To Convert PowerShell Scripts Into EXE Files

I kept the grid layout simple by defining only two rows, with the columns left undefined (though a row inherently implies that a column exists). Here’s how it works:

  • Top row: This row is dedicated to the menu. It includes the File and Edit options. I set its height to “Auto,” meaning it adjusts to fit the menu’s contents.

  • Bottom row: This row is for the inking canvas. I defined the height as “*,” which means it takes up the remaining space in the window after the menu.

The block of code below defines the application window and its grid:


# Create a WPF window
$Window = New-Object System.Windows.Window
$Window.Title = "Poseys PowerShell Whiteboard"
$Window.Width = 1024
$Window.Height = 768

# Create a Grid and define rows with adjusted heights
$Grid = New-Object System.Windows.Controls.Grid
$Grid.RowDefinitions.Add((New-Object System.Windows.Controls.RowDefinition -Property @{ Height = "Auto" }))  # Row for the Menu (Auto size)
$Grid.RowDefinitions.Add((New-Object System.Windows.Controls.RowDefinition -Property @{ Height = "*" }))     # Row for the InkCanvas (fill remaining space)
$Window.Content = $Grid

The first step in building a menu system in WPF is to create a Menu object and anchor it to the grid. Since the top row of the grid is always designated as Row 0, the script places the menu object in this row.  Here is the code that accomplishes this:


# Create a Menu Object and add it to the grid
$Menu = New-Object System.Windows.Controls.Menu
$Menu.SetValue([System.Windows.Controls.Grid]::RowProperty, 0)  # Place menu in the first row
$Grid.Children.Add($Menu) | Out-Null

The Menu object provides the basic structure and functionality of the menu, but it doesn’t do anything on its own. The individual menus within the application—such as File and Edit—are technically referred to as MenuItems. Here’s how we create them: 


# Create File Menu
$FileMenu = New-Object System.Windows.Controls.MenuItem
$FileMenu.Header = "File"  # File Menu Title

# Create Edit Menu
$EditMenu = New-Object System.Windows.Controls.MenuItem
$EditMenu.Header = "Edit"  # Edit Menu Title

In each block, we create a MenuItem object and assign a Header attribute to label it. The header text (e.g., “File” and “Edit”) is what appears in the top row of the grid, representing the respective menus.

At this point, the MenuItem objects—FileMenu and EditMenu—are created but aren’t yet part of the menu system. As such, we must link these items to the Menu object with the following lines of code:


# Add the File menu to the Menu object
$Menu.Items.Add($FileMenu) | Out-Null

# Add the Edit menu to the Menu object
$Menu.Items.Add($EditMenu) | Out-Null

Here, we add both the FileMenu and EditMenu items to the Menu object, effectively linking them. 

So, what about the options that appear in the menus? Surprisingly, these options are also created using MenuItem objects, raising an interesting question: Why do some menu items behave as menus while others serve as simple choices? The answer lies in linkages and click actions.

As you have probably noticed by now, every menu-related object you create, except for the menu itself, must link to another object. For instance, the FileMenu and EditMenu items connect to the top-level Menu object, which causes them to behave as menus. Similarly, MenuItems that act as choices (e.g., Open, Save, and Exit) link to higher-level menu items like File or Edit.

Another component of menu functionality is the click action. A click action defines what happens when a user clicks a menu item. Consider this block of code that creates the Exit option under the File menu:


$ExitItem = New-Object System.Windows.Controls.MenuItem
$ExitItem.Header = "Exit"
$ExitItem.Add_Click({
   $Window.Close()
   })

This block of code does three things:

  1. creates a MenuItem object;

  2. assigns the text to Exit using the Header object; and

  3. defines a click action that closes the GUI.

Click actions can consist of a single command (as shown here) or multiple commands, as with the Save As and Choose Ink Color menu items. 

The Exit menu item has been created but not linked to anything yet. The linkage happens later in the script with this line of code: 

$FileMenu.Items.Add($ExitItem) | Out-Null

Here, the Exit menu item links to the File menu item, which, in turn, connects to the Menu object. 

Advanced Click Actions: The ‘Save As’ Option

Before wrapping up, let’s revisit click actions to explore an important example. As mentioned, a click action can consist of a single command or multiple commands. The Save As menu item’s click action is a perfect example because it includes a block of code with several commands. The first few commands load a predefined dialog box. 

I covered dialog box creation in detail in another article, but here’s a quick overview. Below are the first few lines of the Save As click action:


## Create Save Dialog Box 
$SaveDialog = New-Object Microsoft.Win32.SaveFileDialog
$SaveDialog.Filter = "JPG Image|*.jpg"
If ($SaveDialog.ShowDialog()) {

## Click Action ##

$FilePath = $SaveDialog.FileName

Here’s what each line does:

  1. Creates the dialog box: The $SaveDialog object is based on the Windows Save File dialog box provided by Microsoft. 

  2. Applies a filter: The Filter property limits the file type to JPEG images (*.jpg). It ensures the dialog box displays only JPEG files in the file selection process. However, saving a file with a .jpg extension doesn’t automatically create a valid JPEG file—additional encoding steps are needed. 

  3. Displays the dialog box: The third line checks if the user interacted with the dialog box (e.g., clicked “Save As”). 

  4. Captures the file path: The $FilePath variable sets the output path to match the path and filename provided by the user.

This setup tailors the dialog box to the specific needs of the Save As function, making the user experience more intuitive.

About the Author

Brien Posey

Brien Posey is a bestselling technology author, a speaker, and a 20X Microsoft MVP. In addition to his ongoing work in IT, Posey has spent the last several years training as a commercial astronaut candidate in preparation to fly on a mission to study polar mesospheric clouds from space.

https://brienposey.com/

Sign up for the ITPro Today newsletter
Stay on top of the IT universe with commentary, news analysis, how-to's, and tips delivered to your inbox daily.

You May Also Like