Powershell GUI fronted (WPF) to run categorized console scripts

โŒˆโŒ‹ โŽ‡ branch:  ClickyColoury


Artifact [dc92700bff]

Artifact dc92700bff6f0248e772aaa8c7fcb3fc9ff5eb04:

  • File modules/guimenu.psm1 — part of check-in [8e78d588f1] at 2018-05-18 17:57:05 on branch trunk — Simpler Start-Process doesn't work. PS does not escape double quotes in -ArgumentList properly, nor allows escaping with standard CMD ^ caret etc. (user: mario size: 48649)

# api: ps
# title: GUI handling, WPF + WinForms
# description: WinForm and WPF interaction, GUI and thread handling
# depends: menu, clipboard, sys:presentationframework, sys:system.windows.forms, sys:system.drawing
# doc: http://fossil.include-once.org/clickycoloury/wiki/W
# version: 1.2.1
# license: MITL
# type: functions
# category: ui
# config:
#   { name: cfg.threading, type: bool, value: 1, description: Enable threading/runspaces for GUI/WPF version }
#   { name: cfg.autoclear, type: int, value: 300, description: Clear output box after N seconds. }
#   { name: cfg.noheader, type: bool, value: 0, description: Disable script info header in output. }
#   { name: cfg.theme, type: select, select: "dark|bunti|beta", value: dark, description: XAML theme file. }
# status: stable
# priority: core
#
# Handles $menu list in WPF window. Creates widgets and menu entries
# for plugin list / $menu meta data.
#   ยท `type: inline` is the implied default, renders output in TextBlock 
#   ยท `type: cli` or `window` plugins are run in a separate window
#   ยท `hidden: 1` tools only show up in menus
#   ยท `keycode:` is used for shortcuts; the CLI `key:` regex ignored
#   ยท `type: init-gui` plugins are run once during GUI construction
#   ยท Whereas `type: init` execute in the main/script RunSpace
#
# The responsive UI is achieved through:
#   ยท A new runspace for the GUI and a trivial message queue in $GUI.tasks.
#   ยท Main loop simply holds the window open, then executes $GUI.tasks events.
#   ยท That event queue contains the verbatim entries from $menu (mostly).
#   ยท Pipes them through `Run-GuiTask` (Should ideally be identical to the
#     one in menu.psm1, but needs a few customizations here.)
#
# All widget interactions are confined to the WPF runspace/thread.
#   ยท WPF interaction through `$GUI` would often hang both processes.
#   ยท Thus `Out-Gui` manages output, and a few helpers for title, var fetching.
#   ยท Toolbar actions run entirely in the GUI thread, thus can freeze UI a bit.
#
# Scripts/tools should work identically as for the CLI version:
#   ยท Aliases for `Write-Host` and `Read-Host` should make it transparent.
#   ยท However simple console output (typically to the stdout stream) will
#     have to be pipe-captured.
#   ยท Order of pipe/stream output and Write-Host calls do not intermix still.
#


#-- register libs
Add-Type -AN PresentationCore, PresentationFramework, WindowsBase
Add-Type -AN System.Drawing, System.Windows.Forms, Microsoft.VisualBasic
[System.Windows.Forms.Application]::EnableVisualStyles()
[System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($true)


#-- init a few local vars
#   - $GUI, $last_output etc. get created in Start-Win
#   - New-GuiThread inherits $menu,$cfg,$plugins from global
$ModuleDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$BaseDir = Split-Path -Parent $ModuleDir

#-- standard UI input widgets and handler names
$cfg.standard_fields = @{
    machine = "Computer"
    username = "Username"
    #bulkcsv = "BulkCSV"
}
$cfg.standard_aliases = @{
    username = "^(?i)[$]?(AD[-\s]?)?(User|Account)(?:[-\s]?Name)?\s*[=:?]*\s*$"
    machine =  "^(?i)[$]?(Computer|Machine|PC|Host)([-\s]?Name)?\s*[=:?]*\s*$"
    #bulkcsv = "^(?i)[$]?(Bulk|bulk.?csv|bulkfn|list|CSV)" # obsolete
}
$cfg.standard_actions = @{   # Add-ButtonHooks
    Cpy = 'Set-Clipboard $GUI.{0}.Text'
    Clr = '$GUI.{0}.Text = ""; $GUI.{0}.Background = "White"'
    "" = '$GUI.{0}.Text = (Get-Clipboard).trim()'
}



#-- WPF/WinForms widget wrapper
function W {
    <#
      .SYNOPSIS
         Convenient wrapper to create WPF or WinForms widgets.
      .DESCRIPTION
         Allows to instantiate and modify WPF/XAML or WinForms UI widgets.
         The $prop hash unifies property assignments and widget method calls.
         Notably this implementation is a bit slow, due to this abstraction and
         probing different object trees.          
      .PARAMERER type
         Widget type (e.g. "Button" or "Label") or existing $w widget object.
      .PARAMETER prop
         HashTable listing widget attributes or methods to call:
           @{Color="Red"; Text="Hello World"; Add=$other_widget}
         Property+method names depend on whether WPF or WinForms widgets are used. 
      .PARAMETER base
         Defaults to "Controls" for WPF widgets, but can be set to "Forms" for WF.
      .OUTPUTS
         Returns the instantiated Widget.
      .EXAMPLE
         Create a button:
           $w_btn = W Button @{Content="Text"; Border=2; add_Click=$cb}
         Add child widgets per list:
           $w_grd = W Grid @{Add=$w1, $w2, $w3}
         Or chain creation of multiple widgets:
           $w_all = W StackPanel {Spacing=5; Add=(
              (W Button @{Content="OK"}),
              (W Label @{Content="Really?"})
           )}
         The nesting gets confusing for WPF, but often simplifies WinForm structures.
      .NOTES
         The shortcut method `WF` creates WinForms widgets, whereas `WD` is
         for TextBlock/document inlines.
    #>
    [CmdletBinding()]
    Param($type = "Button", $prop = @{}, $Base="Controls")

    #-- new object
    if ($type.getType().Name -eq "String") {
        $w = New-Object System.Windows.$Base.$type
    }
    else {
        $w = $type      
    }
    #@bug on FOOTERM01 w/ PS 3.0
    if (($PSVersionTable.PSVersion.Major -eq 3) -and ($w -is [System.Windows.Thickness])) { return $w; }

    #-- apply options+methods
    $prop.keys | % {
        $key = $_
        $val = $prop[$_]
        if ($pt = ($w | Get-Member -Force -Name $key)) { $pt = $pt.MemberType }

        #-- properties
        if ($pt -eq "Property") {
            if (($Base -eq "Forms") -and (@("Size" , "ItemSize", "Location") -contains $key)) {
                $val = New-Object System.Drawing.Size($val[0], $val[1])
            }
            $w.$key = $val
        }
        #-- check for methods in widget and common subcontainers
        else {
            try {
                ForEach ($obj in @($w, $w.Children, $w.Child, $w.Container, $w.Controls)) {
                    if ($obj.psobject -and $obj.psobject.methods.match($key) -and $obj.$key) {
                        ([array]$val) | ? { $obj.$key.Invoke } | % { $obj.$key.Invoke($_) } | Out-Null
                        break
                    }
                }
            }
            catch [ArgumentException] { # Augment failures with useful context.
                Write-Error "Failed calling '$key' on '$obj' with argument '$val'`r`n"
                #Write-Error $PSItem.Exception.StackTrace
            }
        }
    }
    return $w
}

#-- WinForms version
function WF {
    Param($type, $prop, $add=@{}, $click=$null)
    if ($add) { $prop.add = $add }
    if ($click) { $prop.add_click = $click }
    W -Base Forms -Type $type -Prop $prop
}

#-- Document "widgets"
function WD {
    Param($type, $prop=@{})
    W -Base Documents -Type $type -Prop $prop
}


#-- WPF main window
function WPF-Window {
    <#
      .SYNOPSIS
         Loads XAML file `wpf.xaml` from same directory as this module.
      .DESCRIPTION
         Also populates $GUI.$_ for a select few global widget names.
      .NOTES
         Workaround img\ cache dir not enabled.
    #>
    Param($main="kbt5.xaml", $theme=$cfg.theme)

    #-- ImgDir
    $ImgDir = $BaseDir
    #if (Test-Path "$($env:APPDATA)\multitool\img") { $ImgDir = "$($env:APPDATA)/multitool" }

    #-- load + patch
    $xaml = (Get-Content "$BaseDir/modules/$main" | Out-String)
    $xaml = $xaml -replace "e:/","$ImgDir/"
    if (Test-Path ($fn = "modules/theme.$theme.xaml")) { $xaml = $xaml -replace "modules/theme.\w+.xaml", $fn }
    $w = [Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader ([xml]$xaml)))

    #-- save aliases int $GUI (sync hash across all threads/runspaces)
    $GUI.styles = $w.Resources
    $shortcuts = "Window,Menu,Ribbon,Grid_ALL,Output,Cancel," + ($cfg.standard_fields.Keys -join ",")
    $shortcuts.split(",") | % { $GUI.$_ = $w.findName($_) } | Out-Null

    #--- return
    return $w
}


