wpfpowershellxaml

How can I fix using Power Shell and WPF windows, setting the text property of a textblock is failing with "Array index was null" with a complex DOM


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.


Solution

  • 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 "&nbsp" -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: $_"
        }
    }