TLDR: This issue is now fixed, it was a scoping issue. Please see my response below for the full working code to use in your own projects. Enjoy!
I have been making an ezXAML library (easy XAML) for Windows Power Shell. Basically you can create a DOM like you would with jQuery: Create element and add children this builds a virtual DOM. The ToString function makes the XAML string from my element class including all the children and their children. You send your element to load window: a window element is created, your DOM is added as a child, the string is sent to the xamlReader to be loaded to a window object, then ShowDialog is called on it. I am using modal windows so the virtual and real DOM stay consistent with each other: I can navigate my virtual DOM easy to find element names to look up in the real DOM.
I then took a page from AngularJS, adding my own ezClick and ezKeyDown. I even added ezBind for databinding. I even wrote a bunch of unit tests to test my library, including a simple incrementer unit test that uses ezClick and ezBind.
The problem was on my end, it was a scoping issue. Databinding now works perfectly. I now have Suduko working with databinding! Here is the corrected ezXAML library, Enjoy! MIT free licence, use at your own risk. Basically AngularJS but in Powershell.
# ezxaml.ps1 v0.0.5
# By: Crawford Computing
# This facilitates creating a window using WFP and XAML, and wiring up events.
# This draws heavily on concepts from AngularJs, and includes a virtual DOM.
# You can use ezClick, ezKeyDown, ezTextChanged ezMouseDown and ezBind attributes for your XAML elements
# note: KeyDown and TextChanged have a bug in .net, we cannot use ezBind in conjuction with them
# Events and Data are bound on window open, and data is bebound during every event.
# in your click handle, your params are: "param ($sender, $eventArgs, $data)"
# $sender.Name is the button name, $data is the scope data object. this is the data you can bind and otherwise use in your app.
# Usage:
# $element = CreateElement -tag [String] -attr [Dictionary [String,String] -selfClosing [Bool, default = false]
# This creates a XamlElement, our internal class. <Window></Window> is created for us in LoadWindow-XAML
# $element.AddAttribute([Name],[Value])
# Add an attribute after the element is created
# $element.AddChild([XamlElement], $place = "last")
# this adds a child to the dom. Place can be first, last, or an int for position.
# $clone = $element.Clone()
# this recursively clones the tree from element
# $element.ToXamlString($debug = $false)
# this converts the entire DOM tree from this element to the last grandchild to a string. The debug flag will include our custom non-valid tags
# $element.ToXamlString($debug = $false)
# this converts the entire DOM tree from this element to the last grandchild to a string. The debug flag will include our custom non-valid tags.
# PrettyPrint -element [XamlElement]
# using ToXamlString, this will include indentations and new lines to make debugging easier
# LoadWindow-XAML -content [XamlElement], -title [String], -data [Dictionary [String, Object] default = $null,
# -height [int, optional], -width [int, optional], -top [int, optional], -left [int, optional],
# -debug [bool, default = $false]
# This Loads the window. As stated, <Window></Window> is created here, and the content is added as a child.
# Before showing the window, ezEvents are wired and the data is bound to any ezBind elements.
# Add the necessary assemblies
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName PresentationCore
Add-Type -AssemblyName PresentationFramework
# XAML element class. This describes a hierarchical element system like the HTML DOM, and then returns a XAML string for WPF.
class XamlElement {
[string]$TagName
[System.Collections.Generic.Dictionary[String, String]]$Attributes
[System.Collections.Generic.List[object]]$Children
[XamlElement]$Parent
[bool]$IsSelfClosing
# Constructor to initialize the tag name and optionally attributes
XamlElement([string]$tagName) {
$this.TagName = $tagName
$this.Attributes = @{}
$this.Children = @()
$this.Parent = $null
}
#initialize
[void] initialize([System.Collections.Generic.Dictionary[String, String]]$attributes = @{}, [bool]$isSelfClosing = $false) {
$this.IsSelfClosing = $isSelfClosing
# Convert the attributes hashtable into a list of key-value pairs to preserve order
foreach ($key in $attributes.Keys) {
$this.Attributes.Add($key, $attributes[$key])
}
}
# Clone method
[XamlElement] Clone() {
# Create a new instance of XamlElement with the same tag name
$clone = [XamlElement]::new($this.TagName)
# Clone attributes
foreach ($key in $this.Attributes.Keys) {
$clone.Attributes[$key] = $this.Attributes[$key]
}
# Clone children recursively
foreach ($child in $this.Children) {
if ($child -is [XamlElement]) {
$clone.Children.Add($child.Clone())
}
}
# Set the self-closing status
$clone.IsSelfClosing = $this.IsSelfClosing
return $clone
}
# Add an attribute to this element
AddAttribute([string]$key, [string]$value) {
$this.Attributes[$key] = $value
}
# Convert the element and its children into a valid XAML string
[string] ToXamlString() {
return $this.ToXamlString($false) # Call the main method with default debug value
}
[string] ToXamlString([bool]$debug = $false) {
$xaml = "<$($this.TagName)"
# Add attributes
foreach ($key in $this.Attributes.Keys) {
# Include custom attributes only if debug mode is enabled
if ($debug -or (-not $key.StartsWith("ez"))) {
$xaml += " $($key)='$($this.Attributes[$key])'"
}
}
# Check if the element is self-closing
if ($this.IsSelfClosing) {
$xaml += " />"
} else {
$xaml += ">"
# Add children elements
foreach ($child in $this.Children) {
$xaml += $child.ToXamlString($debug)
}
# Close the tag
$xaml += "</$($this.TagName)>"
}
return $xaml
}
# Pretty print the element with proper indentation
[string] PrettyPrint([int]$depth = 0) {
$indentation = " " * $depth
$xaml = "$indentation<$($this.TagName)"
# Add attributes
foreach ($key in $this.Attributes.Keys) {
$xaml += " $($key)='$($this.Attributes[$key])'"
}
# Handle self-closing or nested elements
if ($this.IsSelfClosing) {
$xaml += " />"
} else {
$xaml += ">"
if ($this.Children.Count -gt 0) {
$xaml += "`n"
foreach ($child in $this.Children) {
$xaml += $child.PrettyPrint($depth + 1) + "`n"
}
$xaml += "$indentation</$($this.TagName)>"
} else {
$xaml += "</$($this.TagName)>"
}
}
return $xaml
}
}
# Add-ChildElement Function
function AddChild {
param (
[XamlElement]$parent, # The parent XamlElement
[XamlElement]$child, # The child XamlElement to add
[int]$place = -1 # The position to insert the child (-1 means add to the end)
)
if (-not $parent) {
throw "Parent element cannot be null."
}
if (-not $child) {
throw "Child element cannot be null. Parent: $($parent.tagName)"
}
# Add the child at the specified position or at the end
if ($place -ge 0 -and $place -lt $parent.Children.Count) {
$parent.Children.Insert($Place, $Child)
} else {
$parent.Children.Add($Child)
}
# Set the parent of the child
$child.Parent = $parent
}
# Pretty Print
function PrettyPrint {
param (
[XamlElement]$element # Accept the XamlElement
)
$result = $element.PrettyPrint(0)
return $result
}
# open a window
function LoadWindow-XAML {
param (
[XamlElement]$content,
[string]$title,
[System.Collections.Generic.Dictionary[String, Object]]$data = $null,
[int]$height = -1,
[int]$width = -1,
[int]$top = -1,
[int]$left = -1,
[bool]$debug = $false,
[ScriptBlock]$onLoadFunction = $null # Optional parameter for the post-load function
)
# Ensure $data is a valid dictionary
if (-not $data) {
$data = [System.Collections.Generic.Dictionary[String, Object]]::new()
}
# Assign to global variable
$global:Data = $data
# Define base attributes for the Window
$attributes = New-Object 'System.Collections.Generic.Dictionary[String, String]'
$attributes.Add("Title", $title)
$attributes.Add("xmlns", "http://schemas.microsoft.com/winfx/2006/xaml/presentation")
$attributes.Add("xmlns:x", "http://schemas.microsoft.com/winfx/2006/xaml")
if ($height -gt -1) { $attributes.Add("Height", $height) }
if ($width -gt -1) { $attributes.Add("Width", $width) }
if ($top -gt -1) { $attributes.Add("Top", $top) }
if ($left -gt -1) { $attributes.Add("Left", $left) }
# Create the Window XamlElement
$windowXaml = CreateElement -tag "Window" -attr $attributes
$global:Data.DOM = $windowXaml
AddChild -parent $windowXaml -child $content
# Generate the XAML string
$windowXamlString = $windowXaml.ToXamlString()
# If in debug mode, return the XAML string
if ($debug) {
return $windowXamlString
}
try {
# Parse the XAML string and create the Window
[xml]$xml = $windowXamlString
$reader = (New-Object System.Xml.XmlNodeReader $xml)
$global:Data.Window = [Windows.Markup.XamlReader]::Load($reader)
} catch {
Write-Error "Error rendering the XAML window: $_"
Write-Host $windowXamlString
return $false
}
# Bind events
BindEvents -data $global:Data
# Run the optional onLoad function if provided
if ($onLoadFunction) {
& $onLoadFunction $global:Data
}
# Bind data
BindData -data $global:Data
try {
# Show the window as a modal dialog
$global:Data.Window.ShowDialog()
} catch {
Write-Error "Error Showing the XAML window: $_"
Write-Host $windowXamlString
return $false
}
# Return the Data object if needed for further processing
return $global:Data
}
function BindEvents {
param ($data)
$global:handlers = @{}
# Recursive function to bind events based on the virtual DOM
function TraverseAndBind {
param (
[XamlElement]$virtualElement
)
foreach ($key in $virtualElement.Attributes.Keys) {
if ($key.StartsWith("ez")) {
# Extract event name (e.g., "Click" from "ezClick")
$eventName = $key.Substring(2) # Remove "ez" from the key to get event name
# Make sure there is no case mismatch by converting to proper case
$eventName = $eventName.Trim() # Remove any extra spaces
$eventName = $eventName.Substring(0, 1).ToUpper() + $eventName.Substring(1).ToLower() # Standardize case
$handlerName = $virtualElement.Attributes[$key] # Function name as string
if ($eventName -ne "Bind"){
# Resolve the function by name
$handler = Get-Command -Name $handlerName -ErrorAction SilentlyContinue
if ($handler) {
$handler = [scriptblock]::Create($handler.ScriptBlock.ToString())
# Find the real element by matching the name
$name = $virtualElement.Attributes["Name"]
$realElement = $data.Window.FindName($name)
if ($realElement) {
# Store the handler in the global list (associating the element with the handler)
$global:handlers[$name] = $handler
switch ($eventname) {
"Click" {
# Attach the event handler to the Click event
$realElement.Add_Click({
param ($sender, $e)
$elementName = $sender.Name
# Look up the handler based on the sender's name
$handlerFromList = $global:handlers[$elementName]
if ($handlerFromList) {
$handlerFromList.Invoke($sender, $e, $data)
BindData -data $data
} else {
Write-Warning "Handler not found for $elementName"
}
})
}
"KeyDown" {
# Attach the event handler to the KeyDown event
$realElement.Add_KeyDown({
param ($sender, $e)
$elementName = $sender.Name
# Look up the handler based on the sender's name
$handlerFromList = $global:handlers[$elementName]
if ($handlerFromList) {
$handlerFromList.Invoke($sender, $e, $data)
BindData -data $data
} else {
Write-Warning "Handler not found for $elementName"
}
})
}
"TextChanged" {
# Attach the event handler to the TextChanged event
$realElement.Add_TextChanged({
param ($sender, $e)
$elementName = $sender.Name
# Look up the handler based on the sender's name
$handlerFromList = $global:handlers[$elementName]
if ($handlerFromList) {
$handlerFromList.Invoke($sender, $e, $data)
BindData -data $data
} else {
Write-Warning "Handler not found for $elementName"
}
})
}
"MouseDown" {
# Attach the event handler to the TextChanged event
$realElement.Add_MouseDown({
param ($sender, $e)
$elementName = $sender.Name
# Look up the handler based on the sender's name
$handlerFromList = $global:handlers[$elementName]
if ($handlerFromList) {
$handlerFromList.Invoke($sender, $e, $data)
BindData -data $data
} else {
Write-Warning "Handler not found for $elementName"
}
})
}
}
} else {
Write-Warning "Real element not found for: $($virtualElement.Attributes["Name"])"
}
} else {
Write-Warning "Handler function not found for: $handlerName"
}
} else {
if ($virtualElement.TagName -eq "TextBox") { # only elements that can have a TextChanged event
# Find the real element by matching the name
$name = $virtualElement.Attributes["Name"]
$realElement = $dataWindow.FindName($name)
if ($realElement) {
#add a text changed event for bining data the other way
$realElement.Add_TextChanged({
param ($sender, $e)
$elementName = $sender.Name
$element = $data.Window.FindName($elementName)
# update the data
$data[$handlerName] = $element.text
})
} else {
#Write-Warning "Real element not found for: $($virtualElement.Attributes["Name"])"
}
}
}
}
}
# Recurse for child elements
foreach ($child in $virtualElement.Children) {
TraverseAndBind -virtualElement $child
}
}
# Start traversal with the root virtual element
TraverseAndBind -virtualElement $data.DOM
}
function BindData {
param ($data)
# Recursive function to bind data based on the virtual DOM
function TraverseAndBind {
param (
[XamlElement]$virtualElement
)
foreach ($key in $virtualElement.Attributes.Keys) {
if ($key.StartsWith("ez")) {
# Extract event name (e.g., "Click" from "ezClick")
$eventName = $key.Substring(2) # Remove "ez" from the key to get event name
if($eventname -eq "Bind") {
# Make sure there is no case mismatch by converting to proper case
$eventName = $eventName.Trim() # Remove any extra spaces
$eventName = $eventName.Substring(0, 1).ToUpper() + $eventName.Substring(1).ToLower() # Standardize case
$dataKey = $virtualElement.Attributes[$key] # dataKey as string
$expression = "`$global:Data.$dataKey"
# Evaluate the string using Invoke-Expression
$dataValue = Invoke-Expression -Command $expression
# Find the real element by matching the name
$name = $virtualElement.Attributes["Name"]
$realElement = $data.Window.FindName($name)
if ($realElement -and $realElement -is [System.Windows.Controls.TextBox] -or $realElement -is [System.Windows.Controls.TextBlock]) {
if ($dataValue -eq " " -or $dataValue -eq $null -or $dataValue -eq "") {$dataValue = " " }
$dataValue = [String]$dataValue.ToString()
$realElement.Text = [String]$dataValue.ToString()
} else {
Write-Warning "Real element not found for: $($virtualElement.Attributes["Name"])"
}
}
}
}
# Recurse for child elements
foreach ($child in $virtualElement.Children) {
TraverseAndBind -virtualElement $child
}
}
# Start traversal with the root virtual element
TraverseAndBind -virtualElement $data.DOM
}
function CreateElement{
param (
[string]$tag,
[System.Collections.Generic.Dictionary[String, String]]$attr = @{},
[bool]$selfClosing = $false
)
$el = [XamlElement]::new($tag)
$el.initialize($attr,$selfClosing)
return $el
}
Example use:
# Test EzClick an Data Binding
function Increment{
param ($sender, $eventArgs, $data)
$data["number"]++
# or $data.number works too
# $data is like the Angular.js $Scope object.
# I have added in the real window and our virtual DOM here
# DOM is your top most parent in my XAMLElement class.
# This 'Element' has a references to it's parent and children
# and it contains it's attributes.
# This was converted to the XAML string for the window.
# $data.Window and $data.DOM.
}
function Test-EzClick {
# Create a TextBlock for displaying the counter
$textBlockAttributes = New-Object 'System.Collections.Generic.Dictionary[String, String]'
$textBlockAttributes.Add("Name", "CounterText")
$textBlockAttributes.Add("ezBind", "number")
$textBlock = CreateElement -tag "TextBlock" -attr $textBlockAttributes -selfClosing $true
# Create a Button with an ezClick handler
$buttonAttributes = New-Object 'System.Collections.Generic.Dictionary[String, String]'
$buttonAttributes.Add("Name", "IncrementButton")
$buttonAttributes.Add("Content", "Increment")
$buttonAttributes.Add("ezClick", "Increment")
$button = CreateElement -tag "Button" -attr $buttonAttributes -selfClosing $true
# Add TextBlock and Button to a StackPanel
$stackPanel = CreateElement -tag "StackPanel"
AddChild -parent $stackPanel -child $textBlock
AddChild -parent $stackPanel -child $button
$initialData = New-Object 'System.Collections.Generic.Dictionary[String, Object]'
$initialData.Add("number", 0)
# optional:
$onLoadFunction = {
param ($data)
# you can do stuff here to,
# setup data here as it is bound after this is called
}
try {
# Load the window
$data = LoadWindow-XAML -content $stackPanel -title "Test EzClick" -height 300 -width 400 -data $initialData -onLoadFunction $onLoadFunction
if (-not $data) {
Write-Host "Test-OpenWindow Failed unexpectedly."
return $false
}
Write-Host "Test-EzClick and Data Binding Passed"
} catch {
Write-Host "Test-OpenWindow Failed: $_"
}
}