#-- create new runspace
function New-GuiThread {
    <#
      .SYNOPSIS
         Creates a new Runspace for the GUI interface.
      .DESCRIPTION
         Copies functions over into new Runspace/Pipeline and attaches shared $GUI
         hashtable for data exchange.
      .EXAMPLE
         New-GuiThread {code} -Funcs "copy1,copy2" -Vars "menu,GUI" -Modules "WPF"
      .NOTES
         Does not work with PS 5.0 yet (presumably scoping or syntax issue with func duplication).
    #>
    Param(
        $code = {},
        $funcs = "", #"WPF-Window,W,WF,WD" no longer used, now reloading modules
        $modules = "",
        $vars = "menu",
        $level = "",
        [switch]$invoke=$true
    )

    #-- create, add functions and code
    $shell = [PowerShell]::Create()
    $shell.AddScript($code) | Out-Null

    #-- separate thread
    $shell.Runspace = [runspacefactory]::CreateRunspace()
    $shell.Runspace.ApartmentState = "STA"
    $shell.Runspace.ThreadOptions = "ReuseThread"         
    $shell.Runspace.Open() | out-null

    #-- add vars, modules
    $shell.Runspace.SessionStateProxy.SetVariable("GUI", $GUI) | out-null
    $shell.Runspace.SessionStateProxy.SetVariable("vars", @{}) | out-null
    $shell.Runspace.SessionStateProxy.SetVariable("ParentHost", $Host) | out-null
    $vars.split(",")  |  ? { $_ } |  % { $shell.Runspace.SessionStateProxy.SetVariable($_, (get-variable $_).value) } | Out-Null

    #-- start
    if ($invoke) {
       $handle = $shell.BeginInvoke()
    }
    return $shell
}


#-- Look up icon basename path variations
function Get-IconPath {
    <#
      .SYNOPSIS
         Looks up alternative filenames for icons.
      .DESCRIPTION9
         Scans for PNGs (filename from PMD .icon or .img field) in $BaseDir/img/
      .PARAMETER basenames
         Can be a list of icon names "user", "icon.user", "user2.png" etc.
    #>
    [CmdletBinding()]
    Param([Parameter(ValueFromRemainingArguments=$true)]$basenames)
    ForEach ($fn in ($basenames | ? { $_ -ne $null })) {
        ForEach ($png in "icon.$fn.png", "icon.$fn", "$fn.png", "$fn") {
            if (Test-Path ($path = "$BaseDir/img/$png")) {
                return $path
            }
        }
    }
}

