framework.ps1

<#
    .Synopsis
        Kick off a new window from PowerShell of a Visual Studio created XAML file and attach handlers - the easy way 🚀
 
    .Description
        Version 1.1.0
        License MIT
        (c) Nabil Redmann 2019 - 2026
        Supports: Powershell 5+ (including pwsh 7)
 
    .LINK
        https://gist.github.com/BananaAcid/0484b11a03c03f172740096e213d1d82
 
    .Notes
        based on https://stackoverflow.com/a/52416973/1644202
#>


[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingCmdletAliases', '', Scope = 'Function', Target = '*')]
Param()

$script:knownEvents = @(
    # Some major events. There are way more.
    # Window
    "Initialized", "Loaded", "Unloaded", "Activated", "Closed", "Closing", "GotFocus", "LostFocus", "SizeChanged", "GotFocus", "LostFocus",
    # Checkbox, Buttons etc
    "Click", "Checked", "MouseDoubleClick", "MouseEnter", "MouseLeave", "MouseDown", "MouseUp", "MouseLeftButtonDown", "MouseLeftButtonUp", "MouseRightButtonDown", "MouseRightButtonUp", "MouseMove", "MouseWheel",
    # Text
    "KeyDown", "KeyUp", "PreviewKeyDown", "PreviewKeyUp",
    # Combobox
    "SelectionChanged",
    # Drag and Drop
    "Drop", "DragEnter", "DragLeave",
    # Change events
    "TextChanged", "SelectionChanged", "Checked", "Unchecked", "Collapsed", "Expanded"
);

Function Add-KnownEvents
{
    Param ( [String[]]$EventNames )

    $script:knownEvents += $EventNames
}

Function Set-KnownEvents
{
    Param ( [String[]]$EventNames )

    $script:knownEvents = $EventNames
}

Function Get-KnownEvents
{
    return $script:knownEvents
}

Function New-Window
{
    Param
    (
        [Parameter(ValueFromPipeline=$True,Mandatory=$True,Position=0)]$xamlFile,
        [Parameter(Mandatory=$False,Position=1)][Alias("Handlers")][AllowNull()]$HandlersScriptBlockOrFile = $Null
    )

    $xamlString = Get-Content -Path $xamlFile
    return New-WindowXamlString $xamlString -Handlers $HandlersScriptBlockOrFile
}

Function New-WindowUrl
{
    Param
    (
        [Parameter(ValueFromPipeline=$True,Mandatory=$True,Position=0)]$url,
        [Parameter(Mandatory=$False,Position=1)][Alias("Handlers")][AllowNull()]$HandlersScriptBlockOrFile = $Null
    )

    $xamlString = (New-Object System.Net.WebClient).DownloadString($url)
    return New-WindowXamlString $xamlString -Handlers $HandlersScriptBlockOrFile
}

Function New-WindowXamlString
{
    Param
    (
        [Parameter(ValueFromPipeline=$True,Mandatory=$True,Position=0)]$xamlString,
        [Parameter(Mandatory=$False,Position=1)][Alias("Handlers")][AllowNull()]$HandlersScriptBlockOrFile = $Null
    )

    Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase

    If (!$script:knownEvents) { $script:knownEvents = [String[]]@() }

    # prepare window xaml: replace <Win> with <Window>, also allow <Window.Resources>
    $xamlString = $xamlString -replace '<(/?)Win[a-zA-Z]*','<$1Window'

    # store window class
    $windowClass = $null
    $match = [Regex]::Match($xamlString, '^[\s]*<Window[^>]*(x:Class="([^"]*)")', [System.Text.RegularExpressions.RegexOptions]::Multiline)
    
    # actually not a problem ... do not exit
    if ($match.Success -eq $False) {
        if ($DebugPreference -ne 'SilentlyContinue') { Write-Host '[XAML.GUI] XAML does not contain a <Window x:Class="..."> which is not optimal, if you have multiple windows.' }
        # Exit 4
    }

    if ($match.Captures.Groups.Count -eq 3) {
        
        $windowClass = $match.Captures.Groups[2].Value
        
        if ($windowClass) {
            $xamlString = $xamlString -replace $match.Captures.Groups[1].Value,''
        }
    }

    #===========================================================================
    # fix XAML markup for powershell
    #===========================================================================
    $xamlString = $xamlString -replace 'mc:Ignorable="d"','' -replace "x:N",'N' -replace "x:n",'N' -replace "x:Bind", "Binding"
    try {
        [xml]$XAML = $xamlString
    }
    catch {
        if ($DebugPreference -ne 'SilentlyContinue') { Write-Host "[XAML.GUI] XAML parsing error" -ForegroundColor Red }
        if ($DebugPreference -ne 'SilentlyContinue') { Write-Host $_.Exception.message -ForegroundColor Red }
        Exit 5
    }

    #===========================================================================
    # storing events
    #===========================================================================
    $generatedCount = @{} # generated name = int
    # Should generate a name based on its outer XML (complete xml line), that is unique and persistent (unless the line is changed)
    Function Get-CRC32Style
    {
        Param ( [string] $inputString )

        [int64]$hash = 0
        [uint32]$mask = 4294967295  # This avoids the "hex-is-negative" bug in PS 5.1 (do not use 0xFFFFFFFF)
        foreach ($char in $inputString.ToCharArray()) {
            $hash = ([long]($hash * 31) + [int][char]$char) -band $mask
        }
        $hashResult = "{0:X8}" -f $hash

        # count generated identical names
        if ($generatedCount[$hashResult]) {
            $generatedCount[$hashResult]++
        } else {
            $generatedCount[$hashResult] = 1
        }

        return [string]$hashResult + "_" + $generatedCount[$hashResult]
    }

    $eventElements = @()
    Foreach ($eventName in $script:knownEvents) {
        Foreach ($node in $XAML.SelectNodes("//*[@$eventName]")) {
            If (!$node.Attributes['Name']) {
                # Needed, because XAML elements will later be matched to the pure XML by name to append the event to the parsed XAML Element
                if ($DebugPreference -ne 'SilentlyContinue') { Write-Host "[XAML.GUI] Adding NAME to element with event" $node.OuterXml -ForegroundColor Yellow }
                $name = $node.LocalName + "_" + $(Get-CRC32Style $node.OuterXml) -replace "-","_" #$(Get-Random) $(New-Guid)
                $node.SetAttribute("Name", $name)

                if ($DebugPreference -ne 'SilentlyContinue') { Write-Host "[XAML.GUI] ... Applied new generated Name = $($node.Name)" -ForegroundColor Yellow }

                <#*NONAME -- works now above (using Get-CRC32Style)
                if ($DebugPreference -ne 'SilentlyContinue') { Write-Host "[XAML.GUI] Name not set for element $($node.Name) with event $eventName and function $($node.$eventName)" -ForegroundColor Red }
                if ($DebugPreference -ne 'SilentlyContinue') { Write-Host " " $node.OuterXml -ForegroundColor Red }
                # Exit 3
                #>

            }

            #*NONAME If ($node.Attributes['Name']) {
                $eventElements += @{
                    e = $node
                    ev = $eventName
                    fn = $node.$eventName
                    name = $node.Attributes['Name'].Value
                }
            #}

            # PS does not handle events, need to be removed, but were added to the elements collection
            $node.RemoveAttribute($eventName)
        }
    }


    #===========================================================================
    #Read XAML
    #===========================================================================
    $reader = (New-Object System.Xml.XmlNodeReader $XAML)

    try {
        $Form = [Windows.Markup.XamlReader]::Load($reader)
    }
    catch [System.Management.Automation.MethodInvocationException] {
        Write-Warning "[XAML.GUI] We ran into a problem with the XAML code. Check the syntax for this control..."
        if ($DebugPreference -ne 'SilentlyContinue') { Write-Host $error[0].Exception.Message -ForegroundColor Red }
        Exit 1
    }
    catch {#if it broke some other way
        if ($DebugPreference -ne 'SilentlyContinue') { Write-Host "[XAML.GUI] Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed." }
        Exit 2
    }


    #===========================================================================
    # attaching click handlers
    #===========================================================================
    if ($DebugPreference -ne 'SilentlyContinue') { Write-Host "[XAML.GUI] Window class is " $(if ($windowClass) { $windowClass } else { "not set" }) }

    # source handlers from scriptblock if supplied
    if ([string]::IsNullOrWhiteSpace($HandlersScriptBlockOrFile)) { <# param not used. #> }
    elseif (($HandlersScriptBlockOrFile).getType().Name -eq 'ScriptBlock') {
        # Import from scriptblock
        # 1. Execute it locally so the module can use them internally if needed
        . $HandlersScriptBlockOrFile

        # 2. Use the AST (Abstract Syntax Tree) to find all functions defined in the block
        $funcs = $HandlersScriptBlockOrFile.Ast.FindAll({
            $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst]
        }, $true)
        # 3. Force-inject them into the Script scope (Better globally?)
        $funcs |% { Set-Item -Path "function:script:$($_.Name)" -Value $_.Body.GetScriptBlock() }
    }
    elseif (($HandlersScriptBlockOrFile).getType().Name -eq 'String' -and (Test-Path (Join-Path $PWD $HandlersScriptBlockOrFile) -PathType Leaf)) {
        # Import file if it exists
        . (Join-Path $PWD $HandlersScriptBlockOrFile)
    }
    else {
        Write-Error "Handlers not found: ", $HandlersScriptBlockOrFile
        Exit 6
    }


    Foreach ($evData in $eventElements) {
        $fnName = $evData.fn
        if ($windowClass) {
            $fnName = "$windowClass.$fnName"
        }

        #$fns = Get-ChildItem function: | Where-Object { $_.Name -like $fnName } # function namespace.windowclassname.function_name($Sender, $EventArgs)

        $fns = Get-Item function:$fnName -ErrorAction SilentlyContinue
        if (!$fns) { $fns = Get-Item function:global:$fnName -ErrorAction SilentlyContinue; }

        if ($evData.name) { $name = $evData.name } else { $name = '-no name-' }
        If (!$fns.Count) {
            if ($DebugPreference -ne 'SilentlyContinue') { Write-Host "[XAML.GUI] Linking event $($evData.ev) on element $name -> function $fnName(`$Sender,`$EventArgs) FAILED: no handler" -ForegroundColor Red }
        }
        else {
            if ($DebugPreference -ne 'SilentlyContinue') { Write-Host "[XAML.GUI] Linking event $($evData.ev) on element $name -> function $fnName(`$Sender,`$EventArgs)" -ForegroundColor Green }

            Invoke-Expression ('$Form.FindName($evData.name).Add_' + $evData.ev + '( $fns[0].ScriptBlock )')
        }
    }

    #===========================================================================
    # Store named elements to be acessable through $Elements
    #===========================================================================
    $Elements = @{}
    #$XAML.SelectNodes("//*[@Name]") | %{Set-Variable -Name "GUI_$($_.Name)" -Value $Form.FindName($_.Name)}
    $XAML.SelectNodes("//*[@Name]") |% { $Elements[$_.Name] = $Form.FindName($_.Name) }

    $Elements["_Window"] = $Form

    return $Elements,$Form
}

Function Show-Window
{
    Param
    (
        [Parameter(ValueFromPipeline=$True,Mandatory=$True)] $window,
        $dialog = $True
    )

    $win = $window
    if ($window._Window) {
        $win = $window._Window
    }

    if ($dialog) {
        $win.ShowDialog() | Out-Null
    }
    else {
        $win.Show() | Out-Null
    }
    $global:win = $win
}