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
#>


# Enable visual styles, in case there will be a message box or alike
Function Enable-VisualStyles
{
    Add-Type -AssemblyName System.Drawing,System.Windows.Forms
    [System.Windows.Forms.Application]::EnableVisualStyles()
}


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
}


$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 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 Green }
                $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 Green }

                <#*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
        . $HandlersScriptBlockOrFile
    }
    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)

        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)" }

            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
}


# .Net methods for hiding/showing the console in the background, https://stackoverflow.com/a/40621143/1644202
Add-Type -Name Window -Namespace XAML_Gui_Console -MemberDefinition '
[DllImport("Kernel32.dll")]
public static extern IntPtr GetConsoleWindow();
 
[DllImport("user32.dll")]
public static extern bool ShowWindow(IntPtr hWnd, Int32 nCmdShow);
'


Function Show-Console
{
    Param( [Parameter(Mandatory=$false)]$state=4 )

    $consolePtr = [XAML_Gui_Console.Window]::GetConsoleWindow()

    # https://docs.microsoft.com/en-us/windows/desktop/api/winuser/nf-winuser-showwindow
    # Hide = 0,
    # ShowNormal = 1,
    # ShowMinimized = 2,
    # ShowMaximized = 3,
    # Maximize = 3,
    # ShowNormalNoActivate = 4,
    # Show = 5,
    # Minimize = 6,
    # ShowMinNoActivate = 7,
    # ShowNoActivate = 8,
    # Restore = 9,
    # ShowDefault = 10,
    # ForceMinimized = 11

    [XAML_Gui_Console.Window]::ShowWindow($consolePtr, $state)
}

Function Hide-Console
{
    # return true/false
    $consolePtr = [XAML_Gui_Console.Window]::GetConsoleWindow()
    #0 hide
    [XAML_Gui_Console.Window]::ShowWindow($consolePtr, 0)
}




Function New-ClonedObjectStruct
{
    param( [PSCustomObject]$srcObject )

    return $srcObject.psobject.copy() # | ConvertTo-Json -depth 100 | ConvertFrom-Json
}

Function Get-PropOrNull
{
    param( $thing, [string]$prop )

    Try {
        $thing.$prop
    } Catch {}
}


# https://gist.github.com/nwolverson/8003100
Function Get-VisualChildren($item)
{
    for ($i = 0; $i -lt [System.Windows.Media.VisualTreeHelper]::GetChildrenCount($item); $i++) {
        $child = [System.Windows.Media.VisualTreeHelper]::GetChild($item, $i)
        Get-VisualChildren($child)
    }
    $item
}

Function Get-CellItemByName
{
    Param
    (
        [ref]$Parent,
        $ItemNo,
        $Name
    )

    [System.Windows.Forms.Application]::DoEvents()

    if ($DebugPreference -ne 'SilentlyContinue') { $Parent.value | Write-Host }

    $items = (Get-VisualChildren ($Parent.Value) |? { $_.GetType().Name -eq "ListViewItem" })

    if ($DebugPreference -ne 'SilentlyContinue') { $items | Write-Host }
    if ($DebugPreference -ne 'SilentlyContinue') { $ItemNo | Write-Host }
    if ($DebugPreference -ne 'SilentlyContinue') { $items[$ItemNo] | Write-Host }

    if ($DebugPreference -ne 'SilentlyContinue') { Get-VisualChildren $items[$ItemNo] | Write-Host }


    return (Get-VisualChildren $items[$ItemNo] |? { $_.Name -eq $Name} | Select-Object -First 1)
}


Function Wait-AwaitJob
{
    Param ( [Parameter(Mandatory=$true)]$job )

    while ($job.state -eq 'Running') {
        [System.Windows.Forms.Application]::DoEvents()  # keep form responsive
    }

    # Captures and throws any exception in the job output -> '-ErrorAction stop' --- otherwise returns result
    return Receive-Job $job -ErrorAction Continue
}