#-- Add tool buttons to main window
function Add-GuiMenu {
    <#
      .SYNOPSIS
         Adds menu entries and button blocks for each plugin from $menu
      .DESCRIPTION
         Iterators over the $menu list
          - skips "hidden:" or "nomenu:" entries, or "type:init*" plugins
          - adds a MenuItem and callback
          - uses the PMD .category to find the right menu or grid/notebook tab
          - looks up plugin or category icons (WPF requires unique instances each?!)
          - calls Create-GuiParamFields for extra input variables
         Also handles shortcut icons section.
      .NOTES
         This is what causes the slow startup! (perhaps due to `W` being too convenient)
    #>
    Param($menu)
    $icon_default = W Image # empty image
    $GRID = $GUI.Grid_ALL # now a TreeView
    $TV = @{}
    $cb_TV = { $GUI.Grid_ALL.Items | % { $_.IsExpanded = ($_ -eq $this) } }
    $steps = 500 / $menu.count;

    ForEach ($e in $menu) {
        Write-Splash "Add-GuiMenu ($($e.title))" $steps
    
        #-- prepare params
        $e.hidden = ($e.hidden -match "1|yes|hide|hidden|true")
        if ($e.type -eq "init") { continue; }
        $CAT = $e.category.toUpper();
  

        #-- output category header
        if (($e.category -ne $category) -and (-not $e.hidden)) {
            $category = $e.category
            if (-not $TV[$category]) {
                $TV[$category] = (W TreeViewItem @{Header="$category"; FontWeight="Bold"; FontSize=16; Add_MouseUp=$cb_TV})
                $GRID.Items.Add($TV[$category]);
            }
        }

        #-- callback (= now just appends to event queue)
        $cb = { $GUI.tasks += $e }.getnewclosure()

        #-- action block/button
        if (-not $e.hidden) {
            $label = (W StackPanel @{Orientation="Horizontal"; Padding=0; Margin=0; Height=18; Add=@(
                (W Image @{Source=(Get-IconPath $e.img $e.icon $category $e.x_category); Width=16; Height=16;}),
                (W Label @{Content=$e.title; Padding=0; Margin="2,0"; Height=18; FontSize=12; FontWeight="Normal"})
            )})
            $btn = (W Button @{Content=$label; Add_Click=$cb; BorderWidth=1; Padding=0; Height=22; Margin="0,1,0,2"; ToolTip=$e.doc})
            $TV[$category].Items.Add($btn)
        }
        
        #-- add menu entry
        if (($e.type -notmatch "^init") -and ($e.category) -and (!$e.nomenu)) {
            $m = $GUI.w.findName("Menu_$($CAT)")
            # new Extras > submenu if not found
            if (-not $m) {
                $m = (W MenuItem @{Name=$CAT; Header=$e.category})
                $GUI.w.findName("Menu_EXTRAS").Items.Add($m)
                $GUI.w.registerName("Menu_$CAT", $m)
            }
            # add
            $ICON = W Image @{Source=(Get-IconPath $e.img $e.icon $e.category); Height=20; Width=20} 
            $m.Items.Add((W MenuItem @{Header=$e.title; InputGestureText=$e.keycode; Icon=$ICON; ToolTip=(W ToolTip @{Content=$e.description}); Add_Click=$cb})) 
        }
    }

    #-- and a shortcut - have their own custom sorting from `#shortcut: 123`
    if ($m = $GUI.w.findName("Shortcuts")) {
        ForEach ($e in ($menu | ? { $_.shortcut -match "\d+|true|yes" } | Sort-Object @{expression={$_.shortcut.trim()}} )) {
            $cb = { $GUI.tasks += $e }.getnewclosure()
            $ICON = W Image @{Source=(Get-IconPath $e.img $e.icon $e.category); Height=22; Width=22}
            $BTN = W Button @{Style=$GUI.styles.ActionButton; ToolTip=(W ToolTip @{Content=$e.title}); Add_click=$cb; Height=22; Width=22; Content=$ICON}
            $m.Children.Add($BTN)
        }
    }
}

#-- FlowDocument textrange (for clipboard functions)
function Get-OutputTextRange {
    New-Object System.Windows.Documents.TextRange -ArgumentList @($GUI.output.ContentStart,$GUI.Output.ContentEnd)
}

#-- attach callbacks for main UI buttons
function Add-ButtonHooks {
    <#
      .SYNOPSIS
         Prepares callbacks for buttons/toolbar in main window.
      .DESCRIPTION
         Such as the computer and username fields, or the clipboard functionality.
      .NOTES
         Ping and username lookup can freeze the UI, as they run in the WPF runspace already.
         $GUI.machine and $GUI.username are shared with the main thread.
         As is the clipboards $GUI.html shadow content.
    #>
    
    #-- default clipboard actions (Clear+Copy) for primary GUI input boxes
    ForEach ($key in $cfg.standard_fields.Keys) {
        ForEach ($suffix in $cfg.standard_actions.Keys) {
            if ($btn = $GUI.w.findName("Btn" + $cfg.standard_fields[$key] + $suffix)) {
                $btn.add_Click([scriptblock]::create($cfg.standard_actions[$suffix] -f $key))
            }           
        }
    }

    #-- computer name
    $GUI.w.findName("BtnComputer").add_Click({ 
        if ($m = (Get-Clipboard).trim()) {
            $GUI.machine.Text = $m
            $col = if (Test-Connection $m -Count 1 -Quiet -TTL 10 -ErrorAction SilentlyContinue) {"#5599ff99"} else {"#55ff7755"}
            $GUI.machine.Items.Insert(0, (W ComboBoxItem @{Content=$m; Background=$col}))
            $GUI.machine.Background = "$col"
        }
    })
    $GUI.w.findName("BtnComputerPng").add_Click({ })
    $GUI.w.findName("BtnComputerUsr").add_Click({ if ($u = Get-MachineCurrentUser $GUI.machine.Text) { $GUI.username.Text = $u } })

    #-- user name
    $GUI.w.findName("BtnUsername").add_Click({
        $u = (Get-Clipboard).trim()
        if ($u -match "\w+[@, ]+\w+") { $u = (AD-Search $u -only 1) }
        $GUI.username.Text = "$u"
    })
    $GUI.username.add_DropDownOpened({
        if (($u = $GUI.username.Text).length -gt 2) {
            $GUI.username.Items.Clear()
            AD-Search $u | % { $GUI.username.Items.Add($_) }
        }
    })
    $GUI.username.add_DropDownClosed({
        $GUI.username.Text = $GUI.username.Text -replace "\s*\|.+$", ""
    })
    $GUI.w.findName("BtnUsernameCom").add_Click({  })

    #-- other fields
    #

    #-- clipboard tools
    $GUI.w.findName("BtnClipText").add_Click({
        Set-Clipboard ((Get-OutputTextRange).Text)
    })
    $GUI.w.findName("BtnClipHtml").add_Click({
    
        # use $html when available
        if ($GUI.html) {
            Set-ClipboardHtml $GUI.html
        }
        elseif (Test-Path function:Convert-FlowdocumentToHtml) {
            Set-ClipboardHtml (Convert-FlowdocumentToHtml $GUI.Output.Blocks)
        }
        else { # use RTF for now (no XAML-to-HTML converter yet)
            $ms = New-Object System.IO.MemoryStream
            $null = (Get-OutputTextRange).Save($ms, "Rich Text Format")
            $str = [System.Text.Encoding]::ASCII.GetString($ms.getBuffer())
            [System.Windows.Forms.Clipboard]::setData("Rich Text Format", $str)
        }
    })
    $GUI.w.findName("BtnClipFree").add_Click({
        $GUI.prev_output = (Get-OutputTextRange).Text  # retain only text version
        $GUI.html = ""
        $GUI.output.Blocks.Clear()
    })
    $GUI.w.findName("BtnClipSwap").add_Click({
        $GUI.output.blocks.clear()
        Out-Gui ($GUI.prev_output)
    })

    #-- Cancel event
    $GUI.Cancel.add_Click({
         $ParentHost.Runspace.Events.GenerateEvent("Gui.Cancel", $this, $null, $null)
    })
    
    #-- window closing
    $GUI.w.add_Closed({
        $GUI.closed = $true
        $GUI.w = $null
        $ParentHost.Runspace.Events.GenerateEvent("Gui.Exit", $this, $null, $null)
    })

    #-- unicode symbols
    if ($ls = $GUI.w.findName("UnicodeClip")) {
        $cb = {Set-Clipboard $this.Content}
        ForEach ($btn in $ls.Children) {
            $btn.Add_click($cb)
        }
    }
}

#-- User lookup (simple `finduser` variant)
function AD-Search {
    <#
      .SYNOPSIS
         Simple AD username search
      .DESCRIPTION
         Gets invoked whenever somethings is pasted into `username` input field.
         Defaults to search for "Last, First" names and spit out "UserName123".
         Also may scan for phone numbers or email addresses (used in UI for dropdown).
      .NOTES
         Defaults to [adsisearched], but may use Get-ADUser when available. By default
         the ActiveDirectory module is not loaded again into the WPF/UI Runspace.
         May require customization if AD displayname does not follow "First, Last" scheme.
    #>
    Param(
        $s = "",
        $only = 0
    )
    $s = ($s -replace "%","*")
    #-- AD search
    if (Test-Path function:Get-ADUser) {
        $filter = switch -regex ($s) {
           "^[*?\d]+$" {   "telephoneNumber -like '*$s'"                              }
           "^\w+, \w+" {   "displayname -like '$s*'"                                  }
             "\w+@\w+" {   "mail -like '$s'"                                          }
               default {   "displayname -like '$s*' -or samaccountname -like '$s*'"   }
        }
        #-- find
        $u = Get-ADUser -Filter $filter -Properties samaccountname,displayname,telephonenumber
    }
    #-- crude cache search
    elseif ($cache) {
        return ((Get-Content "data\adsearch.txt") -match $s)
    }
    #-- ADSI query
    else {
        $s = $s -replace '([;\\"#+=<>])', '\$1'
        $filter = switch -regex ($s) {
           "^[*?\d]+$" {   "telephonenumber=*$s"                              }
        "^\w+\\?, \w+" {   "displayname=$s"                                   }
           ".+\w+@\w+" {   "mail=$s"                                          }
               default {   "|(displayname=$s*)(samaccountname=$s*)"           }
        }
        $adsi = [adsisearcher]"(&(objectClass=user)(objectCategory=person)(!userAccountControl:1.2.840.113556.1.4.803:=2)($filter))"
        [void]$adsi.PropertiesToLoad.AddRange(("telephonenumber","displayname","samaccountname","mail","*","+"))
        $u = ($adsi.findAll() | % { $_.properties })
    }
    #-- result
    if ($only) {
        if ($u -is "array") { $u = $u[0] }
        return [string]$u.samaccountname
    }
    else { return @($u | % { "{0} | {1} | {2}" -f [string]$_.samaccountname, [string]$_.displayname, [string]$_.telephonenumber }) }
}

#-- get current user
function Get-MachineCurrentUser($m) {
    <#
      .SYNOPSIS
         Get current user from remote machine name.
      .DESCRIPTION
         Runs a quick WMI query. Returns user name (domain prefix stripped).
      .NOTES
         Alternatively now falls back on Explorer.exe process scan.
    #>
    if (!$m) {
    }
    elseif (($w = gwmi win32_computersystem -computername $m) -and ($u = $w.username)) {
        return $u -replace "^\w+[\\\\]",""
    }
    elseif ($W = GWMI Win32_Process -ComputerName $m -filter "name='Explorer.exe'") {
        $r = ($w | % { $_.getOwner().user } | ? { $_ -notmatch "^SYSTEM$" })
        if ($r -is [array]) { $r = $r[0] }
        return $r
    }
}


##########################################################################################
########################   shared between main and GUI runspace   ########################
##########################################################################################


#-- Startup splash screen (WinForms)
#   ยท runs in main thread, no callbacks or extra background handling
#   ยท keeps its own reference once initialized by first call
function Write-Splash {
    Param($msg, $progress=25, $TITLE="โ–ถ ClickyColoury", $VENDOR="PWSH")
    $null = &{
        if (!$Splash) {
             $bold = New-Object System.Drawing.Font -ArgumentList "Verdana",13,"Bold","Pixel"
             $impc = New-Object System.Drawing.Font -ArgumentList "Lucida Sans",32,"Bold","Pixel"
             $bg = [System.Drawing.Image]::FromFile("$BaseDir/img/splash.jpeg")
             $v = $PSVersionTable.PSVersion
             $global:Splash = @{
                 psv1 = (WF Label @{Text=$VENDOR; Location=5,5; Size=50,16; Font=$BOLD; ForeColor="Gray"; BackColor="Transparent"})
                 psv2 = (WF Label @{Text="$($v.Major).$($v.Minor)"; Location=50,5; Size=40,16; Font=$BOLD; ForeColor="Orange"; BackColor="Transparent"})
                 title = (WF Label @{Text=$TITLE; Location=5,25; Size=280,55; AutoSize=0; TextAlign="MiddleCenter"; Font=$IMPC; ForeColor="#ffffff"; BackColor="Transparent"})
                 prg = (WF ProgressBar @{Location=10,80; Size=280,30; Value=1; Step=25; Maximum=1000; BackColor="#111133"; ForeColor="#ddbb22"})
                 msg = (WF Label @{Location=15,120; Size=270,20; Text="..."; ForeColor="#eeffdd"; BackColor="Transparent"})
             }
             $Splash.win = (WF Form @{Text="Clicky"; Size=300,150; StartPosition="CenterScreen"; Opacity=0.95; BackgroundImage=$bg; FormBorderStyle="None"; OnLoad={$this.close()}} -Add $Splash.Values)
             $Splash.win.show()
        }
        $Splash.prg.PerformStep()
        $Splash.msg.Text = $msg
        $Splash.prg.Step = $progress # current step is used for next redraw
        $Splash.win.Refresh()
        if ($progress -ge 1000) { $Splash.win.close(); $Splash = @{} }
    }
}