# start and await a job
Function Start-AwaitJob
{
    Param
    (
        [Parameter(Mandatory=$true)] $scriptBlock,
        [Parameter(Mandatory=$false)] $ArgumentList=@(),
        [Parameter(Mandatory=$false)] $Dir, # sets the current working directory (use it to set the subfolder) !
        [Parameter(Mandatory=$false)] $await = $True
    )

    $useDir = $PWD
    If ($Dir) { $useDir = Resolve-Path $Dir }

    $job = Start-Job -Init ([ScriptBlock]::Create("Set-Location '$($useDir -replace "'", "''")'")) -ScriptBlock $scriptBlock -ArgumentList $ArgumentList

    If ($await) {
        return Wait-AwaitJob $job    
    }
    Else {
        return $job    
    }
}


Function Show-MessageBox
{
    Param
    (
        [string]$Message = "This is a default Message.", 
        [string]$Title = "Default Title", 
        [ValidateSet("Asterisk","Error","Exclamation","Hand","Information","None","Question","Stop","Warning")] 
        [string]$Type = "Error", 
        [ValidateSet("AbortRetryIgnore","OK","OKCancel","RetryCancel","YesNo","YesNoCancel")] 
        [string]$Buttons = "OK" 
    )

    Add-Type -AssemblyName System.Windows.Forms
    $MsgBoxResult = [System.Windows.Forms.MessageBox]::Show($Message,$Title,[Windows.Forms.MessageBoxButtons]::$Buttons,[Windows.Forms.MessageBoxIcon]::$Type) 

    Return $MsgBoxResult 
}


Function Invoke-BalloonTip
{
    <#
    .Synopsis
        Display a balloon tip message in the system tray.
    .Description
        This function displays a user-defined message as a balloon popup in the system tray. This function
        requires Windows Vista or later.
    .Parameter Message
        The message text you want to display. Recommended to keep it short and simple.
    .Parameter Title
        The title for the message balloon.
    .Parameter MessageType
        The type of message. This value determines what type of icon to display. Valid values are
    .Parameter SysTrayIcon
        The path to a file that you will use as the system tray icon. Default is the PowerShell ISE icon.
    .Parameter Duration
        The number of seconds to display the balloon popup. The default is 1000.
    .Inputs
        None
    .Outputs
        None
    .Notes
         NAME: Invoke-BalloonTip
         VERSION: 1.0
         AUTHOR: Boe Prox
    #>

    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$True,HelpMessage="The message text to display. Keep it short and simple.")]
        [string]$Message,
        [Parameter(HelpMessage="The message title")]
        [string]$Title="Attention $env:username",
        [Parameter(HelpMessage="The message type: Info,Error,Warning,None")]
        [System.Windows.Forms.ToolTipIcon]$MessageType="Info",
     
        [Parameter(HelpMessage="The path to a file to use its icon in the system tray")]
        [string]$SysTrayIconPath='',
        [Parameter(HelpMessage="The number of milliseconds to display the message.")]
        [int]$Duration=1000
    )
    
    Add-Type -AssemblyName System.Windows.Forms

    If (-NOT $global:balloon) {
        $global:balloon = New-Object System.Windows.Forms.NotifyIcon
        #Mouse double click on icon to dispose
        [void](Register-ObjectEvent -InputObject $balloon -EventName MouseDoubleClick -SourceIdentifier IconClicked -Action {
            #Perform cleanup actions on balloon tip
            Write-Verbose 'Disposing of balloon'
            $global:balloon.dispose()
            Unregister-Event -SourceIdentifier IconClicked
            Remove-Job -Name IconClicked
            Remove-Variable -Name balloon -Scope Global
        })
    }

    #Need an icon for the tray
    If ($SysTrayIconPath -eq "") {
        $SysTrayIconPath = Get-Process -id $PID | Select-Object -ExpandProperty Path
    }

    #Extract the icon from the file
    $balloon.Icon = [System.Drawing.Icon]::ExtractAssociatedIcon($SysTrayIconPath)
    #Can only use certain TipIcons: [System.Windows.Forms.ToolTipIcon] | Get-Member -Static -Type Property
    $balloon.BalloonTipIcon  = [System.Windows.Forms.ToolTipIcon]$MessageType
    $balloon.BalloonTipText  = $Message
    $balloon.BalloonTipTitle = $Title
    $balloon.Visible = $true
    #Display the tip and specify in milliseconds on how long balloon will stay visible
    $balloon.ShowBalloonTip($Duration)
    Write-Verbose "Ending function"
}


Function Select-FolderDialog
{
    Param
    (
        [string]$Title = "Select a Folder",
        [string]$Description = "",
        [string]$Path = [Environment]::GetFolderPath("Desktop"),
        [string]$SelectedPath = "",
        [boolean]$Multiselect = $false,
        [boolean]$ShowNewFolderButton = $false
    )

    Add-Type -AssemblyName System.Windows.Forms  

    if ($Title -ne "" -and $Description -eq "") {
        $Description = $Title
        $UseDescriptionForTitle = $true
    }
    else {
        $UseDescriptionForTitle = $false
    }

    $objForm = New-Object System.Windows.Forms.FolderBrowserDialog -Property @{
        # https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.folderbrowserdialog?view=windowsdesktop-10.0
        InitialDirectory = $Path
        SelectedPath = $SelectedPath
        Description = $Description
        Multiselect = $Multiselect
        ShowNewFolderButton = $ShowNewFolderButton

        UseDescriptionForTitle = $UseDescriptionForTitle
    }

    $Show = $objForm.ShowDialog()

    If ($Show -eq "OK") {
        Return $objForm.SelectedPaths
    }
    Else {
        Write-Verbose "Select-FolderDialog cancelled by user."
        Return ''
    }
}
# $folder = Select-FolderDialog # the variable contains user folder selection

Function Select-FileDialog
{
    Param
    (
        [string]$Title="Select Folder",
        [string]$Path="Desktop",
        [string]$Filter='Images (*.jpg, *.png)|*.jpg;*.png',
        [boolean]$Multiselect=$false
    )

    Add-Type -AssemblyName System.Windows.Forms

    $FileBrowser = New-Object System.Windows.Forms.OpenFileDialog -Property @{
        Filter = $Filter # Specified file types
        Multiselect = $Multiselect # Multiple files can be chosen
        Title = $Title
        InitialDirectory = $Path
    }
 
    [void]$FileBrowser.ShowDialog()

    If ($FileBrowser.FileNames -like "*\*") {
        # even with multiselect, if only 1 file was selected, it will NOT return an array
        Return $FileBrowser.FileNames
    }
    Else {
        #if ($DebugPreference -ne 'SilentlyContinue') { Write-Host "Cancelled by user" }
        Return ""
    }
}