#-- GUI output
#   ยท Primarily appends Paragraphs to `output` FlowDocument
#   ยท Is called from main thread (and the only allowed interaction method),
#     itself uses w.Dispatcher.Invoke to execute codeblock in GUI runspace
function Out-Gui {
    <#
      .SYNOPSIS
         Fills the $GUI.output TextBlock from calls to Write-Host (aliased to Out-Gui).
      .DESCRIPTION
         Mainly converts any objects (HashTables/PSObjects) to strings, then appends them to main output pane.
         Since this is called from the main script Runspace, uses the WPF dispatcher to execute the appending
         in the WPF runspace (factually WPF does it in another separate thread).
      .PARAM str
         Input string or object
      .PARAM f
         Foreground color
      .PARAM b
         Background color
      .PARAM N
         NoNewLine
      .PARAM bold, s
         Not CLI/Write-Host compatible. Should not be used unless the plugin/script was meant for GUI usage only.
      .NOTES
         Ultimately this should handle error/exception highlighting and creating OUtGrid-like display
         for HashTables and Objects. Not implemented yet, as it doesn't even run on PS 5.0 yet.
         Also should be rewritten to Begin{} Process{} End{} and split up to handle proper piping.
      .NOTES
         Getting this to work in Powershell 5.0 was quite an act. Apparently you can't create
         new Document widgets in the Dispatcher thread anymore. It's again related to object/variable
         scopes. Using [action][scriptblock]::create() did severe the $str/etc. variable binding.
         Thus we're now creating XAML flowdocument inlines before passing it on. Much slower, but
         gives more flexibility later on.
    #>
    [CmdletBinding()]
    Param(
        [Parameter(ValueFromPipeline=$true)]$str = $null,
        [alias("Foreground")]$f = $null,
        [alias("Background")]$b = $null,
        [alias("Bld")]$bold = $null,
        [alias("Strikethrough")]$S = $null,
        [alias("Underline")]$U = $null,
        [alias("NoNewLine")][switch]$N = $false,
        $style = "",
        $xaml = $null,   # Alternatively to string/object input
        $cols = "^(#([a-f0-9]{3,4}){1,2}|Transparent|liceBlue|AntiqueWhite|Aqua|Aquamarine|Azure|Beige|Bisque|Black|BlanchedAlmond|Blue|BlueViolet|Brown|BurlyWood|CadetBlue|Chartreuse|Chocolate|Coral|CornflowerBlue|Cornsilk|Crimson|Cyan|DarkBlue|DarkCyan|DarkGoldenrod|DarkGray|DarkGreen|DarkKhaki|DarkMagenta|DarkOliveGreen|DarkOrange|DarkOrchid|DarkRed|DarkSalmon|DarkSeaGreen|DarkSlateBlue|DarkSlateGray|DarkTurquoise|DarkViolet|DeepPink|DeepSkyBlue|DimGray|DodgerBlue|Firebrick|FloralWhite|ForestGreen|Fuchsia|Gainsboro|GhostWhite|Gold|Goldenrod|Gray|Green|GreenYellow|Honeydew|HotPink|IndianRed|Indigo|Ivory|Khaki|Lavender|LavenderBlush|LawnGreen|LemonChiffon|LightBlue|LightCoral|LightCyan|LightGoldenrodYellow|LightGreen|LightGray|LightPink|LightSalmon|LightSeaGreen|LightSkyBlue|LightSlateGray|LightSteelBlue|LightYellow|Lime|LimeGreen|Linen|Magenta|Maroon|MediumAquamarine|MediumBlue|MediumOrchid|MediumPurple|MediumSeaGreen|MediumSlateBlue|MediumSpringGreen|MediumTurquoise|MediumVioletRed|MidnightBlue|MintCream|MistyRose|Moccasin|NavajoWhite|Navy|OldLace|Olive|OliveDrab|Orange|OrangeRed|Orchid|PaleGoldenrod|PaleGreen|PaleTurquoise|PaleVioletRed|PapayaWhip|PeachPuff|Peru|Pink|Plum|PowderBlue|Purple|Red|RosyBrown|RoyalBlue|SaddleBrown|Salmon|SandyBrown|SeaGreen|SeaShell|Sienna|Silver|SkyBlue|SlateBlue|SlateGray|Snow|SpringGreen|SteelBlue|Tan|Teal|Thistle|Tomato|Turquoise|Violet|Wheat|White|WhiteSmoke|Yellow|YellowGreen)$"
    )
 
    BEGIN {

        #--PS5dbg
        TRAP { $_ | Out-String >> ./data/log.errors.wpf.txt }

        #-- prepare output, check types
        try { $type = $str.GetType().Name } catch { $str = ""; $type = "unknown" }

        #-- finalized output
        if ($xaml) {
            # leave as-is
        }
        #-- assume string
        else {
            $style = ""
            if ($f -and $f -match $cols) { $style += " Foreground='$f'" }
            if ($b -and $b -match $cols) { $style += " Background='$b'" }
            if ($bold) { $style += " FontWeight='Bold'" }
            if ($S) { $style += " TextDecorations='Strikethrough'" }
            if ($U) { $style += " TextDecorations='Underline'" }
        }

     }

     PROCESS {

        #-- convert current pipeline object to string
        if ($str) {
            try {
                $str = ($str | Out-String -Width 120) -replace "\r?\n$",""
            }
            catch {
                $str = "โ€"
            }
            $xaml = @"
               <Paragraph
                 xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation'
                 xml:space='preserve' Margin="0,1" $style
               ><![CDATA[$str]]></Paragraph>
"@
        }
        elseif (-not $xaml) {return}


        #-- inject via dispatcher
        Dispatch-GUI ([action]{
            try {
                #-- append as XAML
                $w = [Windows.Markup.XamlReader]::Parse($xaml)
                [void]$GUI.output.Blocks.Add($w)
            }
            catch {
                $ParentHost.UI.Write("ERR_OUT_GUI_ACTION: $($_|FL|Out-String)`r`n")
                #$GUI.output.Blocks.AddText(($_|FL|Out-String))
            }
        })

        $xaml = $null
     }

     END {
         Dispatch-GUI ([action]{ [void]$GUI.output.Parent.Parent.ScrollToEnd() })
     }

}

#-- invoke [action] callback via main window dispatcher
#   PowerShell >= 3.0 does need the parameters swapped
#   @src: https://gallery.technet.microsoft.com/Script-New-ProgressBar-329abbfd
#
function Dispatch-Gui($action, $args=@()) {
    if ($PSVersionTable.PSVersion.Major -eq 2) {
        [void]$GUI.w.Dispatcher.Invoke("Normal", $action)
    }
    else {
        [void]$GUI.w.Dispatcher.Invoke($action, "Normal") #, $args)
    }
}

#-- just -Title update
function Set-GuiTitle($title) {
    Dispatch-Gui ([action]{ $GUI.w.title = $title })
}

#-- clear Window output pane
#   ยท if last output over 5 minutes ago (`-clear:$true` is set by caller)
function Clear-GuiOutput {
    $GUI.html = ""
    Dispatch-Gui ([action]{
        $GUI.prev_output = (Get-OutputTextRange).Text
        $GUI.output.blocks.clear()
    })
}

#-- Read standard input fields
#  ยท this is a workaround, because accessing $GUI.machine.text
#    directly from parent runspace would hang up WPF
#  ยท for some reason also needs its hashtable recreated
function Get-GuiStandardVars {
    Dispatch-Gui ([action]{
        $GUI.vars = @{
            machine = $GUI.machine.Text
            username = $GUI.username.Text
        }
    })
}


##########################################################################################
########################   everything below runs in main thread   ########################
##########################################################################################



#-- build input widget blocks
#   ยท called for entries in $meta.vars[] (each an hash with .name/.type/.value/...)
#   ยท only returns an input widget (checkbox, textbox, combobox, button) each
#   ยท default .value is used unless it's a standard var, or was previously set
function New-GuiVarInput {
    Param($PARAM, $CURRENT, $SELECT_FN = "./data/combobox.{0}.txt")
    $NAME = $PARAM.name
    $ALIAS= Get-VarsAlias $NAME
             
    # value preference: (1) explicit alias โ†’ `{value:$varname}` or expression `{value:$(...)}`
    if (($PARAM.VALUE -match '^\$(\w+)$') -and $GUI.vars.containsKey($matches[1])) { $VALUE = $GUI.vars[$matches[1]] }
    elseif ($PARAM.VALUE -match '^\$\((\w.+)\)$') { $VALUE = Invoke-Expression ($matches[1] -replace '\$(username|machine)','$GUI.vars["$1"]') }
    elseif ($VALUE = $GUI.vars[$ALIAS]) {} # (2) use standard vars
    elseif ($VALUE = $CURRENT[$NAME]) {}   # (3) previously entered data
    else {  $VALUE = $PARAM.value  }       # (4) literal default โ†’ `{value:"..."}`
     
    # widget/type variants
    switch -regex ($PARAM.type) {
        "bool" {
            return W CheckBox @{Margin="10,0"; IsChecked=($VALUE -notmatch "^$|0|false|no")}
        }
        "text|csv|long" {
            return W TextBox @{ Height=120; Width=280; Text=$VALUE+"`r`n"; AcceptsReturn=1 }
        }
        "select|combo" {
            if ($PARAM.select) { $ls = $PARAM.select.split("[,;|]") }   # normal `select: alternative|list|syntax`
            elseif (Test-Path ($SELECT_FN -f $NAME)) { $ls = Get-Content ($SELECT_FN -f $NAME) } # read combobox.name.txt else
            else { $ls = @("Null") }
            if (!$VALUE) { $VALUE = $ls[0] }  # default value = first entry in select list
            return W ComboBox @{Height=22; Width=260; IsEditable=$true; Text=$VALUE; ItemsSource=$ls }
        }
        "btn|button|action" {
            return W Button @{Content="โ–ถ "+$PARAM.description; Width=125; Height=26; HorizontalAlignment="Left"; Background="#cc77ff77"; Add_Click=[scriptblock]::create(
               '$results._proceed = $results["{0}"] = 1; $w.Close()' -f $NAME   # scriptblock workaround, because $w lives in parent function scope, but $NAME only here
            )}
        }
        "file|choose" {
            return W Button @{Content="File โ†’ "+$PARAM.description; Background="#eeffee77"; Add_Click={
                $f = WF OpenFileDialog @{CheckFileExists=$false; Filter="Text (*.txt)|*.txt|CSV (*.csv)|*.csv|JSON (*.json)|*.json|XML (*.xml)|*.xml|Powershell (*.ps1)|*.ps1|All Files (*.*)|*.*"; SupportMultiDottedExtensions=$true; Title="Script '$NAME' file"}
                if ($f.ShowDialog() -and $f.FileName) { $this.Content = $f.FileName }
            }}
        }
        "str|int|^$|.*" { 
            return W TextBox @{Height=20; Width=270; Text=$VALUE}
        }
    }
}


#-- Variable list input
#   ยท shows an input box popup for $meta.vars[], waits for input, then returns variable hash
#   ยท reuses current $GUI.vars, default {value:...} data, or previous values
#   ยท gets run only when extra input fields are requested
#   ยท also utilized by `Read-Host` alias `Ask-Gui` for single input
function Read-GuiExtraParams {
    Param(
       $meta = @{
          title="INPUT"; description="EXAMPLE"; doc="META"; version="0.0";
          vars=@( @{name="mail"; type="string"; description="email addr"; value="user@local"} )
       },
       $height = 450, $CURRENT_STORE = $GUI.vars_previous,
       $vars = @{}, $widget_blocks = @(), $results = @{_proceed=0}
    )
    if (!$meta.vars) { return }

    #-- create input widgets, wrap in label and description
    $widget_blocks = $meta.vars | % {
        $vars[$_.name] = New-GuiVarInput $_ $CURRENT_STORE
        W Label @{Content=$_.name; FontWeight="Bold"}
        $vars[$_.name]
        W TextBlock @{Text=$_.description; TextWrapping="WrapWithOverflow"; FontSize=9; Margin="0,0,0,5"}
    }

    #-- window
    $w = (W (New-Object System.Windows.Window) @{
      Title=$meta.title;
      Width=345; Height=$height; Top=350; Left=600; TopMost=$true;
      Background="#eef7f7ff";
      Content=(
        W StackPanel @{Width=350;Add=@(  #AddChild ?
          (W ScrollViewer @{
            Height=($height-90); Padding="15,5"; HorizontalAlignment="Left";
            Content=(
              W StackPanel @{
                Add=@(
                  (W Expander @{Expanded=$false; Header=$meta.description;
                    Content=(W Label @{Content=" $($meta.api) // $($meta.type) // $($meta.category) // $($meta.version)`r`n$($meta.doc)"})
                  })
                ) + $widget_blocks
              }
            )
          }),
          (W DockPanel @{Height=50; Margin="35,5";
            Add=@(
              (W Button @{Content="Cancel"; Width=70; Height=30; Dock="Right"; BackGround="Red";
                Add_Click={ $results._proceed = 0; $w.close() }
              }),
              (W Button @{Content="โ–ถ Run"; Width=150; Height=30; BackGround="Green";
                Add_Click={ $results._proceed = 1; $w.close() }
              })
            )
          })
        )}#=stackpanel
      )
    })#=window
    [void]$w.ShowDialog()

    #-- fetch vars
    if ($results._proceed) {
        ForEach ($NAME in $vars.Keys) {
            $results[$NAME] = switch -regex ($meta.vars | ? { $_.name -eq $NAME } | % { $_.type}) {
                "bool"     { [int32]($vars[$NAME].IsChecked); break }
                "but|btn"  { $results[$NAME]; break } # already set in button scriptblock
                "file|cho" { $vars[$NAME].Content; break }
                "str|.*"   { $vars[$NAME].Text; break }
            }
        }
        #$results|FL|Out-String|Write-Host
        $results.getEnumerator() | % { $CURRENT_STORE[$_.Name] = $_.Value }
        return $results
    }
}


#-- User input
#   ยท this is aliased over `Read-Host`
#   โ†’ so scripts can be used unaltered in standalone/CLI or GUI mode
#   ยท for standard field names just returns the GUI $machine/$username input
#   ยท else shows a VB input box window
function Ask-Gui {
    <#
      .SYNOPSIS
         Alias over `Read-Host`
      .DESCRIPTION
         Captures Read-Host requests, returns either custom or standard $GUI.vars[] entry.
      .PARAM str
         Textual input query. Usually just the variable name '$computer' or '$user'.
         Recognizes common aliases like 'Machine' 'PC' 'Hostname' 'AD-Account' or 'AD-User-Name'
      .NOTES
         Scripts should preferrably query for input in their Param() section once,
         and list custom fields per plugin meta #param: list. NEW: per #vars: block.
    #>
    param($name, $title="Input", [switch]$N=$false)
    $alias = Get-VarsAlias $name
    if ($GUI.vars.containsKey($alias)) {
        return $GUI.vars[$alias]
    }
    else {
        #-- undefined/non-Param() input request / stray Read-Host calls
        $results = Read-GuiExtraParams @{
            title="Input"; description="Read-Host"; doc="stray Read-Host call from script";
            api="clicky"; type="io"; category="extra"; version="1.1";
            vars=@(@{name=$name; type="str"; description="$name"; value=""})
        } -Height 200
        if (!$results) { throw "โŽ Cancelled at Read-Host request." }
        return $results[$name]
    }
}

#-- Parameter/varname aliasing
function Get-VarsAlias {
    <#
      .SYNOPSIS
         Aliases like "Computer" for $machine variable
      .DESCRIPTION
         Allows flexible title matching for standard/internal/default variables.
         Which permits scripts to list arguments by different monikers. For instance
         Read-Host '$computer-name:' would still match $GUI.vars."machine"
    #>
    Param($name)
    ForEach ($key in $cfg.standard_aliases.keys) {
        if ($name -match $cfg.standard_aliases[$key]) { return $key }
    }
    return $name;
}

#-- Converts vars: name list from $e.vars[]{} to quoted cmdline "arg" "strings"
function Convert-VarsToCmdArgs {
    <#
      .SYNOPSIS
         Crafts a list of cmd/Invoke-quoted strings from params list
      .DESCRIPTION
         Is used for type:window and type:cli scripts/plugins. Those get executed
         in a separate Powershell process, thus need input variables as exec arguments.
      .NOTES
         Aliases any "standard" $GUI.var names back onto requested aliases/order.
    #>
    Param(
        $meta_params = @(),    # meta.vars[] list of dicts
        $vals = @{machine=""; username=""},  # from $GUI.vars[]
        $out = ""
    )
    # default list
    if (!$meta_params) {
        $meta_params = @( @{name="machine"}, @{name="username"} )
    }
    ForEach ($key in ($meta_params | % { $_.name })) {
        # aliases
        $key = Get-VarsAlias $key
        # quote + append
        $out += ' "'+($vals[$key] -replace '(["^])','^$1')+'"'
        #$out += $vals[$key]
    }
    return $out
}

#-- fetch all script input vars - ribbon fields + custom vars:
function Get-ScriptVars($e) {
    #-- machine, user
    $null = Get-GuiStandardVars
    
    #-- convert obsolete param: scheme to vars: list
    if ($e.param -and -not $e.vars) {
        $e.vars = ($e.param.trim() -split "\s*[,;]\s*") | % {
            if (Test-Path "data/combobox.$_.txt") { $t = "select" } else { $t = "str" }
            @{name=$_; description="..."; type="$t"; value=$GUI.vars[$_]}
        }
    }

    #-- input dialog, if vars: lists non-standard fields
    if ($e.vars -and ($e.vars | % { Get-VarsAlias ($_.name) } | ? { $GUI.vars.keys -notcontains $_ })) {
        if ($add = Read-GuiExtraParams $e) {
            $add.getEnumerator() | % { $GUI.vars[$_.Name] = $_.Value }
        }
        else {
            return $false;
        }
    }
    return $GUI.vars
}

#-- print header/title
function Out-GuiHeader($e) {
   Set-GuiTitle "โžฑ $($cfg.main.title) โ†’ $($e.title)"
   if ($e.noheader -or $cfg.noheader) { return; }
   #$Host.UI.Write("HDR`r`n")
   $XAML = @"
      <Paragraph xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation'
       Foreground="#ff9988dd" Background="#ff102070" Margin="0,3"
       ><Image Source='$(Get-IconPath $e.icon $e.img $e.category)' Width='16' Height='16'
       /><Bold><![CDATA[$($e.title)]]></Bold><Span Foreground="#a98d"><![CDATA[ - $($e.description)]]></Span
       ></Paragraph>
"@
   # inject any $placeholders from $GUI.vars
   $XAML = [regex]::replace($XAML, "\`$(\w+)", {$GUI.vars[$Args[0].Groups[1].Value]})
   Out-Gui -xaml $XAML
}

#-- wrapper around Process-MenuTask
function Run-GuiTask {
    <#
      .SYNOPSIS
         Executes the given $e event queue entry returned from GUI (menu or button callback).
      .DESCRIPTION
         Checks the $menu.type and handles output start, script execution, and result collection.
          - Just prints .title and .description first
          - Then collects GUI variables ($machine, $username and extra script fields)
          - Runs `type:inline` plugins in main thread.
          - But `type:window` in separate window/CLI process.
          - Captures and outputs any errors
          - Then returns to Start-Win loop
      .PARAM e
         The script/tool entry as returned from $GUI.tasks (just one of the $menu entries really).
         Said queue gets filled by any of the menu or toolblock buttons in the main window.
      .NOTES
         Ideally this should just wrap the CLI Process-MenuTask function (but too divergent now).
         The new | Out-Gui piping should capture literal stream results, BUT still does not mix
         with direct Write-Host calls in order. (Probably needs Runspace.Streams event listener)
    #>
    [CmdletBinding()]
    param($e, $clear=$false) # one of the $menu entries{}

    #-- check last output time (>= 5 minutes per default)
    if ($last_output -and (([double]::parse((Get-Date -u %s)) - $last_output) -ge $cfg.autoclear)) {
        Clear-GuiOutput
    }

    #-- get vars (fetch input from "Ribbon"-fields: machine, username, etc)
    if (!(Get-ScriptVars $e)) { Write-Host -f Red "Cancelled."; return; }
    $GUI.vars.GetEnumerator() | % { Set-Variable $_.name $_.value -Scope Global }

    #-- print header
    Out-GuiHeader $e

    #-- plugins
    TRAP { $_ | out-gui -b red }
    $plugins.before | % { Invoke-Expression ($_.ToString()) }

    #-- Run $menu entry rules (command=, func=, or fn=)
    #$Host.UI.Write("INVOKE`r`n")
    try {
        #-- Internal commands
        if ($e.command) {
            [void]((Invoke-Expression $e.command) | Out-String | Out-Gui -f Yellow)
        }
        elseif ($e.func) {
            [void]((Invoke-Expression "$e.func $machine $username") | Out-String | Out-Gui -f Yellow)
        }
        #-- Start script
        elseif ($e.fn) {
            if ($e.type -match "window|cli") {  # in separate window
                $cmd_params = Convert-VarsToCmdArgs ($e.vars) ($GUI.vars)
                Start-Process powershell.exe -Argumentlist "-STA -ExecutionPolicy ByPass -File ""$($e.fn)"" $cmd_params"
                #Start-Process powershell.exe -ArgumentList (@("-STA", "-ExecutionPolicy", "ByPass", "-File", $e.fn)+@($cmd_params))
            }
            else {  # dot-source all "inline" type: plugins
                . $e.fn | Out-String -Width 120 | Out-Gui
            }
        }
        #-- No handler
        else {
            Out-Gui -f Red "No Run-GuiTask handler for:"
            [void](@($e) | Out-String | Out-Gui)
        }
    }
    #-- Error cleanup
    catch {
        Out-Gui -f Yellow $_
    }
    if ($Error) {
        $Error | % { $_ | Out-Gui -f Red } | Out-Null
        [void]($Error.Clear())
    }

    #-- Run plugins / cleanup
    $plugins.after | % { Invoke-Expression ($_.ToString()) }
    $global:last_output = [double]::parse((Get-Date -u %s))
    Set-GuiTitle "โžฑ $($cfg.main.title)"
}


#-- verify runspace and window are still active
function Test-GuiRunning($shell, $GUI) {
    return ($shell.Runspace.RunspaceStateinfo.State -eq "Opened") -and ($GUI.w) -and (-not $GUI.closed)
}


#-- Initialize Window, Buttons, Subshell+GUI thread
function Start-Win {
    <#
      .SYNOPSIS
         Starts the WPF window and Runspace, then waits for GUI events
      .DESCRIPTION
         Sets up GUI environment from $menu entries, then polls the shared $GUI. variables
      .NOTES
         Perhaps the least interesting function and self-explanatory.
    #>
    Param($menu)

    #-- no threading / manual
    #    $GUI.w = $w = WPF-Window; $w.showdialog(); Write-Host "Ran WPF-Window without thread"

    #-- start GUI in separate thread/runspace
    Write-Splash "New-GuiThread.." 40
    $GUI.shell = New-GuiThread -Code {

        #-- error catching
        TRAP { $ParentHost.UI.Write("GUI_THREAD_ERR: $($_|FL|Out-String)`r`n");  }
        function Write-Host {
            [Cmdletbinding()]
            Param([parameter(ValueFromPipeline=$True)]$str)
            PROCESS { $ParentHost.UI.Write("$str`r`n"); }
        }

        #-- modules
        Import-Module -DisableNameChecking .\modules\guimenu.psm1
        Write-Splash "Loading modules anew.." 30
        Import-Module -DisableNameChecking .\modules\menu.psm1
        Import-Module -DisableNameChecking .\modules\clipboard.psm1

        #-- setup things

        #-- build gui
        Write-Splash "Create WPF-Window.." 40
        $GUI.w = WPF-Window
        Write-Splash "Add-ButtonHooks.." 10
        Add-ButtonHooks
        Write-Splash "Add-GuiMenu.." 15
        Add-GuiMenu $menu
        #$GUI.w.Add_Loaded({Add-GuiMenu $menu})  # "speed" mode (late init would be faster, but halts window draw, might make sense with timer pulsing)

        #-- execute `type:init` files right away
        $menu | ? { $_.fn -and ($_.type -eq "init-gui") } | % {
            Write-Splash "Init-Gui plugins.. $($_.title)" 25
            . $_.fn
        }
        Write-Splash "Go." 1000
        $GUI.w.ShowDialog()

    } -Vars "menu,BaseDir,cfg,plugins,Splash"

    #-- wait for window to be visible
    while (!($GUI.w.IsInitialized)) { 
        Write-Splash "Event loop preparations.." 1
        Start-Sleep -milliseconds 275
        Write-Host 'wait on $GUI.w.isInitialized'
        if ($Debug -and $shell.streams.error) {
            $shell.streams | FL | Out-String | Write-Host -b Red
        }
    } 

    #-- other initializations
    $GUI.tasks = @()
    $GUI.vars = @{}
    $GUI.vars_previous = @{}
    $global:last_output = 1.5

    #-- Alias console functions
    # `echo` is already alias to `Write-Output`
    Set-Alias -Name Write-Host  -Value Out-Gui  -Scope Global
    Set-Alias -Name Read-Host   -Value Ask-Gui  -Scope Global
    
    #-- basic error catching
    TRAP { $_ | Out-Gui -f Red; $Error.Clear() }

    #-- events
    Register-EngineEvent "Gui.Exit" -Action {exit;}
    Register-EngineEvent "Gui.Cancel" -Action {$Host.UI.Write("Main.Cancel()");}

    #-- main loop / trivial message queue
    #   test for window state, pause a few seconds, then resolves $tasks[]
    while (Test-GuiRunning $GUI.shell $GUI) {
        if ($GUI.tasks) {
            $GUI.tasks | % { Run-GuiTask $_ }
            $GUI.tasks = @()
        }
        Start-Sleep -milliseconds 175
        $Host.UI.Write((Get-Event))
    }
}