diff --git a/.github/workflows/powershell-xaml-designer.yml b/.github/workflows/powershell-xaml-designer.yml
new file mode 100644
index 0000000..07e51d8
--- /dev/null
+++ b/.github/workflows/powershell-xaml-designer.yml
@@ -0,0 +1,30 @@
+name: PowerShell XAML Designer checks
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+
+jobs:
+ validate:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Parse PowerShell and XAML
+ shell: pwsh
+ run: .\Tests\Test-Repository.ps1
+ - name: Verify WPF assemblies can load
+ shell: pwsh
+ run: |
+ Add-Type -AssemblyName WindowsBase
+ Add-Type -AssemblyName PresentationCore
+ Add-Type -AssemblyName PresentationFramework
+ [System.Windows.Window] | Out-String | Write-Host
+
+ - name: Test generated PowerShell code
+ shell: pwsh
+ run: .\Tests\Test-CodeGeneration.ps1
+
+ - name: Smoke test designer startup in STA
+ shell: cmd
+ run: powershell.exe -NoProfile -STA -ExecutionPolicy Bypass -File .\Tests\Test-DesignerStartup.ps1
diff --git a/README.md b/README.md
index cd0fd6f..2fa695e 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,99 @@
-# PowerShell_WPF_XAML_GUI_SampleCodes
+# PowerShell WPF XAML GUI Sample Codes / PowerShell XAML Designer
+
+This repository is being refocused around a **PowerShell-only WPF/XAML GUI designer** for Windows.
+
+The goal is to make it possible to create and maintain PowerShell WPF screens even in environments where Visual Studio, Blend, paid IDE add-ons, or third-party GUI designers cannot be installed or used.
+
+The original WPF sample scripts are kept as compatibility/reference samples. The main tool is now under [`XamlDesigner`](./XamlDesigner/).
+
+## PowerShell XAML Designer
+
+The designer follows a two-file model:
+
+```text
+MyWindow.xaml # UI / layout
+MyWindow.ps1 # PowerShell control references, event registration, application logic
+```
+
+This is intentional. A standalone PowerShell `XamlReader` does not use the compiled C#/VB code-behind model that Visual Studio WPF projects use. Therefore the designer generates PowerShell event registration such as:
+
+```powershell
+[System.Windows.Controls.Button]$Button1 = $Window.FindName('Button1')
+
+$Button1.Add_Click({
+ param($sender, $e)
+
+ # Add logic here.
+})
+```
+
+instead of depending on `Click="Button1_Click"` in XAML.
+
+### Current features
+
+- PowerShell + WPF only; no Visual Studio and no third-party module required at runtime.
+- New / Open / Save / Save As for paired `.xaml` and `.ps1` files.
+- Runtime-discovered toolbox for public, instantiable WPF visual element types.
+- Toolbox search and category filtering.
+- Drag and drop from the toolbox to Canvas/Grid/StackPanel/DockPanel/WrapPanel/UniformGrid layouts and supported empty single-child containers such as Border and GroupBox.
+- Direct mouse movement for controls whose parent is a `Canvas`.
+- Arrow-key Canvas movement (1 px, or 10 px with Shift) plus optional 10-pixel mouse snap-to-grid.
+- XAML document Outline, including selection/editing of the root Window.
+- Selection of controls on the preview surface.
+- Undo/redo for designer-side XAML changes, while text editors keep their native text undo/redo.
+- Reflection-based property browser and property editing, including Canvas/Grid/DockPanel attached properties.
+- Reflection-based event browser.
+- Double-click an event to generate PowerShell event code.
+- Double-click a control on the designer to generate its typical/default event (`Click`, `SelectionChanged`, `TextChanged`, etc.).
+- XAML source editing, XML formatting, and well-formedness validation with line/position reporting.
+- Live WPF preview through `System.Windows.Markup.XamlReader`.
+- Preview-time removal of Visual Studio/Blend build-time attributes such as `x:Class`, `mc:Ignorable`, and `d:*`.
+- Preview-time removal of XAML event attributes that cannot be resolved by standalone PowerShell, while preserving the source document.
+- Automatic synchronization of named XAML controls into a generated control-reference region in the paired `.ps1` file.
+- Generated event handlers are placed in a dedicated region before `$Window.ShowDialog()`, so handlers are registered before the UI runs.
+- Existing user event code is not overwritten when control references are refreshed.
+- Save As warns before replacing an already-existing paired `.ps1` file.
+
+### Start the designer
+
+Windows PowerShell 5.1:
+
+```powershell
+powershell.exe -STA -ExecutionPolicy Bypass -File .\XamlDesigner\Start-XamlDesigner.ps1
+```
+
+PowerShell 7 on Windows:
+
+```powershell
+pwsh.exe -STA -File .\XamlDesigner\Start-XamlDesigner.ps1
+```
+
+WPF requires Windows and an STA thread.
+
+## Design direction
+
+Visual Studio's XML editor provides features such as XML syntax checking, schema-aware validation, IntelliSense, snippets, and document outlining. The PowerShell XAML Designer aims to provide the most useful subset for standalone PowerShell/WPF work without requiring a Visual Studio project.
+
+The visual designer intentionally uses a `Canvas` as the default root layout because absolute positioning makes drag/move behavior deterministic. Existing `Grid`, `StackPanel`, `DockPanel`, `WrapPanel`, and `UniformGrid` layouts can still be opened and previewed, and controls can be added to supported root layout containers. Direct coordinate dragging is currently limited to `Canvas` parents.
+
+See [`XamlDesigner/README.md`](./XamlDesigner/README.md) for architecture, workflow, limitations, and planned editor features.
+
+## Existing samples
+
+The original files remain available at the repository root as WPF/PowerShell examples:
+
+- `WPF_CustomGraphicalInputBoxSample.*`
+- `WPF_GraphicalDatePickerSample.*`
+- `WPF_InkCanvas.ps1`
+- `WPF_OCR_Sample.ps1`
+- `WPF_SimpleWeatherFormSample/`
+
+They are useful as real-world XAML compatibility samples for the designer.
+
+## Microsoft references
+
+- Visual Studio XML editor: https://learn.microsoft.com/visualstudio/xml-tools/xml-editor
+- Editing XML files: https://learn.microsoft.com/visualstudio/xml-tools/how-to-edit-xml-files
+- XML editor IntelliSense: https://learn.microsoft.com/visualstudio/xml-tools/xml-editor-intellisense-features
+- WPF drag and drop: https://learn.microsoft.com/dotnet/desktop/wpf/advanced/drag-and-drop-overview
+- WPF dependency properties: https://learn.microsoft.com/dotnet/desktop/wpf/properties/dependency-properties-overview
diff --git a/Tests/Test-CodeGeneration.ps1 b/Tests/Test-CodeGeneration.ps1
new file mode 100644
index 0000000..84617c0
--- /dev/null
+++ b/Tests/Test-CodeGeneration.ps1
@@ -0,0 +1,83 @@
+[CmdletBinding()]
+param()
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+if ($env:OS -ne 'Windows_NT') {
+ Write-Host 'Code-generation test skipped: WPF requires Windows.'
+ return
+}
+
+Add-Type -AssemblyName WindowsBase
+Add-Type -AssemblyName PresentationCore
+Add-Type -AssemblyName PresentationFramework
+
+$repositoryRoot = Split-Path -Parent $PSScriptRoot
+$designerDirectory = Join-Path $repositoryRoot 'XamlDesigner'
+$coreDirectory = Join-Path $designerDirectory 'Core'
+
+. (Join-Path $coreDirectory 'State.ps1')
+. (Join-Path $coreDirectory 'Xml.ps1')
+. (Join-Path $coreDirectory 'CodeBehind.ps1')
+. (Join-Path $coreDirectory 'Events.ps1')
+
+$script:State.BaseDirectory = $designerDirectory
+$script:State.ToolboxItems = @()
+$script:State.XamlDocument = New-XmlDocumentFromText -Text @'
+
+
+
+'@
+
+$referenceLines = @(Get-ControlReferenceLines)
+$expectedReference = '[System.Windows.Controls.Button]${Button1} = $Window.FindName(''Button1'')'
+if ($referenceLines -notcontains $expectedReference) {
+ throw "Expected generated control reference was not found. Actual: $($referenceLines -join '; ')"
+}
+
+$code = Get-Content -LiteralPath (Join-Path $designerDirectory 'Templates\BlankWindow.ps1') -Raw
+$script:State.Ui = @{
+ CodeEditor = [pscustomobject]@{ Text = $code }
+ MainTabs = [pscustomobject]@{ SelectedIndex = 0 }
+ StatusText = [pscustomobject]@{ Text = '' }
+}
+$script:State.CurrentXamlPath = 'C:\Temp\GeneratedWindow.xaml'
+$script:State.SelectedElementName = 'Button1'
+$script:State.SelectedRuntimeElement = [System.Windows.Controls.Button]::new()
+$script:State.SelectedRuntimeElement.Name = 'Button1'
+
+Sync-CodeEditor
+Generate-EventHandlerForName -EventName 'Click'
+$generated = $script:State.Ui.CodeEditor.Text
+
+$eventMarker = '# '
+if (-not $generated.Contains($eventMarker)) {
+ throw 'Generated Click event marker was not found.'
+}
+
+$handlerIndex = $generated.IndexOf('${Button1}.Add_Click(', [System.StringComparison]::Ordinal)
+$showDialogIndex = $generated.IndexOf('$null = $Window.ShowDialog()', [System.StringComparison]::Ordinal)
+if ($handlerIndex -lt 0 -or $showDialogIndex -lt 0 -or $handlerIndex -gt $showDialogIndex) {
+ throw 'Generated event handler must be registered before $Window.ShowDialog().'
+}
+
+$tokens = $null
+$parseErrors = $null
+[void][System.Management.Automation.Language.Parser]::ParseInput(
+ $generated,
+ [ref]$tokens,
+ [ref]$parseErrors
+)
+if ($parseErrors.Count -gt 0) {
+ $messages = $parseErrors | ForEach-Object {
+ "line $($_.Extent.StartLineNumber): $($_.Message)"
+ }
+ throw "Generated PowerShell does not parse: $($messages -join '; ')"
+}
+
+Write-Host 'Generated control references and event registration order are valid.'
diff --git a/Tests/Test-DesignerStartup.ps1 b/Tests/Test-DesignerStartup.ps1
new file mode 100644
index 0000000..e138ea1
--- /dev/null
+++ b/Tests/Test-DesignerStartup.ps1
@@ -0,0 +1,57 @@
+[CmdletBinding()]
+param()
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+if ($env:OS -ne 'Windows_NT') {
+ Write-Host 'Designer startup smoke test skipped: WPF requires Windows.'
+ return
+}
+
+if ([System.Threading.Thread]::CurrentThread.GetApartmentState() -ne [System.Threading.ApartmentState]::STA) {
+ throw 'Designer startup smoke test must run in an STA PowerShell process.'
+}
+
+Add-Type -AssemblyName WindowsBase
+Add-Type -AssemblyName PresentationCore
+Add-Type -AssemblyName PresentationFramework
+
+$repositoryRoot = Split-Path -Parent $PSScriptRoot
+$designerDirectory = Join-Path $repositoryRoot 'XamlDesigner'
+Import-Module (Join-Path $designerDirectory 'XamlDesigner.Core.psm1') -Force
+
+$designerXamlPath = Join-Path $designerDirectory 'XamlDesigner.xaml'
+[xml]$designerXaml = Get-Content -LiteralPath $designerXamlPath -Raw
+$reader = [System.Xml.XmlNodeReader]::new($designerXaml)
+try {
+ [System.Windows.Window]$window = [System.Windows.Markup.XamlReader]::Load($reader)
+}
+finally {
+ $reader.Close()
+}
+
+Initialize-XamlDesigner -Window $window -BaseDirectory $designerDirectory
+
+$requiredControls = @(
+ 'MenuNew','MenuOpen','MenuSave','MenuSaveAs','MenuExit',
+ 'MenuUndo','MenuRedo','MenuDelete','MenuDuplicate','MenuValidate','MenuRefreshToolbox','MenuAbout',
+ 'StatusText','DocumentText','ToolboxSearch','ToolboxCategory','ToolboxList','OutlineTree',
+ 'MainTabs','PreviewBorder','PreviewHost','CheckSnapToGrid',
+ 'ButtonDelete','ButtonDuplicate','ButtonApplyXaml','ButtonFormatXaml','ButtonValidateCode',
+ 'XamlEditor','CodeEditor','SelectedControlText','PropertyGrid',
+ 'PropertyNameText','PropertyValueText','ButtonApplyProperty','EventGrid'
+)
+
+foreach ($name in $requiredControls) {
+ if ($null -eq $window.FindName($name)) {
+ throw "Designer UI smoke test failed: '$name' was not found."
+ }
+}
+
+if ($window.Title -notlike 'PowerShell XAML Designer*') {
+ throw "Designer initialization did not set the expected window title: $($window.Title)"
+}
+
+$window.Close()
+Write-Host 'PowerShell XAML Designer startup smoke test passed.'
diff --git a/Tests/Test-Repository.ps1 b/Tests/Test-Repository.ps1
new file mode 100644
index 0000000..c203fdc
--- /dev/null
+++ b/Tests/Test-Repository.ps1
@@ -0,0 +1,74 @@
+[CmdletBinding()]
+param()
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$repositoryRoot = Split-Path -Parent $PSScriptRoot
+$errorsFound = [System.Collections.Generic.List[string]]::new()
+
+Get-ChildItem -LiteralPath $repositoryRoot -Recurse -File -Include *.ps1,*.psm1 | ForEach-Object {
+ $tokens = $null
+ $parseErrors = $null
+ [void][System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$parseErrors)
+ foreach ($parseError in $parseErrors) {
+ $errorsFound.Add("PowerShell parse error: $($_.FullName):$($parseError.Extent.StartLineNumber):$($parseError.Extent.StartColumnNumber) $($parseError.Message)")
+ }
+}
+
+Get-ChildItem -LiteralPath $repositoryRoot -Recurse -File -Include *.xaml | ForEach-Object {
+ try {
+ $settings = [System.Xml.XmlReaderSettings]::new()
+ $settings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit
+ $settings.XmlResolver = $null
+ $reader = [System.Xml.XmlReader]::Create($_.FullName, $settings)
+ try {
+ $document = [System.Xml.XmlDocument]::new()
+ $document.XmlResolver = $null
+ $document.Load($reader)
+ }
+ finally {
+ $reader.Close()
+ }
+ }
+ catch {
+ $errorsFound.Add("XML parse error: $($_.FullName): $($_.Exception.Message)")
+ }
+}
+
+$requiredFiles = @(
+ 'XamlDesigner\Start-XamlDesigner.ps1',
+ 'XamlDesigner\XamlDesigner.xaml',
+ 'XamlDesigner\XamlDesigner.Core.psm1',
+ 'XamlDesigner\Templates\BlankWindow.xaml',
+ 'XamlDesigner\Templates\BlankWindow.ps1'
+)
+foreach ($relativePath in $requiredFiles) {
+ if (-not (Test-Path -LiteralPath (Join-Path $repositoryRoot $relativePath))) {
+ $errorsFound.Add("Required designer file is missing: $relativePath")
+ }
+}
+
+$templateCodePath = Join-Path $repositoryRoot 'XamlDesigner\Templates\BlankWindow.ps1'
+if (Test-Path -LiteralPath $templateCodePath) {
+ $templateCode = Get-Content -LiteralPath $templateCodePath -Raw
+ foreach ($marker in @(
+ '# ',
+ '# ',
+ '# ',
+ '# ',
+ '# ',
+ '# '
+ )) {
+ if (-not $templateCode.Contains($marker)) {
+ $errorsFound.Add("Generated-code marker is missing from BlankWindow.ps1: $marker")
+ }
+ }
+}
+
+if ($errorsFound.Count -gt 0) {
+ $errorsFound | ForEach-Object { Write-Error $_ }
+ throw "$($errorsFound.Count) repository validation error(s) found."
+}
+
+Write-Host 'PowerShell syntax, XAML/XML well-formedness, required files, and generated-code marker checks passed.'
diff --git a/XamlDesigner/Core/CodeBehind.ps1 b/XamlDesigner/Core/CodeBehind.ps1
new file mode 100644
index 0000000..83e71b4
--- /dev/null
+++ b/XamlDesigner/Core/CodeBehind.ps1
@@ -0,0 +1,177 @@
+function Sync-CodeBehindXamlFileName {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Code,
+
+ [Parameter(Mandatory)]
+ [string]$XamlFileName
+ )
+
+ $start = '# '
+ $end = '# '
+ $replacement = @"
+$start
+`$xamlFileName = '$($XamlFileName.Replace("'", "''"))'
+$end
+"@
+
+ $pattern = [regex]::Escape($start) + '.*?' + [regex]::Escape($end)
+ if ([regex]::IsMatch($Code, $pattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)) {
+ return [regex]::Replace(
+ $Code,
+ $pattern,
+ [System.Text.RegularExpressions.MatchEvaluator]{ param($match) $replacement },
+ [System.Text.RegularExpressions.RegexOptions]::Singleline
+ )
+ }
+
+ return $replacement + "`r`n`r`n" + $Code
+}
+
+function Get-ControlReferenceLines {
+ $lines = [System.Collections.Generic.List[string]]::new()
+
+ foreach ($item in (Get-AllNamedXamlElements | Sort-Object Name)) {
+ $type = Get-WpfTypeByElementName -ElementName $item.ElementName
+ $typeName = 'System.Windows.FrameworkElement'
+ if ($null -ne $type -and -not [string]::IsNullOrWhiteSpace($type.FullName)) {
+ $typeName = $type.FullName
+ }
+
+ $escapedName = $item.Name.Replace("'", "''")
+ $referenceLine = '[{0}]${{{1}}} = $Window.FindName(''{2}'')' -f $typeName, $item.Name, $escapedName
+ $lines.Add($referenceLine)
+ }
+
+ return $lines
+}
+
+function Sync-CodeBehindControlReferences {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Code
+ )
+
+ $start = '# '
+ $end = '# '
+ $lines = Get-ControlReferenceLines
+ $body = if ($lines.Count -gt 0) {
+ $lines -join "`r`n"
+ }
+ else {
+ '# No named child controls.'
+ }
+
+ $replacement = "$start`r`n$body`r`n$end"
+ $pattern = [regex]::Escape($start) + '.*?' + [regex]::Escape($end)
+
+ if ([regex]::IsMatch($Code, $pattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)) {
+ return [regex]::Replace(
+ $Code,
+ $pattern,
+ [System.Text.RegularExpressions.MatchEvaluator]{ param($match) $replacement },
+ [System.Text.RegularExpressions.RegexOptions]::Singleline
+ )
+ }
+
+ return $Code.TrimEnd() + "`r`n`r`n$replacement`r`n"
+}
+
+function Sync-CodeBehindEventRegion {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Code
+ )
+
+ $start = '# '
+ $end = '# '
+ if ($Code.Contains($start) -and $Code.Contains($end)) {
+ return $Code
+ }
+
+ $region = "$start`r`n# Event handlers generated by PowerShell XAML Designer are inserted here.`r`n$end"
+ $showDialogRegex = [regex]::new(
+ '^\s*\$null\s*=\s*\$Window\.ShowDialog\(\)\s*$',
+ [System.Text.RegularExpressions.RegexOptions]::Multiline
+ )
+
+ if ($showDialogRegex.IsMatch($Code)) {
+ return $showDialogRegex.Replace(
+ $Code,
+ [System.Text.RegularExpressions.MatchEvaluator]{
+ param($match)
+ return $region + "`r`n`r`n" + $match.Value
+ },
+ 1
+ )
+ }
+
+ return $Code.TrimEnd() + "`r`n`r`n$region`r`n"
+}
+
+function Sync-CodeEditor {
+ $code = $script:State.Ui.CodeEditor.Text
+ $xamlName = 'Untitled.xaml'
+
+ if (-not [string]::IsNullOrWhiteSpace($script:State.CurrentXamlPath)) {
+ $xamlName = Split-Path -Leaf $script:State.CurrentXamlPath
+ }
+
+ $code = Sync-CodeBehindXamlFileName -Code $code -XamlFileName $xamlName
+ $code = Sync-CodeBehindControlReferences -Code $code
+ $code = Sync-CodeBehindEventRegion -Code $code
+ $script:State.Ui.CodeEditor.Text = $code
+}
+
+function Test-CodeEditorPowerShell {
+ $tokens = $null
+ $parseErrors = $null
+
+ [void][System.Management.Automation.Language.Parser]::ParseInput(
+ $script:State.Ui.CodeEditor.Text,
+ [ref]$tokens,
+ [ref]$parseErrors
+ )
+
+ if ($null -ne $parseErrors -and $parseErrors.Count -gt 0) {
+ $first = $parseErrors[0]
+ $message = "PowerShell error at line $($first.Extent.StartLineNumber), column $($first.Extent.StartColumnNumber): $($first.Message)"
+ Set-DesignerStatus -Message $message
+ return $false
+ }
+
+ Set-DesignerStatus -Message 'PowerShell code-behind syntax is valid.'
+ return $true
+}
+
+function Rename-GeneratedEventControlReference {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Code,
+
+ [Parameter(Mandatory)]
+ [string]$OldName,
+
+ [Parameter(Mandatory)]
+ [string]$NewName
+ )
+
+ if ($OldName -eq $NewName) {
+ return $Code
+ }
+
+ $pattern = '(?ms)^# .*?^# $'
+
+ return [regex]::Replace(
+ $Code,
+ $pattern,
+ [System.Text.RegularExpressions.MatchEvaluator]{
+ param($match)
+
+ $block = $match.Value
+ $block = $block.Replace('Control="' + $OldName + '"', 'Control="' + $NewName + '"')
+ $block = $block.Replace('$' + '{' + $OldName + '}', '$' + '{' + $NewName + '}')
+ return $block
+ }
+ )
+}
diff --git a/XamlDesigner/Core/Documents.ps1 b/XamlDesigner/Core/Documents.ps1
new file mode 100644
index 0000000..07fa006
--- /dev/null
+++ b/XamlDesigner/Core/Documents.ps1
@@ -0,0 +1,197 @@
+function New-XamlDesignerDocument {
+ if (-not (Confirm-ContinueWithUnsavedChanges)) {
+ return
+ }
+
+ $script:State.XamlDocument = New-XmlDocumentFromText -Text (Get-BlankXamlText)
+ $script:State.CurrentXamlPath = $null
+ $script:State.CurrentCodePath = $null
+ $script:State.SelectedElementName = $null
+ $script:State.SelectedRuntimeElement = $null
+ Reset-XamlHistory
+ $script:State.Ui.CodeEditor.Text = Get-BlankCodeText
+ Refresh-XamlTextFromDocument
+ [void](Refresh-Preview)
+ Update-DocumentCaption
+ Set-DocumentSavedSnapshot
+ Set-DesignerStatus -Message 'Created a new XAML + PowerShell document pair.'
+}
+
+function Open-XamlDesignerDocument {
+ if (-not (Confirm-ContinueWithUnsavedChanges)) {
+ return
+ }
+
+ $dialog = [Microsoft.Win32.OpenFileDialog]::new()
+ $dialog.Filter = 'XAML files (*.xaml)|*.xaml|XML files (*.xml)|*.xml|All files (*.*)|*.*'
+ $dialog.Title = 'Open XAML file'
+ if ($dialog.ShowDialog() -ne $true) {
+ return
+ }
+
+ try {
+ $text = Get-Content -LiteralPath $dialog.FileName -Raw
+ $document = New-XmlDocumentFromText -Text $text
+ $oldDocument = $script:State.XamlDocument
+ $script:State.XamlDocument = $document
+
+ if (-not (Refresh-Preview)) {
+ $script:State.XamlDocument = $oldDocument
+ throw 'The selected file is well-formed XML but could not be loaded as a WPF Window.'
+ }
+
+ $script:State.CurrentXamlPath = $dialog.FileName
+ $script:State.CurrentCodePath = [System.IO.Path]::ChangeExtension($dialog.FileName, '.ps1')
+ Reset-XamlHistory
+ Refresh-XamlTextFromDocument
+
+ if (Test-Path -LiteralPath $script:State.CurrentCodePath) {
+ $script:State.Ui.CodeEditor.Text = Get-Content -LiteralPath $script:State.CurrentCodePath -Raw
+ }
+ else {
+ $script:State.Ui.CodeEditor.Text = Get-BlankCodeText
+ }
+
+ Sync-CodeEditor
+ Update-DocumentCaption
+ Set-DocumentSavedSnapshot
+ Set-DesignerStatus -Message "Opened $($dialog.FileName)"
+ }
+ catch {
+ [System.Windows.MessageBox]::Show(
+ $_.Exception.Message,
+ 'Open failed',
+ [System.Windows.MessageBoxButton]::OK,
+ [System.Windows.MessageBoxImage]::Error
+ ) | Out-Null
+ }
+}
+
+function Apply-XamlEditorText {
+ try {
+ $candidate = New-XmlDocumentFromText -Text $script:State.Ui.XamlEditor.Text
+ }
+ catch [System.Xml.XmlException] {
+ $ex = $_.Exception
+ Set-DesignerStatus -Message "XML error at line $($ex.LineNumber), position $($ex.LinePosition): $($ex.Message)"
+ return $false
+ }
+ catch {
+ Set-DesignerStatus -Message ("XML error: " + $_.Exception.Message)
+ return $false
+ }
+
+ $old = $script:State.XamlDocument
+ $oldText = if ($null -ne $old) { ConvertTo-FormattedXml -Document $old } else { $null }
+ $candidateText = ConvertTo-FormattedXml -Document $candidate
+ $recordHistory = (
+ -not $script:State.IsRestoringHistory -and
+ $null -ne $oldText -and
+ $oldText -cne $candidateText
+ )
+
+ $script:State.XamlDocument = $candidate
+ if (-not (Refresh-Preview -KeepSelection)) {
+ $script:State.XamlDocument = $old
+ return $false
+ }
+
+ if ($recordHistory) {
+ Push-XamlUndoSnapshot -Text $oldText
+ }
+
+ Refresh-XamlTextFromDocument
+ Sync-CodeEditor
+ return $true
+}
+
+function Confirm-CodeBehindOverwrite {
+ param(
+ [Parameter(Mandatory)]
+ [string]$CodePath
+ )
+
+ if (-not (Test-Path -LiteralPath $CodePath)) {
+ return $true
+ }
+
+ if (
+ -not [string]::IsNullOrWhiteSpace($script:State.CurrentCodePath) -and
+ [string]::Equals(
+ [System.IO.Path]::GetFullPath($CodePath),
+ [System.IO.Path]::GetFullPath($script:State.CurrentCodePath),
+ [System.StringComparison]::OrdinalIgnoreCase
+ )
+ ) {
+ return $true
+ }
+
+ $result = [System.Windows.MessageBox]::Show(
+ "The paired PowerShell file already exists and will also be replaced:`r`n`r`n$CodePath`r`n`r`nContinue?",
+ 'Replace paired PowerShell file?',
+ [System.Windows.MessageBoxButton]::YesNo,
+ [System.Windows.MessageBoxImage]::Warning
+ )
+
+ return $result -eq [System.Windows.MessageBoxResult]::Yes
+}
+
+function Save-XamlDesignerDocument {
+ param(
+ [switch]$SaveAs
+ )
+
+ if (-not (Apply-XamlEditorText)) {
+ [System.Windows.MessageBox]::Show(
+ 'The XAML contains an error. Fix the error before saving.',
+ 'Save blocked',
+ [System.Windows.MessageBoxButton]::OK,
+ [System.Windows.MessageBoxImage]::Warning
+ ) | Out-Null
+ return
+ }
+
+ if (-not (Test-CodeEditorPowerShell)) {
+ [System.Windows.MessageBox]::Show(
+ 'The PowerShell code-behind contains a syntax error. Fix the error before saving.',
+ 'Save blocked',
+ [System.Windows.MessageBoxButton]::OK,
+ [System.Windows.MessageBoxImage]::Warning
+ ) | Out-Null
+ return
+ }
+
+ if ($SaveAs -or [string]::IsNullOrWhiteSpace($script:State.CurrentXamlPath)) {
+ $dialog = [Microsoft.Win32.SaveFileDialog]::new()
+ $dialog.Filter = 'XAML files (*.xaml)|*.xaml'
+ $dialog.DefaultExt = '.xaml'
+ $dialog.AddExtension = $true
+ $dialog.OverwritePrompt = $true
+ $dialog.Title = 'Save XAML and PowerShell code-behind'
+
+ if ($dialog.ShowDialog() -ne $true) {
+ return
+ }
+
+ $candidateXamlPath = $dialog.FileName
+ $candidateCodePath = [System.IO.Path]::ChangeExtension($candidateXamlPath, '.ps1')
+ if (-not (Confirm-CodeBehindOverwrite -CodePath $candidateCodePath)) {
+ Set-DesignerStatus -Message 'Save As cancelled; paired PowerShell file was not overwritten.'
+ return
+ }
+
+ $script:State.CurrentXamlPath = $candidateXamlPath
+ $script:State.CurrentCodePath = $candidateCodePath
+ }
+
+ Sync-CodeEditor
+ $xamlText = ConvertTo-FormattedXml -Document $script:State.XamlDocument
+ $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
+
+ [System.IO.File]::WriteAllText($script:State.CurrentXamlPath, $xamlText, $utf8NoBom)
+ [System.IO.File]::WriteAllText($script:State.CurrentCodePath, $script:State.Ui.CodeEditor.Text, $utf8NoBom)
+
+ Update-DocumentCaption
+ Set-DocumentSavedSnapshot
+ Set-DesignerStatus -Message "Saved XAML and code-behind: $($script:State.CurrentXamlPath)"
+}
diff --git a/XamlDesigner/Core/Events.ps1 b/XamlDesigner/Core/Events.ps1
new file mode 100644
index 0000000..dec6e79
--- /dev/null
+++ b/XamlDesigner/Core/Events.ps1
@@ -0,0 +1,94 @@
+function Generate-EventHandlerForName {
+ param(
+ [Parameter(Mandatory)]
+ [string]$EventName
+ )
+
+ if ([string]::IsNullOrWhiteSpace($script:State.SelectedElementName) -or
+ $script:State.SelectedRuntimeElement -isnot [System.Windows.FrameworkElement]) {
+ Set-DesignerStatus -Message 'Select a named control before generating an event handler.'
+ return
+ }
+
+ $name = $script:State.SelectedElementName
+ $availableEvents = @($script:State.SelectedRuntimeElement.GetType().GetEvents([System.Reflection.BindingFlags]'Public,Instance') | ForEach-Object Name)
+ if ($availableEvents -notcontains $EventName) {
+ Set-DesignerStatus -Message "Event '$EventName' is not available on $name."
+ return
+ }
+
+ Sync-CodeEditor
+ $code = $script:State.Ui.CodeEditor.Text
+
+ $marker = '# '
+ $legacyPattern = [regex]::Escape('$' + $name + '.Add_' + $EventName + '(')
+ $variableReference = '$' + '{' + $name + '}'
+ $bracedPattern = [regex]::Escape($variableReference + '.Add_' + $EventName + '(')
+ if ($code.Contains($marker) -or
+ [regex]::IsMatch($code, $legacyPattern) -or
+ [regex]::IsMatch($code, $bracedPattern)) {
+ Set-DesignerStatus -Message "An $EventName handler for $name already exists."
+ $script:State.Ui.MainTabs.SelectedIndex = 2
+ return
+ }
+
+ $block = "$marker`r`n" +
+ $variableReference + ".Add_$EventName({`r`n" +
+ " param(`$sender, `$e)`r`n`r`n" +
+ " # TODO: Add $EventName logic for $name.`r`n" +
+ "})`r`n# `r`n"
+
+ $eventsEnd = '# '
+ if (-not $code.Contains($eventsEnd)) {
+ Set-DesignerStatus -Message 'The generated event region is missing from the PowerShell code-behind.'
+ return
+ }
+
+ $script:State.Ui.CodeEditor.Text = $code.Replace($eventsEnd, $block + $eventsEnd)
+ $script:State.Ui.MainTabs.SelectedIndex = 2
+ Set-DesignerStatus -Message "Generated PowerShell event handler: $name.$EventName"
+}
+
+function Generate-SelectedEventHandler {
+ $selectedEvent = $script:State.Ui.EventGrid.SelectedItem
+ if ($null -eq $selectedEvent) {
+ return
+ }
+ Generate-EventHandlerForName -EventName ([string]$selectedEvent.Name)
+}
+
+function Get-DefaultDesignerEventName {
+ param(
+ [Parameter(Mandatory)]
+ [System.Windows.FrameworkElement]$Element
+ )
+
+ $eventNames = @($Element.GetType().GetEvents([System.Reflection.BindingFlags]'Public,Instance') | ForEach-Object Name)
+ foreach ($candidate in @('Click','Checked','SelectionChanged','TextChanged','ValueChanged','SelectedDateChanged','MouseDoubleClick','Loaded')) {
+ if ($eventNames -contains $candidate) {
+ return $candidate
+ }
+ }
+ return $null
+}
+
+function Update-SelectedCanvasPosition {
+ param(
+ [Parameter(Mandatory)]
+ [double]$Left,
+
+ [Parameter(Mandatory)]
+ [double]$Top
+ )
+
+ if ([string]::IsNullOrWhiteSpace($script:State.SelectedElementName)) {
+ return
+ }
+ $node = Get-XamlElementByName -Name $script:State.SelectedElementName
+ if ($null -eq $node) {
+ return
+ }
+ $node.SetAttribute('Canvas.Left', $Left.ToString([System.Globalization.CultureInfo]::InvariantCulture))
+ $node.SetAttribute('Canvas.Top', $Top.ToString([System.Globalization.CultureInfo]::InvariantCulture))
+ Refresh-XamlTextFromDocument
+}
diff --git a/XamlDesigner/Core/History.ps1 b/XamlDesigner/Core/History.ps1
new file mode 100644
index 0000000..3239c87
--- /dev/null
+++ b/XamlDesigner/Core/History.ps1
@@ -0,0 +1,71 @@
+function Reset-XamlHistory {
+ $script:State.UndoStack.Clear()
+ $script:State.RedoStack.Clear()
+}
+
+function Push-XamlUndoSnapshot {
+ param(
+ [string]$Text
+ )
+
+ if ($script:State.IsRestoringHistory -or $null -eq $script:State.XamlDocument) {
+ return
+ }
+
+ if ([string]::IsNullOrEmpty($Text)) {
+ $Text = ConvertTo-FormattedXml -Document $script:State.XamlDocument
+ }
+
+ if ($script:State.UndoStack.Count -gt 0 -and $script:State.UndoStack.Peek() -ceq $Text) {
+ return
+ }
+
+ $script:State.UndoStack.Push($Text)
+ $script:State.RedoStack.Clear()
+}
+
+function Restore-XamlHistorySnapshot {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Text
+ )
+
+ $script:State.IsRestoringHistory = $true
+ try {
+ $script:State.XamlDocument = New-XmlDocumentFromText -Text $Text
+ $script:State.SelectedElementName = $null
+ $script:State.SelectedRuntimeElement = $null
+ Refresh-XamlTextFromDocument
+ Sync-CodeEditor
+ [void](Refresh-Preview)
+ }
+ finally {
+ $script:State.IsRestoringHistory = $false
+ }
+}
+
+function Undo-XamlDesignerChange {
+ if ($script:State.UndoStack.Count -eq 0) {
+ Set-DesignerStatus -Message 'Nothing to undo.'
+ return
+ }
+
+ $current = ConvertTo-FormattedXml -Document $script:State.XamlDocument
+ $script:State.RedoStack.Push($current)
+ $previous = $script:State.UndoStack.Pop()
+ Restore-XamlHistorySnapshot -Text $previous
+ Set-DesignerStatus -Message 'Undo completed.'
+}
+
+function Redo-XamlDesignerChange {
+ if ($script:State.RedoStack.Count -eq 0) {
+ Set-DesignerStatus -Message 'Nothing to redo.'
+ return
+ }
+
+ $current = ConvertTo-FormattedXml -Document $script:State.XamlDocument
+ $script:State.UndoStack.Push($current)
+ $next = $script:State.RedoStack.Pop()
+ Restore-XamlHistorySnapshot -Text $next
+ Set-DesignerStatus -Message 'Redo completed.'
+}
diff --git a/XamlDesigner/Core/Outline.ps1 b/XamlDesigner/Core/Outline.ps1
new file mode 100644
index 0000000..d9c034a
--- /dev/null
+++ b/XamlDesigner/Core/Outline.ps1
@@ -0,0 +1,46 @@
+function New-OutlineTreeItem {
+ param(
+ [Parameter(Mandatory)]
+ [System.Xml.XmlElement]$Node
+ )
+
+ $name = Get-ElementNameFromNode -Node $Node
+ $header = $Node.LocalName
+ if (-not [string]::IsNullOrWhiteSpace($name)) {
+ $header += " [$name]"
+ }
+
+ $item = [System.Windows.Controls.TreeViewItem]::new()
+ $item.Header = $header
+ $item.Tag = $name
+ $item.IsExpanded = $true
+
+ if (
+ -not [string]::IsNullOrWhiteSpace($name) -and
+ $name -eq $script:State.SelectedElementName
+ ) {
+ $item.IsSelected = $true
+ }
+
+ foreach ($child in $Node.ChildNodes) {
+ if ($child -is [System.Xml.XmlElement]) {
+ [void]$item.Items.Add((New-OutlineTreeItem -Node $child))
+ }
+ }
+
+ return $item
+}
+
+function Refresh-DocumentOutline {
+ $tree = $script:State.Ui.OutlineTree
+ if ($null -eq $tree) {
+ return
+ }
+
+ $tree.Items.Clear()
+ if ($null -eq $script:State.XamlDocument -or $null -eq $script:State.XamlDocument.DocumentElement) {
+ return
+ }
+
+ [void]$tree.Items.Add((New-OutlineTreeItem -Node $script:State.XamlDocument.DocumentElement))
+}
diff --git a/XamlDesigner/Core/Preview.ps1 b/XamlDesigner/Core/Preview.ps1
new file mode 100644
index 0000000..683c317
--- /dev/null
+++ b/XamlDesigner/Core/Preview.ps1
@@ -0,0 +1,188 @@
+function Get-RuntimePreviewDocument {
+ $clone = [System.Xml.XmlDocument]$script:State.XamlDocument.CloneNode($true)
+ Remove-PowerShellUnsupportedXamlAttributes -Document $clone
+ return $clone
+}
+
+function Find-VisualElementByName {
+ param(
+ [Parameter(Mandatory)]
+ [System.Windows.DependencyObject]$Root,
+
+ [Parameter(Mandatory)]
+ [string]$Name
+ )
+
+ if ($Root -is [System.Windows.FrameworkElement]) {
+ if ($Root.Name -eq $Name) {
+ return $Root
+ }
+
+ # FindName is fast when the element belongs to this namescope.
+ try {
+ $named = $Root.FindName($Name)
+ if ($named -is [System.Windows.FrameworkElement]) {
+ return $named
+ }
+ }
+ catch {
+ # Continue with tree traversal; not every element owns a namescope.
+ }
+ }
+
+ # Prefer the visual tree, but not every WPF DependencyObject is a Visual.
+ try {
+ $count = [System.Windows.Media.VisualTreeHelper]::GetChildrenCount($Root)
+ for ($i = 0; $i -lt $count; $i++) {
+ $child = [System.Windows.Media.VisualTreeHelper]::GetChild($Root, $i)
+ $match = Find-VisualElementByName -Root $child -Name $Name
+ if ($null -ne $match) {
+ return $match
+ }
+ }
+ }
+ catch {
+ # Fall through to LogicalTreeHelper.
+ }
+
+ try {
+ foreach ($child in [System.Windows.LogicalTreeHelper]::GetChildren($Root)) {
+ if ($child -isnot [System.Windows.DependencyObject]) {
+ continue
+ }
+ $match = Find-VisualElementByName -Root $child -Name $Name
+ if ($null -ne $match) {
+ return $match
+ }
+ }
+ }
+ catch {
+ # Some templated/content objects expose neither traversable tree.
+ }
+
+ return $null
+}
+
+function Get-NamedFrameworkElementFromOriginalSource {
+ param(
+ [Parameter(Mandatory)]
+ [object]$OriginalSource
+ )
+
+ $current = $OriginalSource
+ while ($null -ne $current) {
+ if ($current -is [System.Windows.FrameworkElement] -and -not [string]::IsNullOrWhiteSpace($current.Name)) {
+ if ($null -ne (Get-XamlElementByName -Name $current.Name)) {
+ return $current
+ }
+ }
+
+ if ($current -isnot [System.Windows.DependencyObject]) {
+ break
+ }
+
+ $parent = $null
+ try {
+ $parent = [System.Windows.Media.VisualTreeHelper]::GetParent($current)
+ }
+ catch {
+ # ContentElements are not Visuals.
+ }
+
+ if ($null -eq $parent) {
+ try {
+ $parent = [System.Windows.LogicalTreeHelper]::GetParent($current)
+ }
+ catch {
+ $parent = $null
+ }
+ }
+
+ if ($null -eq $parent) {
+ break
+ }
+ $current = $parent
+ }
+ return $null
+}
+
+function Refresh-XamlTextFromDocument {
+ $script:State.Ui.XamlEditor.Text = ConvertTo-FormattedXml -Document $script:State.XamlDocument
+}
+
+function Refresh-Preview {
+ param(
+ [switch]$KeepSelection
+ )
+
+ $selectionName = $script:State.SelectedElementName
+ try {
+ $runtimeDocument = Get-RuntimePreviewDocument
+ $reader = [System.Xml.XmlNodeReader]::new($runtimeDocument)
+ try {
+ $loadedRoot = [System.Windows.Markup.XamlReader]::Load($reader)
+ }
+ finally {
+ $reader.Close()
+ }
+
+ if ($loadedRoot -isnot [System.Windows.Window]) {
+ throw 'The root XAML element must be a WPF Window for this designer.'
+ }
+
+ $script:State.PreviewWindow = $loadedRoot
+ $content = $loadedRoot.Content
+ $loadedRoot.Content = $null
+
+ $host = $script:State.Ui.PreviewHost
+ $host.Children.Clear()
+ if ($null -ne $content) {
+ [void]$host.Children.Add($content)
+ }
+
+ $width = $loadedRoot.Width
+ $height = $loadedRoot.Height
+ if ([double]::IsNaN($width) -or $width -lt 200) { $width = 800 }
+ if ([double]::IsNaN($height) -or $height -lt 150) { $height = 500 }
+ $script:State.Ui.PreviewBorder.Width = $width
+ $script:State.Ui.PreviewBorder.Height = $height
+
+ $script:State.SelectedRuntimeElement = $null
+ if ($KeepSelection -and -not [string]::IsNullOrWhiteSpace($selectionName)) {
+ $runtime = $null
+ if ($loadedRoot.Name -eq $selectionName) {
+ $runtime = $loadedRoot
+ }
+ else {
+ $runtime = Find-VisualElementByName -Root $host -Name $selectionName
+ }
+
+ if ($null -ne $runtime) {
+ $script:State.SelectedRuntimeElement = $runtime
+ }
+ else {
+ $script:State.SelectedElementName = $null
+ }
+ }
+
+ Refresh-DocumentOutline
+ Refresh-SelectionPanels
+ Set-DesignerStatus -Message 'XAML preview updated successfully.'
+ return $true
+ }
+ catch {
+ $script:State.PreviewWindow = $null
+ Set-DesignerStatus -Message ("XAML preview error: " + $_.Exception.Message)
+ return $false
+ }
+}
+
+function Update-DocumentCaption {
+ $display = 'Untitled.xaml'
+ if (-not [string]::IsNullOrWhiteSpace($script:State.CurrentXamlPath)) {
+ $display = Split-Path -Leaf $script:State.CurrentXamlPath
+ }
+ $dirtyMarker = if (Test-DesignerDocumentDirty) { '*' } else { '' }
+ $script:State.Ui.DocumentText.Text = "$display$dirtyMarker"
+ $script:State.Window.Title = "PowerShell XAML Designer - $display$dirtyMarker"
+}
diff --git a/XamlDesigner/Core/Properties.ps1 b/XamlDesigner/Core/Properties.ps1
new file mode 100644
index 0000000..7f9caff
--- /dev/null
+++ b/XamlDesigner/Core/Properties.ps1
@@ -0,0 +1,211 @@
+function Get-SimpleEditableProperties {
+ param(
+ [Parameter(Mandatory)]
+ [System.Windows.FrameworkElement]$Element
+ )
+
+ $preferredOrder = @(
+ 'Name',
+ 'Canvas.Left', 'Canvas.Top',
+ 'Grid.Row', 'Grid.Column', 'Grid.RowSpan', 'Grid.ColumnSpan',
+ 'DockPanel.Dock', 'Panel.ZIndex',
+ 'Width', 'Height', 'MinWidth', 'MinHeight', 'MaxWidth', 'MaxHeight',
+ 'Margin', 'HorizontalAlignment', 'VerticalAlignment', 'Visibility', 'IsEnabled',
+ 'Background', 'Foreground', 'BorderBrush', 'BorderThickness',
+ 'FontFamily', 'FontSize', 'FontWeight', 'FontStyle',
+ 'Content', 'Text', 'ToolTip', 'Opacity'
+ )
+
+ $items = [System.Collections.Generic.List[object]]::new()
+ $parent = $Element.Parent
+
+ if ($parent -is [System.Windows.Controls.Canvas]) {
+ $left = [System.Windows.Controls.Canvas]::GetLeft($Element)
+ $top = [System.Windows.Controls.Canvas]::GetTop($Element)
+ if ([double]::IsNaN($left)) { $left = 0 }
+ if ([double]::IsNaN($top)) { $top = 0 }
+
+ $items.Add([pscustomobject]@{ Name = 'Canvas.Left'; Value = [string]$left; TypeName = 'System.Double'; IsAttached = $true })
+ $items.Add([pscustomobject]@{ Name = 'Canvas.Top'; Value = [string]$top; TypeName = 'System.Double'; IsAttached = $true })
+ }
+
+ if ($parent -is [System.Windows.Controls.Grid]) {
+ $items.Add([pscustomobject]@{ Name = 'Grid.Row'; Value = [string][System.Windows.Controls.Grid]::GetRow($Element); TypeName = 'System.Int32'; IsAttached = $true })
+ $items.Add([pscustomobject]@{ Name = 'Grid.Column'; Value = [string][System.Windows.Controls.Grid]::GetColumn($Element); TypeName = 'System.Int32'; IsAttached = $true })
+ $items.Add([pscustomobject]@{ Name = 'Grid.RowSpan'; Value = [string][System.Windows.Controls.Grid]::GetRowSpan($Element); TypeName = 'System.Int32'; IsAttached = $true })
+ $items.Add([pscustomobject]@{ Name = 'Grid.ColumnSpan'; Value = [string][System.Windows.Controls.Grid]::GetColumnSpan($Element); TypeName = 'System.Int32'; IsAttached = $true })
+ }
+
+ if ($parent -is [System.Windows.Controls.DockPanel]) {
+ $items.Add([pscustomobject]@{ Name = 'DockPanel.Dock'; Value = [string][System.Windows.Controls.DockPanel]::GetDock($Element); TypeName = 'System.Windows.Controls.Dock'; IsAttached = $true })
+ }
+
+ if ($parent -is [System.Windows.Controls.Panel]) {
+ $items.Add([pscustomobject]@{ Name = 'Panel.ZIndex'; Value = [string][System.Windows.Controls.Panel]::GetZIndex($Element); TypeName = 'System.Int32'; IsAttached = $true })
+ }
+
+ $properties = @($Element.GetType().GetProperties([System.Reflection.BindingFlags]'Public,Instance') | Where-Object {
+ $_.CanRead -and $_.CanWrite -and $_.GetIndexParameters().Count -eq 0
+ })
+
+ $propertyItems = foreach ($property in $properties) {
+ $type = $property.PropertyType
+ $converter = [System.ComponentModel.TypeDescriptor]::GetConverter($type)
+ $editable = $type.IsEnum -or $type -eq [string] -or $type.IsPrimitive -or $type -eq [decimal] -or $converter.CanConvertFrom([string])
+ if (-not $editable) {
+ continue
+ }
+
+ try {
+ $value = $property.GetValue($Element, $null)
+ if ($null -eq $value) {
+ $text = ''
+ }
+ elseif ($converter.CanConvertTo([string])) {
+ try {
+ $text = $converter.ConvertToInvariantString($value)
+ }
+ catch {
+ $text = [string]$value
+ }
+ }
+ else {
+ $text = [string]$value
+ }
+
+ [pscustomobject]@{
+ Name = $property.Name
+ Value = $text
+ TypeName = $type.FullName
+ IsAttached = $false
+ }
+ }
+ catch {
+ # Some WPF properties throw when queried outside a complete visual tree.
+ }
+ }
+
+ $ordered = @($propertyItems | Sort-Object @{ Expression = {
+ $index = [array]::IndexOf($preferredOrder, $_.Name)
+ if ($index -lt 0) { 1000 } else { $index }
+ } }, Name)
+
+ foreach ($item in $ordered) {
+ $items.Add($item)
+ }
+
+ return $items
+}
+
+function Refresh-PropertyGrid {
+ $grid = $script:State.Ui.PropertyGrid
+ $grid.ItemsSource = $null
+ $script:State.Ui.PropertyNameText.Text = 'Select a property'
+ $script:State.Ui.PropertyValueText.Text = ''
+
+ $element = $script:State.SelectedRuntimeElement
+ if ($element -isnot [System.Windows.FrameworkElement]) {
+ return
+ }
+ $grid.ItemsSource = Get-SimpleEditableProperties -Element $element
+}
+
+function Refresh-EventGrid {
+ $grid = $script:State.Ui.EventGrid
+ $grid.ItemsSource = $null
+ $element = $script:State.SelectedRuntimeElement
+ if ($element -isnot [System.Windows.FrameworkElement]) {
+ return
+ }
+
+ $events = @($element.GetType().GetEvents([System.Reflection.BindingFlags]'Public,Instance') | Sort-Object Name | ForEach-Object {
+ [pscustomobject]@{
+ Name = $_.Name
+ DeclaringType = $_.DeclaringType.Name
+ }
+ })
+ $grid.ItemsSource = $events
+}
+
+function Refresh-SelectionPanels {
+ if ([string]::IsNullOrWhiteSpace($script:State.SelectedElementName) -or $null -eq $script:State.SelectedRuntimeElement) {
+ $script:State.Ui.SelectedControlText.Text = 'No control selected'
+ }
+ else {
+ $script:State.Ui.SelectedControlText.Text = "$($script:State.SelectedElementName) : $($script:State.SelectedRuntimeElement.GetType().Name)"
+ }
+ Refresh-PropertyGrid
+ Refresh-EventGrid
+}
+
+function Select-DesignerElement {
+ param(
+ [Parameter(Mandatory)]
+ [System.Windows.FrameworkElement]$Element
+ )
+
+ $script:State.SelectedRuntimeElement = $Element
+ $script:State.SelectedElementName = $Element.Name
+ Refresh-SelectionPanels
+ Set-DesignerStatus -Message "Selected $($Element.Name)."
+}
+
+function Apply-SelectedProperty {
+ $selected = $script:State.Ui.PropertyGrid.SelectedItem
+ if ($null -eq $selected -or [string]::IsNullOrWhiteSpace($script:State.SelectedElementName)) {
+ return
+ }
+
+ $node = Get-XamlElementByName -Name $script:State.SelectedElementName
+ if ($null -eq $node) {
+ return
+ }
+
+ $propertyName = [string]$selected.Name
+ $value = $script:State.Ui.PropertyValueText.Text
+ $oldXml = ConvertTo-FormattedXml -Document $script:State.XamlDocument
+ $oldName = $script:State.SelectedElementName
+
+ try {
+ if ($propertyName -eq 'Name') {
+ if ([string]::IsNullOrWhiteSpace($value) -or $value -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') {
+ throw 'x:Name must start with a letter or underscore and contain only letters, digits, and underscores.'
+ }
+ $existing = Get-XamlElementByName -Name $value
+ if ($value -ne $oldName -and $null -ne $existing) {
+ throw "A control named '$value' already exists."
+ }
+ Set-ElementNameOnNode -Node $node -Name $value
+ if ($value -ne $oldName) {
+ $script:State.Ui.CodeEditor.Text = Rename-GeneratedEventControlReference -Code $script:State.Ui.CodeEditor.Text -OldName $oldName -NewName $value
+ }
+ $script:State.SelectedElementName = $value
+ }
+ elseif ([string]::IsNullOrWhiteSpace($value)) {
+ $node.RemoveAttribute($propertyName)
+ }
+ else {
+ $node.SetAttribute($propertyName, $value)
+ }
+
+ if (-not (Refresh-Preview -KeepSelection)) {
+ throw 'The property value is not valid for this XAML element.'
+ }
+ Push-XamlUndoSnapshot -Text $oldXml
+ Refresh-XamlTextFromDocument
+ Sync-CodeEditor
+ Set-DesignerStatus -Message "Applied $propertyName to $($script:State.SelectedElementName)."
+ }
+ catch {
+ $script:State.XamlDocument = New-XmlDocumentFromText -Text $oldXml
+ $script:State.SelectedElementName = $oldName
+ [void](Refresh-Preview -KeepSelection)
+ Refresh-XamlTextFromDocument
+ [System.Windows.MessageBox]::Show(
+ $_.Exception.Message,
+ 'Property update failed',
+ [System.Windows.MessageBoxButton]::OK,
+ [System.Windows.MessageBoxImage]::Warning
+ ) | Out-Null
+ }
+}
diff --git a/XamlDesigner/Core/State.ps1 b/XamlDesigner/Core/State.ps1
new file mode 100644
index 0000000..e4eae53
--- /dev/null
+++ b/XamlDesigner/Core/State.ps1
@@ -0,0 +1,101 @@
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$script:XamlNs = 'http://schemas.microsoft.com/winfx/2006/xaml'
+$script:PresentationNs = 'http://schemas.microsoft.com/winfx/2006/xaml/presentation'
+$script:McNs = 'http://schemas.openxmlformats.org/markup-compatibility/2006'
+$script:DesignNs = 'http://schemas.microsoft.com/expression/blend/2008'
+
+$script:State = [ordered]@{
+ Window = $null
+ BaseDirectory = $null
+ Ui = @{}
+ XamlDocument = $null
+ CurrentXamlPath = $null
+ CurrentCodePath = $null
+ SavedXamlText = $null
+ SavedCodeText = $null
+ SelectedElementName = $null
+ SelectedRuntimeElement = $null
+ PreviewWindow = $null
+ ToolboxItems = @()
+ ToolboxDragOrigin = $null
+ DesignerDragActive = $false
+ DesignerDragOrigin = $null
+ DesignerDragStartLeft = 0.0
+ DesignerDragStartTop = 0.0
+ DesignerDragCanvas = $null
+ UndoStack = [System.Collections.Generic.Stack[string]]::new()
+ RedoStack = [System.Collections.Generic.Stack[string]]::new()
+ IsRestoringHistory = $false
+}
+
+function Set-DesignerStatus {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Message
+ )
+
+ if ($null -ne $script:State.Ui.StatusText) {
+ $script:State.Ui.StatusText.Text = $Message
+ }
+}
+
+function Get-UiControl {
+ param(
+ [Parameter(Mandatory)]
+ [System.Windows.Window]$Window,
+
+ [Parameter(Mandatory)]
+ [string]$Name
+ )
+
+ $control = $Window.FindName($Name)
+ if ($null -eq $control) {
+ throw "Required UI control '$Name' was not found in XamlDesigner.xaml."
+ }
+ return $control
+}
+
+function Set-DocumentSavedSnapshot {
+ $script:State.SavedXamlText = $script:State.Ui.XamlEditor.Text
+ $script:State.SavedCodeText = $script:State.Ui.CodeEditor.Text
+ Update-DocumentCaption
+}
+
+function Test-DesignerDocumentDirty {
+ if ($null -eq $script:State.XamlDocument) {
+ return $false
+ }
+
+ return (
+ $script:State.Ui.XamlEditor.Text -cne [string]$script:State.SavedXamlText -or
+ $script:State.Ui.CodeEditor.Text -cne [string]$script:State.SavedCodeText
+ )
+}
+
+function Confirm-ContinueWithUnsavedChanges {
+ if (-not (Test-DesignerDocumentDirty)) {
+ return $true
+ }
+
+ $result = [System.Windows.MessageBox]::Show(
+ 'The current XAML/PowerShell pair has unsaved changes. Save before continuing?',
+ 'Unsaved changes',
+ [System.Windows.MessageBoxButton]::YesNoCancel,
+ [System.Windows.MessageBoxImage]::Question
+ )
+
+ switch ($result) {
+ ([System.Windows.MessageBoxResult]::Yes) {
+ Save-XamlDesignerDocument
+ return -not (Test-DesignerDocumentDirty)
+ }
+ ([System.Windows.MessageBoxResult]::No) {
+ return $true
+ }
+ default {
+ return $false
+ }
+ }
+}
diff --git a/XamlDesigner/Core/ToolboxCatalog.ps1 b/XamlDesigner/Core/ToolboxCatalog.ps1
new file mode 100644
index 0000000..597a968
--- /dev/null
+++ b/XamlDesigner/Core/ToolboxCatalog.ps1
@@ -0,0 +1,112 @@
+function Get-WpfControlCatalog {
+ $frameworkElementType = [System.Windows.FrameworkElement]
+ $excluded = @(
+ 'System.Windows.Window',
+ 'System.Windows.Navigation.NavigationWindow',
+ 'System.Windows.Controls.Page'
+ )
+
+ $assemblies = @(
+ [System.Windows.Controls.Control].Assembly,
+ [System.Windows.Controls.Panel].Assembly,
+ [System.Windows.Shapes.Shape].Assembly,
+ [System.Windows.FrameworkElement].Assembly
+ ) | Select-Object -Unique
+
+ $types = [System.Collections.Generic.List[Type]]::new()
+ foreach ($assembly in $assemblies) {
+ foreach ($type in $assembly.GetTypes()) {
+ if (-not $type.IsPublic -or $type.IsAbstract -or $type.IsGenericTypeDefinition) {
+ continue
+ }
+ if (-not $frameworkElementType.IsAssignableFrom($type)) {
+ continue
+ }
+ if ($excluded -contains $type.FullName) {
+ continue
+ }
+ if ($null -eq $type.GetConstructor([Type]::EmptyTypes)) {
+ continue
+ }
+ if (-not ($type.Namespace -like 'System.Windows.Controls*' -or $type.Namespace -eq 'System.Windows.Shapes')) {
+ continue
+ }
+ if (-not $types.Contains($type)) {
+ $types.Add($type)
+ }
+ }
+ }
+
+ $catalog = foreach ($type in $types) {
+ $category = 'Other'
+ if ([System.Windows.Controls.Panel].IsAssignableFrom($type)) {
+ $category = 'Panels'
+ }
+ elseif ([System.Windows.Shapes.Shape].IsAssignableFrom($type)) {
+ $category = 'Shapes'
+ }
+ elseif ([System.Windows.Controls.Decorator].IsAssignableFrom($type)) {
+ $category = 'Decorators'
+ }
+ elseif ([System.Windows.Controls.Control].IsAssignableFrom($type)) {
+ $category = 'Controls'
+ }
+
+ [pscustomobject]@{
+ DisplayName = $type.Name
+ FullName = $type.FullName
+ Category = $category
+ Type = $type
+ }
+ }
+
+ return @($catalog | Sort-Object DisplayName, FullName)
+}
+
+function Refresh-ToolboxCatalog {
+ $script:State.ToolboxItems = @(Get-WpfControlCatalog)
+ Apply-ToolboxFilter
+ Set-DesignerStatus -Message "Toolbox loaded $($script:State.ToolboxItems.Count) WPF element types discovered at runtime."
+}
+
+function Apply-ToolboxFilter {
+ $search = $script:State.Ui.ToolboxSearch.Text
+ $categoryItem = $script:State.Ui.ToolboxCategory.SelectedItem
+ $category = 'All'
+ if ($null -ne $categoryItem -and $null -ne $categoryItem.Content) {
+ $category = [string]$categoryItem.Content
+ }
+
+ $filtered = $script:State.ToolboxItems
+ if (-not [string]::IsNullOrWhiteSpace($search)) {
+ $filtered = @($filtered | Where-Object {
+ $_.DisplayName -like "*$search*" -or $_.FullName -like "*$search*"
+ })
+ }
+ if ($category -ne 'All') {
+ $filtered = @($filtered | Where-Object Category -eq $category)
+ }
+ $script:State.Ui.ToolboxList.ItemsSource = $filtered
+}
+
+function New-UniqueControlName {
+ param(
+ [Parameter(Mandatory)]
+ [string]$BaseName
+ )
+
+ $safeBase = $BaseName -replace '[^A-Za-z0-9_]', ''
+ if ([string]::IsNullOrWhiteSpace($safeBase)) {
+ $safeBase = 'Control'
+ }
+ if ($safeBase[0] -match '[0-9]') {
+ $safeBase = '_' + $safeBase
+ }
+
+ $index = 1
+ do {
+ $candidate = "$safeBase$index"
+ $index++
+ } while ($null -ne (Get-XamlElementByName -Name $candidate))
+ return $candidate
+}
diff --git a/XamlDesigner/Core/ToolboxEditing.ps1 b/XamlDesigner/Core/ToolboxEditing.ps1
new file mode 100644
index 0000000..e276ad1
--- /dev/null
+++ b/XamlDesigner/Core/ToolboxEditing.ps1
@@ -0,0 +1,262 @@
+function Set-DefaultNewElementAttributes {
+ param(
+ [Parameter(Mandatory)]
+ [System.Xml.XmlElement]$Node,
+
+ [Parameter(Mandatory)]
+ [Type]$Type,
+
+ [double]$Left = 20,
+
+ [double]$Top = 20,
+
+ [switch]$CanvasParent
+ )
+
+ $name = New-UniqueControlName -BaseName $Type.Name
+ Set-ElementNameOnNode -Node $Node -Name $name
+
+ if ($CanvasParent) {
+ $Node.SetAttribute('Canvas.Left', ([math]::Round($Left)).ToString([System.Globalization.CultureInfo]::InvariantCulture))
+ $Node.SetAttribute('Canvas.Top', ([math]::Round($Top)).ToString([System.Globalization.CultureInfo]::InvariantCulture))
+ }
+
+ $defaults = switch ($Type.Name) {
+ 'Button' { @{ Content = 'Button'; Width = '110'; Height = '32' }; break }
+ 'Label' { @{ Content = 'Label'; Width = '120'; Height = '30' }; break }
+ 'TextBlock' { @{ Text = 'TextBlock'; Width = '140'; Height = '28' }; break }
+ 'TextBox' { @{ Text = ''; Width = '180'; Height = '30' }; break }
+ 'PasswordBox' { @{ Width = '180'; Height = '30' }; break }
+ 'CheckBox' { @{ Content = 'CheckBox'; Width = '130'; Height = '28' }; break }
+ 'RadioButton' { @{ Content = 'RadioButton'; Width = '140'; Height = '28' }; break }
+ 'ComboBox' { @{ Width = '160'; Height = '30' }; break }
+ 'ListBox' { @{ Width = '180'; Height = '120' }; break }
+ 'ListView' { @{ Width = '220'; Height = '140' }; break }
+ 'TreeView' { @{ Width = '220'; Height = '180' }; break }
+ 'Slider' { @{ Width = '180'; Height = '30'; Minimum = '0'; Maximum = '100'; Value = '50' }; break }
+ 'ProgressBar' { @{ Width = '180'; Height = '24'; Minimum = '0'; Maximum = '100'; Value = '50' }; break }
+ 'Image' { @{ Width = '160'; Height = '120'; Stretch = 'Uniform' }; break }
+ 'Border' { @{ Width = '180'; Height = '120'; BorderBrush = 'Gray'; BorderThickness = '1' }; break }
+ 'Canvas' { @{ Width = '240'; Height = '160'; Background = 'Transparent' }; break }
+ 'Grid' { @{ Width = '240'; Height = '160'; Background = 'Transparent' }; break }
+ 'StackPanel' { @{ Width = '220'; Height = '160' }; break }
+ 'WrapPanel' { @{ Width = '220'; Height = '160' }; break }
+ 'DockPanel' { @{ Width = '220'; Height = '160' }; break }
+ 'Rectangle' { @{ Width = '120'; Height = '80'; Stroke = 'Gray'; Fill = 'Transparent' }; break }
+ 'Ellipse' { @{ Width = '120'; Height = '80'; Stroke = 'Gray'; Fill = 'Transparent' }; break }
+ 'Line' { @{ X1 = '0'; Y1 = '0'; X2 = '120'; Y2 = '60'; Stroke = 'Black'; StrokeThickness = '1' }; break }
+ default {
+ if ([System.Windows.Controls.Control].IsAssignableFrom($Type)) {
+ @{ Width = '120'; Height = '32' }
+ }
+ elseif ([System.Windows.Shapes.Shape].IsAssignableFrom($Type)) {
+ @{ Width = '120'; Height = '80'; Stroke = 'Gray' }
+ }
+ else {
+ @{}
+ }
+ }
+ }
+
+ foreach ($key in $defaults.Keys) {
+ $Node.SetAttribute($key, [string]$defaults[$key])
+ }
+ return $name
+}
+
+function Test-XamlLayoutContainerNode {
+ param(
+ [Parameter(Mandatory)]
+ [System.Xml.XmlElement]$Node
+ )
+
+ return $Node.LocalName -in @('Canvas', 'Grid', 'StackPanel', 'WrapPanel', 'DockPanel', 'UniformGrid')
+}
+
+function Test-XamlSingleChildContainerNode {
+ param(
+ [Parameter(Mandatory)]
+ [System.Xml.XmlElement]$Node
+ )
+
+ return $Node.LocalName -in @('Border', 'GroupBox', 'ScrollViewer', 'Viewbox')
+}
+
+function Test-XamlNodeHasDirectElementChild {
+ param(
+ [Parameter(Mandatory)]
+ [System.Xml.XmlElement]$Node
+ )
+
+ foreach ($child in $Node.ChildNodes) {
+ if ($child -is [System.Xml.XmlElement]) {
+ return $true
+ }
+ }
+
+ return $false
+}
+
+function Get-PrimaryDesignContainerNode {
+ if (-not [string]::IsNullOrWhiteSpace($script:State.SelectedElementName)) {
+ $selectedNode = Get-XamlElementByName -Name $script:State.SelectedElementName
+ if ($null -ne $selectedNode -and (Test-XamlLayoutContainerNode -Node $selectedNode)) {
+ return $selectedNode
+ }
+
+ if (
+ $null -ne $selectedNode -and
+ (Test-XamlSingleChildContainerNode -Node $selectedNode) -and
+ -not (Test-XamlNodeHasDirectElementChild -Node $selectedNode)
+ ) {
+ return $selectedNode
+ }
+ }
+
+ $designCanvas = Get-XamlElementByName -Name 'DesignCanvas'
+ if ($null -ne $designCanvas) {
+ return $designCanvas
+ }
+
+ $root = $script:State.XamlDocument.DocumentElement
+ if ($null -eq $root) {
+ return $null
+ }
+
+ foreach ($child in $root.ChildNodes) {
+ if ($child -isnot [System.Xml.XmlElement]) {
+ continue
+ }
+ if (Test-XamlLayoutContainerNode -Node $child) {
+ return $child
+ }
+ }
+ return $null
+}
+
+function Add-ToolboxElementToDocument {
+ param(
+ [Parameter(Mandatory)]
+ [Type]$Type,
+
+ [double]$Left = 20,
+
+ [double]$Top = 20
+ )
+
+ $container = Get-PrimaryDesignContainerNode
+ if ($null -eq $container) {
+ Set-DesignerStatus -Message 'No supported target container was found. Select an empty Border/GroupBox/ScrollViewer/Viewbox or a Canvas/Grid/StackPanel-style panel.'
+ return
+ }
+
+ if ((Test-XamlSingleChildContainerNode -Node $container) -and (Test-XamlNodeHasDirectElementChild -Node $container)) {
+ Set-DesignerStatus -Message "$($container.LocalName) already contains a child. Select another container or edit XAML source."
+ return
+ }
+
+ Push-XamlUndoSnapshot
+ $node = $script:State.XamlDocument.CreateElement($Type.Name, $script:PresentationNs)
+ $isCanvas = $container.LocalName -eq 'Canvas'
+ $name = Set-DefaultNewElementAttributes -Node $node -Type $Type -Left $Left -Top $Top -CanvasParent:$isCanvas
+ [void]$container.AppendChild($node)
+
+ $script:State.SelectedElementName = $name
+ Refresh-XamlTextFromDocument
+ Sync-CodeEditor
+ [void](Refresh-Preview -KeepSelection)
+ $targetName = Get-ElementNameFromNode -Node $container
+ if ([string]::IsNullOrWhiteSpace($targetName)) { $targetName = $container.LocalName }
+ Set-DesignerStatus -Message "Added $($Type.Name) as $name to $targetName."
+}
+
+function Copy-XamlElementNode {
+ param(
+ [Parameter(Mandatory)]
+ [System.Xml.XmlElement]$Source
+ )
+
+ $copy = [System.Xml.XmlElement]$Source.CloneNode($true)
+ $newName = New-UniqueControlName -BaseName $Source.LocalName
+ Set-ElementNameOnNode -Node $copy -Name $newName
+
+ foreach ($attributeName in @('Canvas.Left', 'Canvas.Top')) {
+ if ($copy.HasAttribute($attributeName)) {
+ $value = 0.0
+ if ([double]::TryParse($copy.GetAttribute($attributeName), [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$value)) {
+ $copy.SetAttribute($attributeName, ($value + 20).ToString([System.Globalization.CultureInfo]::InvariantCulture))
+ }
+ }
+ }
+
+ [void]$Source.ParentNode.AppendChild($copy)
+ return $newName
+}
+
+function Delete-SelectedElement {
+ if ([string]::IsNullOrWhiteSpace($script:State.SelectedElementName)) {
+ return
+ }
+ $node = Get-XamlElementByName -Name $script:State.SelectedElementName
+ if ($null -eq $node -or $node -eq $script:State.XamlDocument.DocumentElement) {
+ return
+ }
+ Push-XamlUndoSnapshot
+ $deletedName = $script:State.SelectedElementName
+ [void]$node.ParentNode.RemoveChild($node)
+ $script:State.SelectedElementName = $null
+ $script:State.SelectedRuntimeElement = $null
+ Refresh-XamlTextFromDocument
+ Sync-CodeEditor
+ [void](Refresh-Preview)
+ Set-DesignerStatus -Message "Deleted $deletedName. Existing user-written event code is preserved for manual cleanup."
+}
+
+function Duplicate-SelectedElement {
+ if ([string]::IsNullOrWhiteSpace($script:State.SelectedElementName)) {
+ return
+ }
+ $node = Get-XamlElementByName -Name $script:State.SelectedElementName
+ if ($null -eq $node -or $node -eq $script:State.XamlDocument.DocumentElement) {
+ return
+ }
+ Push-XamlUndoSnapshot
+ $newName = Copy-XamlElementNode -Source $node
+ $script:State.SelectedElementName = $newName
+ Refresh-XamlTextFromDocument
+ Sync-CodeEditor
+ [void](Refresh-Preview -KeepSelection)
+ Set-DesignerStatus -Message "Duplicated control as $newName."
+}
+
+
+function Move-SelectedCanvasElementBy {
+ param(
+ [double]$DeltaX,
+ [double]$DeltaY
+ )
+
+ $element = $script:State.SelectedRuntimeElement
+ if ($element -isnot [System.Windows.FrameworkElement] -or $element.Parent -isnot [System.Windows.Controls.Canvas]) {
+ return $false
+ }
+
+ $left = [System.Windows.Controls.Canvas]::GetLeft($element)
+ $top = [System.Windows.Controls.Canvas]::GetTop($element)
+ if ([double]::IsNaN($left)) { $left = 0 }
+ if ([double]::IsNaN($top)) { $top = 0 }
+
+ $newLeft = [math]::Max(0, $left + $DeltaX)
+ $newTop = [math]::Max(0, $top + $DeltaY)
+ if ([math]::Abs($newLeft - $left) -lt 0.01 -and [math]::Abs($newTop - $top) -lt 0.01) {
+ return $false
+ }
+
+ Push-XamlUndoSnapshot
+ [System.Windows.Controls.Canvas]::SetLeft($element, $newLeft)
+ [System.Windows.Controls.Canvas]::SetTop($element, $newTop)
+ Update-SelectedCanvasPosition -Left $newLeft -Top $newTop
+ Refresh-PropertyGrid
+ Set-DesignerStatus -Message "Moved $($script:State.SelectedElementName) to $newLeft, $newTop."
+ return $true
+}
diff --git a/XamlDesigner/Core/Ui.ps1 b/XamlDesigner/Core/Ui.ps1
new file mode 100644
index 0000000..5c30e90
--- /dev/null
+++ b/XamlDesigner/Core/Ui.ps1
@@ -0,0 +1,352 @@
+function Initialize-UiReferences {
+ param(
+ [Parameter(Mandatory)]
+ [System.Windows.Window]$Window
+ )
+
+ $names = @(
+ 'MenuNew','MenuOpen','MenuSave','MenuSaveAs','MenuExit','MenuUndo','MenuRedo','MenuDelete','MenuDuplicate','MenuValidate','MenuRefreshToolbox','MenuAbout',
+ 'StatusText','DocumentText','ToolboxSearch','ToolboxCategory','ToolboxList','OutlineTree','MainTabs','PreviewBorder','PreviewHost','CheckSnapToGrid',
+ 'ButtonDelete','ButtonDuplicate','ButtonApplyXaml','ButtonFormatXaml','ButtonValidateCode','XamlEditor','CodeEditor','SelectedControlText','PropertyGrid',
+ 'PropertyNameText','PropertyValueText','ButtonApplyProperty','EventGrid'
+ )
+
+ foreach ($name in $names) {
+ $script:State.Ui[$name] = Get-UiControl -Window $Window -Name $name
+ }
+}
+
+function Register-UiEvents {
+ $ui = $script:State.Ui
+
+ $ui.MenuNew.Add_Click({ New-XamlDesignerDocument })
+ $ui.MenuOpen.Add_Click({ Open-XamlDesignerDocument })
+ $ui.MenuSave.Add_Click({ Save-XamlDesignerDocument })
+ $ui.MenuSaveAs.Add_Click({ Save-XamlDesignerDocument -SaveAs })
+ $ui.MenuExit.Add_Click({ $script:State.Window.Close() })
+
+ $script:State.Window.Add_Closing({
+ param($sender, $e)
+ if (-not (Confirm-ContinueWithUnsavedChanges)) {
+ $e.Cancel = $true
+ }
+ })
+ $ui.MenuUndo.Add_Click({ Undo-XamlDesignerChange })
+ $ui.MenuRedo.Add_Click({ Redo-XamlDesignerChange })
+ $ui.MenuDelete.Add_Click({ Delete-SelectedElement })
+ $ui.MenuDuplicate.Add_Click({ Duplicate-SelectedElement })
+ $ui.MenuValidate.Add_Click({ [void](Apply-XamlEditorText) })
+ $ui.MenuRefreshToolbox.Add_Click({ Refresh-ToolboxCatalog })
+ $ui.MenuAbout.Add_Click({
+ [System.Windows.MessageBox]::Show(
+ "PowerShell XAML Designer`r`n`r`nA dependency-free WPF/XAML visual editor implemented in PowerShell. XAML defines the UI; a paired .ps1 file contains control references and event logic.",
+ 'About PowerShell XAML Designer',
+ [System.Windows.MessageBoxButton]::OK,
+ [System.Windows.MessageBoxImage]::Information
+ ) | Out-Null
+ })
+
+ $ui.XamlEditor.Add_TextChanged({ Update-DocumentCaption })
+ $ui.CodeEditor.Add_TextChanged({ Update-DocumentCaption })
+
+ $ui.ButtonDelete.Add_Click({ Delete-SelectedElement })
+ $ui.ButtonDuplicate.Add_Click({ Duplicate-SelectedElement })
+ $ui.ButtonApplyXaml.Add_Click({ [void](Apply-XamlEditorText) })
+ $ui.ButtonValidateCode.Add_Click({ [void](Test-CodeEditorPowerShell) })
+ $ui.ButtonFormatXaml.Add_Click({
+ try {
+ $document = New-XmlDocumentFromText -Text $ui.XamlEditor.Text
+ $ui.XamlEditor.Text = ConvertTo-FormattedXml -Document $document
+ Set-DesignerStatus -Message 'XML formatted.'
+ }
+ catch [System.Xml.XmlException] {
+ Set-DesignerStatus -Message "XML format error at line $($_.Exception.LineNumber), position $($_.Exception.LinePosition): $($_.Exception.Message)"
+ }
+ })
+
+ $ui.OutlineTree.Add_SelectedItemChanged({
+ $item = $ui.OutlineTree.SelectedItem
+ if ($null -eq $item -or [string]::IsNullOrWhiteSpace([string]$item.Tag)) {
+ return
+ }
+ $targetName = [string]$item.Tag
+ $runtime = $null
+ if (
+ $script:State.PreviewWindow -is [System.Windows.Window] -and
+ $script:State.PreviewWindow.Name -eq $targetName
+ ) {
+ $runtime = $script:State.PreviewWindow
+ }
+ else {
+ $runtime = Find-VisualElementByName -Root $ui.PreviewHost -Name $targetName
+ }
+
+ if ($runtime -is [System.Windows.FrameworkElement]) {
+ Select-DesignerElement -Element $runtime
+ }
+ })
+
+ $ui.ToolboxSearch.Add_TextChanged({ Apply-ToolboxFilter })
+ $ui.ToolboxCategory.Add_SelectionChanged({ Apply-ToolboxFilter })
+
+ $ui.ToolboxList.Add_PreviewMouseLeftButtonDown({
+ param($sender, $e)
+ $script:State.ToolboxDragOrigin = $e.GetPosition($sender)
+ })
+
+ $ui.ToolboxList.Add_PreviewMouseMove({
+ param($sender, $e)
+ if ($e.LeftButton -ne [System.Windows.Input.MouseButtonState]::Pressed) {
+ return
+ }
+ if ($null -eq $sender.SelectedItem -or $null -eq $script:State.ToolboxDragOrigin) {
+ return
+ }
+ $position = $e.GetPosition($sender)
+ $deltaX = [math]::Abs($position.X - $script:State.ToolboxDragOrigin.X)
+ $deltaY = [math]::Abs($position.Y - $script:State.ToolboxDragOrigin.Y)
+ if ($deltaX -lt [System.Windows.SystemParameters]::MinimumHorizontalDragDistance -and
+ $deltaY -lt [System.Windows.SystemParameters]::MinimumVerticalDragDistance) {
+ return
+ }
+
+ $data = [System.Windows.DataObject]::new()
+ $data.SetData('WpfTypeFullName', $sender.SelectedItem.FullName)
+ [void][System.Windows.DragDrop]::DoDragDrop($sender, $data, [System.Windows.DragDropEffects]::Copy)
+ })
+
+ $ui.PreviewBorder.Add_DragOver({
+ param($sender, $e)
+ if ($e.Data.GetDataPresent('WpfTypeFullName')) {
+ $e.Effects = [System.Windows.DragDropEffects]::Copy
+ $e.Handled = $true
+ }
+ })
+
+ $ui.PreviewBorder.Add_Drop({
+ param($sender, $e)
+ if (-not $e.Data.GetDataPresent('WpfTypeFullName')) {
+ return
+ }
+ $fullName = [string]$e.Data.GetData('WpfTypeFullName')
+ $item = $script:State.ToolboxItems | Where-Object FullName -eq $fullName | Select-Object -First 1
+ if ($null -eq $item) {
+ return
+ }
+
+ $dropSurface = $null
+ if ($script:State.SelectedRuntimeElement -is [System.Windows.Controls.Canvas]) {
+ $dropSurface = $script:State.SelectedRuntimeElement
+ }
+ if ($null -eq $dropSurface) {
+ $dropSurface = Find-VisualElementByName -Root $ui.PreviewHost -Name 'DesignCanvas'
+ }
+
+ if ($dropSurface -is [System.Windows.Controls.Canvas]) {
+ $point = $e.GetPosition($dropSurface)
+ $left = $point.X
+ $top = $point.Y
+ }
+ else {
+ # Grid/StackPanel/etc. control layout themselves; coordinates are ignored.
+ $left = 20
+ $top = 20
+ }
+ if ($ui.CheckSnapToGrid.IsChecked -eq $true) {
+ $left = [math]::Round($left / 10) * 10
+ $top = [math]::Round($top / 10) * 10
+ }
+ Add-ToolboxElementToDocument -Type $item.Type -Left $left -Top $top
+ $e.Handled = $true
+ })
+
+ $ui.PreviewHost.Add_PreviewMouseLeftButtonDown({
+ param($sender, $e)
+ $element = Get-NamedFrameworkElementFromOriginalSource -OriginalSource $e.OriginalSource
+ if ($null -eq $element) {
+ return
+ }
+ Select-DesignerElement -Element $element
+
+ if ($e.ClickCount -ge 2) {
+ $defaultEvent = Get-DefaultDesignerEventName -Element $element
+ if (-not [string]::IsNullOrWhiteSpace($defaultEvent)) {
+ Generate-EventHandlerForName -EventName $defaultEvent
+ }
+ $e.Handled = $true
+ return
+ }
+
+ if ($element.Parent -is [System.Windows.Controls.Canvas]) {
+ $script:State.DesignerDragActive = $true
+ $script:State.DesignerDragCanvas = $element.Parent
+ $script:State.DesignerDragOrigin = $e.GetPosition($element.Parent)
+ $left = [System.Windows.Controls.Canvas]::GetLeft($element)
+ $top = [System.Windows.Controls.Canvas]::GetTop($element)
+ if ([double]::IsNaN($left)) { $left = 0 }
+ if ([double]::IsNaN($top)) { $top = 0 }
+ $script:State.DesignerDragStartLeft = $left
+ $script:State.DesignerDragStartTop = $top
+ [void]$element.CaptureMouse()
+ }
+ $e.Handled = $true
+ })
+
+ $ui.PreviewHost.Add_PreviewMouseMove({
+ param($sender, $e)
+ if (-not $script:State.DesignerDragActive -or $e.LeftButton -ne [System.Windows.Input.MouseButtonState]::Pressed) {
+ return
+ }
+ $element = $script:State.SelectedRuntimeElement
+ $canvas = $script:State.DesignerDragCanvas
+ if ($element -isnot [System.Windows.FrameworkElement] -or $canvas -isnot [System.Windows.Controls.Canvas]) {
+ return
+ }
+
+ $point = $e.GetPosition($canvas)
+ $left = $script:State.DesignerDragStartLeft + ($point.X - $script:State.DesignerDragOrigin.X)
+ $top = $script:State.DesignerDragStartTop + ($point.Y - $script:State.DesignerDragOrigin.Y)
+ $left = [math]::Max(0, $left)
+ $top = [math]::Max(0, $top)
+ if ($ui.CheckSnapToGrid.IsChecked -eq $true) {
+ $left = [math]::Round($left / 10) * 10
+ $top = [math]::Round($top / 10) * 10
+ }
+ [System.Windows.Controls.Canvas]::SetLeft($element, $left)
+ [System.Windows.Controls.Canvas]::SetTop($element, $top)
+ $e.Handled = $true
+ })
+
+ $ui.PreviewHost.Add_PreviewMouseLeftButtonUp({
+ param($sender, $e)
+ if (-not $script:State.DesignerDragActive) {
+ return
+ }
+ $element = $script:State.SelectedRuntimeElement
+ if ($element -is [System.Windows.FrameworkElement]) {
+ $left = [System.Windows.Controls.Canvas]::GetLeft($element)
+ $top = [System.Windows.Controls.Canvas]::GetTop($element)
+ if (-not [double]::IsNaN($left) -and -not [double]::IsNaN($top)) {
+ $moved = (
+ [math]::Abs($left - $script:State.DesignerDragStartLeft) -gt 0.01 -or
+ [math]::Abs($top - $script:State.DesignerDragStartTop) -gt 0.01
+ )
+ if ($moved) {
+ Push-XamlUndoSnapshot
+ Update-SelectedCanvasPosition -Left $left -Top $top
+ Refresh-PropertyGrid
+ Set-DesignerStatus -Message "Moved $($script:State.SelectedElementName) to $left, $top."
+ }
+ }
+ $element.ReleaseMouseCapture()
+ }
+ $script:State.DesignerDragActive = $false
+ $script:State.DesignerDragCanvas = $null
+ $e.Handled = $true
+ })
+
+ $ui.PropertyGrid.Add_SelectionChanged({
+ $item = $ui.PropertyGrid.SelectedItem
+ if ($null -eq $item) {
+ return
+ }
+ $ui.PropertyNameText.Text = "$($item.Name) [$($item.TypeName)]"
+ $ui.PropertyValueText.Text = [string]$item.Value
+ })
+ $ui.ButtonApplyProperty.Add_Click({ Apply-SelectedProperty })
+ $ui.PropertyValueText.Add_KeyDown({
+ param($sender, $e)
+ if ($e.Key -eq [System.Windows.Input.Key]::Enter) {
+ Apply-SelectedProperty
+ $e.Handled = $true
+ }
+ })
+
+ $ui.EventGrid.Add_MouseDoubleClick({ Generate-SelectedEventHandler })
+
+ $script:State.Window.Add_KeyDown({
+ param($sender, $e)
+
+ $modifiers = [System.Windows.Input.Keyboard]::Modifiers
+ $ctrl = ($modifiers -band [System.Windows.Input.ModifierKeys]::Control) -ne 0
+ $shift = ($modifiers -band [System.Windows.Input.ModifierKeys]::Shift) -ne 0
+ $focused = [System.Windows.Input.Keyboard]::FocusedElement
+ $isTextEditing = $focused -is [System.Windows.Controls.TextBox]
+
+ if ($ctrl -and $e.Key -eq [System.Windows.Input.Key]::N) {
+ New-XamlDesignerDocument
+ $e.Handled = $true
+ return
+ }
+ if ($ctrl -and $e.Key -eq [System.Windows.Input.Key]::O) {
+ Open-XamlDesignerDocument
+ $e.Handled = $true
+ return
+ }
+ if ($ctrl -and $e.Key -eq [System.Windows.Input.Key]::S) {
+ Save-XamlDesignerDocument
+ $e.Handled = $true
+ return
+ }
+
+ if ($ctrl -and $e.Key -eq [System.Windows.Input.Key]::Z) {
+ if ($isTextEditing -and $focused.CanUndo) {
+ $focused.Undo()
+ Set-DesignerStatus -Message 'Text edit undone.'
+ }
+ else {
+ Undo-XamlDesignerChange
+ }
+ $e.Handled = $true
+ return
+ }
+
+ if ($ctrl -and $e.Key -eq [System.Windows.Input.Key]::Y) {
+ if ($isTextEditing -and $focused.CanRedo) {
+ $focused.Redo()
+ Set-DesignerStatus -Message 'Text edit redone.'
+ }
+ else {
+ Redo-XamlDesignerChange
+ }
+ $e.Handled = $true
+ return
+ }
+
+ if ($isTextEditing) {
+ return
+ }
+
+ if ($ctrl -and $e.Key -eq [System.Windows.Input.Key]::D) {
+ Duplicate-SelectedElement
+ $e.Handled = $true
+ return
+ }
+ if ($e.Key -eq [System.Windows.Input.Key]::Delete) {
+ Delete-SelectedElement
+ $e.Handled = $true
+ return
+ }
+ if ($e.Key -eq [System.Windows.Input.Key]::F5) {
+ [void](Apply-XamlEditorText)
+ $e.Handled = $true
+ return
+ }
+
+ $step = if ($shift) { 10.0 } else { 1.0 }
+ switch ($e.Key) {
+ ([System.Windows.Input.Key]::Left) {
+ if (Move-SelectedCanvasElementBy -DeltaX (-$step) -DeltaY 0) { $e.Handled = $true }
+ }
+ ([System.Windows.Input.Key]::Right) {
+ if (Move-SelectedCanvasElementBy -DeltaX $step -DeltaY 0) { $e.Handled = $true }
+ }
+ ([System.Windows.Input.Key]::Up) {
+ if (Move-SelectedCanvasElementBy -DeltaX 0 -DeltaY (-$step)) { $e.Handled = $true }
+ }
+ ([System.Windows.Input.Key]::Down) {
+ if (Move-SelectedCanvasElementBy -DeltaX 0 -DeltaY $step) { $e.Handled = $true }
+ }
+ }
+ })
+}
diff --git a/XamlDesigner/Core/Xml.ps1 b/XamlDesigner/Core/Xml.ps1
new file mode 100644
index 0000000..b97f5ff
--- /dev/null
+++ b/XamlDesigner/Core/Xml.ps1
@@ -0,0 +1,227 @@
+function ConvertTo-FormattedXml {
+ param(
+ [Parameter(Mandatory)]
+ [System.Xml.XmlDocument]$Document
+ )
+
+ $settings = [System.Xml.XmlWriterSettings]::new()
+ $settings.Indent = $true
+ $settings.IndentChars = ' '
+ $settings.NewLineChars = "`r`n"
+ $settings.NewLineHandling = [System.Xml.NewLineHandling]::Replace
+ $settings.OmitXmlDeclaration = $true
+
+ $builder = [System.Text.StringBuilder]::new()
+ $writer = [System.Xml.XmlWriter]::Create($builder, $settings)
+ try {
+ $Document.Save($writer)
+ }
+ finally {
+ $writer.Close()
+ }
+
+ return $builder.ToString().Trim() + "`r`n"
+}
+
+function New-XmlDocumentFromText {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Text
+ )
+
+ # Treat XAML as data. DTD processing and external resource resolution are
+ # disabled explicitly so opening a document never performs XML network/file
+ # resolution behind the user's back.
+ $settings = [System.Xml.XmlReaderSettings]::new()
+ $settings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit
+ $settings.XmlResolver = $null
+
+ $stringReader = [System.IO.StringReader]::new($Text)
+ $xmlReader = [System.Xml.XmlReader]::Create($stringReader, $settings)
+ try {
+ $document = [System.Xml.XmlDocument]::new()
+ $document.PreserveWhitespace = $false
+ $document.XmlResolver = $null
+ $document.Load($xmlReader)
+ return $document
+ }
+ finally {
+ $xmlReader.Close()
+ $stringReader.Close()
+ }
+}
+
+function Get-BlankXamlText {
+ $path = Join-Path $script:State.BaseDirectory 'Templates\BlankWindow.xaml'
+ return Get-Content -LiteralPath $path -Raw
+}
+
+function Get-BlankCodeText {
+ $path = Join-Path $script:State.BaseDirectory 'Templates\BlankWindow.ps1'
+ return Get-Content -LiteralPath $path -Raw
+}
+
+function Get-ElementNameFromNode {
+ param(
+ [Parameter(Mandatory)]
+ [System.Xml.XmlElement]$Node
+ )
+
+ $name = $Node.GetAttribute('Name', $script:XamlNs)
+ if ([string]::IsNullOrWhiteSpace($name)) {
+ $name = $Node.GetAttribute('Name')
+ }
+ return $name
+}
+
+function Set-ElementNameOnNode {
+ param(
+ [Parameter(Mandatory)]
+ [System.Xml.XmlElement]$Node,
+
+ [Parameter(Mandatory)]
+ [string]$Name
+ )
+
+ $Node.RemoveAttribute('Name')
+ $attribute = $Node.OwnerDocument.CreateAttribute('x', 'Name', $script:XamlNs)
+ $attribute.Value = $Name
+ [void]$Node.Attributes.SetNamedItem($attribute)
+}
+
+function Get-XamlElementByName {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Name
+ )
+
+ if ([string]::IsNullOrWhiteSpace($Name) -or $null -eq $script:State.XamlDocument) {
+ return $null
+ }
+
+ foreach ($node in $script:State.XamlDocument.SelectNodes('//*')) {
+ if ($node -isnot [System.Xml.XmlElement]) {
+ continue
+ }
+ if ((Get-ElementNameFromNode -Node $node) -eq $Name) {
+ return $node
+ }
+ }
+ return $null
+}
+
+function Get-AllNamedXamlElements {
+ $result = [System.Collections.Generic.List[object]]::new()
+ if ($null -eq $script:State.XamlDocument) {
+ return $result
+ }
+
+ $root = $script:State.XamlDocument.DocumentElement
+ foreach ($node in $script:State.XamlDocument.SelectNodes('//*')) {
+ if ($node -isnot [System.Xml.XmlElement]) {
+ continue
+ }
+ if ($node -eq $root) {
+ continue
+ }
+ $name = Get-ElementNameFromNode -Node $node
+ if (-not [string]::IsNullOrWhiteSpace($name)) {
+ $result.Add([pscustomobject]@{
+ Name = $name
+ ElementName = $node.LocalName
+ Node = $node
+ })
+ }
+ }
+ return $result
+}
+
+function Get-WpfTypeByElementName {
+ param(
+ [Parameter(Mandatory)]
+ [string]$ElementName
+ )
+
+ $known = @{
+ Window = [System.Windows.Window]
+ Border = [System.Windows.Controls.Border]
+ Image = [System.Windows.Controls.Image]
+ TextBlock = [System.Windows.Controls.TextBlock]
+ }
+
+ if ($known.ContainsKey($ElementName)) {
+ return $known[$ElementName]
+ }
+
+ foreach ($item in $script:State.ToolboxItems) {
+ if ($item.DisplayName -eq $ElementName) {
+ return $item.Type
+ }
+ }
+
+ foreach ($namespace in @('System.Windows.Controls', 'System.Windows.Shapes', 'System.Windows.Documents')) {
+ $type = [Type]::GetType("$namespace.$ElementName, PresentationFramework", $false)
+ if ($null -ne $type) {
+ return $type
+ }
+ }
+
+ return $null
+}
+
+function Remove-PowerShellUnsupportedXamlAttributes {
+ param(
+ [Parameter(Mandatory)]
+ [System.Xml.XmlDocument]$Document
+ )
+
+ $root = $Document.DocumentElement
+ if ($null -eq $root) {
+ return
+ }
+
+ # x:Class and mc:Ignorable are design/build-time concepts. PowerShell's
+ # standalone XamlReader does not require them.
+ $root.RemoveAttribute('Class', $script:XamlNs)
+ $root.RemoveAttribute('Ignorable', $script:McNs)
+
+ foreach ($node in $Document.SelectNodes('//*')) {
+ if ($node -isnot [System.Xml.XmlElement]) {
+ continue
+ }
+
+ # Remove d:* design-time attributes only from the preview clone.
+ $attributesToRemove = [System.Collections.Generic.List[System.Xml.XmlAttribute]]::new()
+ foreach ($attribute in @($node.Attributes)) {
+ if ($attribute.NamespaceURI -eq $script:DesignNs) {
+ $attributesToRemove.Add($attribute)
+ }
+ }
+ foreach ($attribute in $attributesToRemove) {
+ [void]$node.Attributes.Remove($attribute)
+ }
+
+ # Visual Studio can emit Click="Handler" style attributes. Those
+ # handlers cannot be resolved by a standalone PowerShell XamlReader,
+ # because PowerShell wires events from the .ps1 code-behind instead.
+ $type = Get-WpfTypeByElementName -ElementName $node.LocalName
+ if ($null -eq $type) {
+ continue
+ }
+
+ $eventNames = @($type.GetEvents([System.Reflection.BindingFlags]'Public,Instance') | ForEach-Object Name)
+ if ($eventNames.Count -eq 0) {
+ continue
+ }
+
+ $eventAttributes = [System.Collections.Generic.List[System.Xml.XmlAttribute]]::new()
+ foreach ($attribute in @($node.Attributes)) {
+ if ([string]::IsNullOrWhiteSpace($attribute.NamespaceURI) -and $eventNames -contains $attribute.LocalName) {
+ $eventAttributes.Add($attribute)
+ }
+ }
+ foreach ($attribute in $eventAttributes) {
+ [void]$node.Attributes.Remove($attribute)
+ }
+ }
+}
diff --git a/XamlDesigner/README.md b/XamlDesigner/README.md
new file mode 100644
index 0000000..f97e33c
--- /dev/null
+++ b/XamlDesigner/README.md
@@ -0,0 +1,211 @@
+# PowerShell XAML Designer
+
+A standalone WPF/XAML visual editor implemented with PowerShell and Windows WPF.
+
+## Why this exists
+
+PowerShell can build useful Windows desktop tools with WPF and XAML, but the normal graphical WPF authoring experience is closely associated with Visual Studio / Blend. In managed corporate PCs, labs, lightweight admin environments, or personal setups, those tools may be unavailable or restricted.
+
+This project therefore uses only components normally available to PowerShell on Windows:
+
+- PowerShell
+- `WindowsBase`
+- `PresentationCore`
+- `PresentationFramework`
+- `System.Xml`
+- WPF reflection APIs
+
+There is no required NuGet package, PowerShell Gallery module, web service, or external executable.
+
+## File model
+
+A project is normally one pair of files:
+
+```text
+Example.xaml
+Example.ps1
+```
+
+### XAML file
+
+Owns UI structure and UI properties.
+
+```xml
+
+
+
+```
+
+### PowerShell code-behind
+
+Loads XAML, resolves named controls, attaches events, and contains application logic.
+
+```powershell
+[System.Windows.Controls.Button]$Button1 = $Window.FindName('Button1')
+
+$Button1.Add_Click({
+ param($sender, $e)
+
+ [System.Windows.MessageBox]::Show('Hello')
+})
+```
+
+The designer maintains only explicit marker regions for generated metadata/control references. Event blocks and the rest of the script stay editable.
+
+## Main UI
+
+### Toolbox
+
+The toolbox is not a small hard-coded list. At startup, PowerShell reflects over WPF assemblies and discovers public, non-abstract `FrameworkElement` types that:
+
+- belong to standard WPF control/shape namespaces,
+- can be instantiated with a public parameterless constructor,
+- are suitable to appear as visual elements.
+
+Types are grouped as Controls, Panels, Shapes, Decorators, and Other. Search filters by short and fully-qualified type name.
+
+Root-only objects such as another `Window` are intentionally excluded from drag/drop insertion.
+
+### Designer
+
+For a new document, the default root content is:
+
+```xml
+
+```
+
+Drag a toolbox item onto the preview to create a named XAML element. The new control receives practical default size/content attributes so that it is visible immediately.
+
+Controls inside a `Canvas` can be moved directly by mouse. Their `Canvas.Left` and `Canvas.Top` values are written back to the XML document. Arrow keys nudge a selected Canvas control by 1 pixel; hold Shift for 10 pixels.
+
+Supported panels can also receive toolbox drops. Empty `Border`, `GroupBox`, `ScrollViewer`, and `Viewbox` elements can accept a single child. The Outline tab displays the complete XAML tree, including the root `Window`, so Window properties can be edited without switching to source.
+
+### XAML source
+
+The XAML tab is the authoritative source editor.
+
+`Validate / Apply` performs two stages:
+
+1. XML well-formedness parsing (`System.Xml.XmlDocument`).
+2. WPF runtime loading through `XamlReader` after creating an in-memory PowerShell-safe preview clone.
+
+XML errors report line and position when available.
+
+`Format XML` rewrites indentation only after XML parsing succeeds.
+
+### Properties
+
+Selecting a visual element uses .NET reflection and `TypeDescriptor` to enumerate public read/write properties that can reasonably be represented as strings.
+
+High-value WPF properties are sorted near the top. Attached properties such as `Canvas.Left`, `Canvas.Top`, `Grid.Row`, `Grid.Column`, row/column spans, `DockPanel.Dock`, and `Panel.ZIndex` are surfaced when applicable.
+
+A property change is first applied to the XML DOM and then reloaded into WPF. If WPF rejects the value, the XML change is rolled back.
+
+### Events
+
+The event tab enumerates public instance events from the selected WPF type.
+
+Double-click an event row to generate a block such as:
+
+```powershell
+$Button1.Add_Click({
+ param($sender, $e)
+
+ # TODO: Add Click logic for Button1.
+})
+```
+
+Double-clicking a control on the designer chooses a typical event when possible, in this order:
+
+1. `Click`
+2. `Checked`
+3. `SelectionChanged`
+4. `TextChanged`
+5. `ValueChanged`
+6. `SelectedDateChanged`
+7. `MouseDoubleClick`
+8. `Loaded`
+
+The same control/event combination is not generated twice. Generated handlers are inserted into a dedicated event region before `$Window.ShowDialog()`, which guarantees that handlers are registered before the window is shown.
+
+## Visual Studio / Blend XAML compatibility
+
+XAML exported from Visual Studio can contain information intended for a compiled WPF project, for example:
+
+- `x:Class`
+- `mc:Ignorable`
+- `d:*` design-time attributes
+- event attributes such as `Click="Button_Click"`
+
+A standalone PowerShell `XamlReader` has no compiled code-behind class that can resolve those handlers. The designer therefore creates an in-memory clone for preview and removes unsupported build/design attributes from that clone only.
+
+The paired PowerShell template uses the same principle at runtime. UI structure remains in `.xaml`; executable event registration remains in `.ps1`.
+
+## Keyboard shortcuts
+
+| Shortcut | Action |
+|---|---|
+| `Ctrl+N` | New pair |
+| `Ctrl+O` | Open XAML |
+| `Ctrl+S` | Save pair |
+| `Ctrl+Z` | Undo designer change, or native text undo while editing text |
+| `Ctrl+Y` | Redo designer change, or native text redo while editing text |
+| `Ctrl+D` | Duplicate selected control |
+| `Delete` | Delete selected control |
+| Arrow keys | Move selected Canvas control by 1 px |
+| `Shift` + Arrow keys | Move selected Canvas control by 10 px |
+| `F5` | Validate / Apply XAML |
+
+## Current boundaries
+
+This is a useful visual designer foundation, not yet a complete clone of Visual Studio's XML/XAML tooling.
+
+The current implementation does **not** yet provide:
+
+- syntax coloring,
+- XAML IntelliSense / attribute completion,
+- XSD schema completion,
+- collapsible document outlining,
+- visual Grid row/column editors,
+- resize handles / adorners,
+- binding editors,
+- resource/style/template designers,
+- custom control assembly loading,
+- a project explorer.
+
+It can still open and preview many existing standard-WPF XAML files. Drag/move behavior is most complete with `Canvas` layout. Nonvisual XAML objects such as brushes, transforms, resources, bindings, styles, and templates are edited in source at this stage rather than exposed as toolbox controls.
+
+## Architecture
+
+```text
+XamlDesigner/
+├─ Start-XamlDesigner.ps1 Entry point / WPF bootstrap
+├─ XamlDesigner.xaml The designer's own UI
+├─ XamlDesigner.Core.psm1 Editor state, reflection, XML DOM, preview, D&D, properties, events
+├─ README.md
+└─ Templates/
+ ├─ BlankWindow.xaml New-document UI template
+ └─ BlankWindow.ps1 New-document PowerShell runtime template
+```
+
+The designer itself follows the same architecture it promotes: XAML for the screen and PowerShell for the behavior.
+
+## Recommended next milestones
+
+The next editor features with the highest value are:
+
+1. WPF adorners for resize handles and selection rectangle.
+2. Grid row/column visual editor.
+3. XAML token coloring and tag/attribute completion driven by reflected WPF metadata.
+4. Resource/style/template tree editor.
+5. Custom assembly loading so third-party WPF controls can appear in the toolbox.
+6. Project folder mode for multiple `.xaml` / `.ps1` pairs.
+
+These can remain PowerShell-only; no C# helper assembly is required for the core design.
diff --git a/XamlDesigner/Start-XamlDesigner.ps1 b/XamlDesigner/Start-XamlDesigner.ps1
new file mode 100644
index 0000000..c49ed0d
--- /dev/null
+++ b/XamlDesigner/Start-XamlDesigner.ps1
@@ -0,0 +1,33 @@
+[CmdletBinding()]
+param()
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+if ($env:OS -ne 'Windows_NT') {
+ throw 'PowerShell XAML Designer requires Windows because it is implemented with WPF.'
+}
+
+if ([System.Threading.Thread]::CurrentThread.GetApartmentState() -ne [System.Threading.ApartmentState]::STA) {
+ throw 'WPF requires an STA thread. Start with powershell.exe -STA or pwsh.exe -STA and run Start-XamlDesigner.ps1 again.'
+}
+
+Add-Type -AssemblyName WindowsBase
+Add-Type -AssemblyName PresentationCore
+Add-Type -AssemblyName PresentationFramework
+
+$modulePath = Join-Path $PSScriptRoot 'XamlDesigner.Core.psm1'
+Import-Module -Name $modulePath -Force
+
+$designerXamlPath = Join-Path $PSScriptRoot 'XamlDesigner.xaml'
+[xml]$designerXaml = Get-Content -LiteralPath $designerXamlPath -Raw
+$reader = [System.Xml.XmlNodeReader]::new($designerXaml)
+try {
+ [System.Windows.Window]$designerWindow = [System.Windows.Markup.XamlReader]::Load($reader)
+}
+finally {
+ $reader.Close()
+}
+
+Initialize-XamlDesigner -Window $designerWindow -BaseDirectory $PSScriptRoot
+$null = $designerWindow.ShowDialog()
diff --git a/XamlDesigner/Templates/BlankWindow.ps1 b/XamlDesigner/Templates/BlankWindow.ps1
new file mode 100644
index 0000000..3aa1b10
--- /dev/null
+++ b/XamlDesigner/Templates/BlankWindow.ps1
@@ -0,0 +1,121 @@
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+Add-Type -AssemblyName WindowsBase
+Add-Type -AssemblyName PresentationCore
+Add-Type -AssemblyName PresentationFramework
+
+function ConvertTo-PowerShellRuntimeXaml {
+ <#
+ .SYNOPSIS
+ Creates a runtime-safe clone of a WPF XAML document.
+
+ .DESCRIPTION
+ Visual Studio / Blend XAML can contain build-time attributes such as
+ x:Class, d:* design data, and event attributes such as Click="...".
+ A standalone PowerShell XamlReader has no compiled code-behind class,
+ so those attributes are removed only from the in-memory runtime clone.
+ The saved .xaml file itself remains the UI definition.
+ #>
+ param(
+ [Parameter(Mandatory)]
+ [System.Xml.XmlDocument]$Document
+ )
+
+ $xamlNamespace = 'http://schemas.microsoft.com/winfx/2006/xaml'
+ $mcNamespace = 'http://schemas.openxmlformats.org/markup-compatibility/2006'
+ $designNamespace = 'http://schemas.microsoft.com/expression/blend/2008'
+
+ [System.Xml.XmlDocument]$runtimeDocument = $Document.CloneNode($true)
+ [System.Xml.XmlElement]$root = $runtimeDocument.DocumentElement
+ if ($null -eq $root) {
+ return $runtimeDocument
+ }
+
+ $root.RemoveAttribute('Class', $xamlNamespace)
+ $root.RemoveAttribute('Ignorable', $mcNamespace)
+
+ $typeNamespaces = @(
+ 'System.Windows.Controls',
+ 'System.Windows.Shapes',
+ 'System.Windows.Documents'
+ )
+
+ foreach ($node in $runtimeDocument.SelectNodes('//*')) {
+ if ($node -isnot [System.Xml.XmlElement]) {
+ continue
+ }
+
+ foreach ($attribute in @($node.Attributes | ForEach-Object { $_ })) {
+ if ($attribute.NamespaceURI -eq $designNamespace) {
+ [void]$node.Attributes.Remove($attribute)
+ }
+ }
+
+ [Type]$wpfType = $null
+ if ($node.LocalName -eq 'Window') {
+ $wpfType = [System.Windows.Window]
+ }
+ else {
+ foreach ($namespace in $typeNamespaces) {
+ $candidate = [Type]::GetType("$namespace.$($node.LocalName), PresentationFramework", $false)
+ if ($null -ne $candidate) {
+ $wpfType = $candidate
+ break
+ }
+ }
+ }
+
+ if ($null -eq $wpfType) {
+ continue
+ }
+
+ $eventNames = @($wpfType.GetEvents([System.Reflection.BindingFlags]'Public,Instance') | ForEach-Object Name)
+ foreach ($attribute in @($node.Attributes | ForEach-Object { $_ })) {
+ if ([string]::IsNullOrWhiteSpace($attribute.NamespaceURI) -and $eventNames -contains $attribute.LocalName) {
+ [void]$node.Attributes.Remove($attribute)
+ }
+ }
+ }
+
+ return $runtimeDocument
+}
+
+$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
+
+#
+$xamlFileName = 'Untitled.xaml'
+#
+
+$xamlPath = Join-Path $scriptDirectory $xamlFileName
+$xmlSettings = [System.Xml.XmlReaderSettings]::new()
+$xmlSettings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit
+$xmlSettings.XmlResolver = $null
+$sourceReader = [System.Xml.XmlReader]::Create($xamlPath, $xmlSettings)
+try {
+ [System.Xml.XmlDocument]$sourceXamlDocument = [System.Xml.XmlDocument]::new()
+ $sourceXamlDocument.XmlResolver = $null
+ $sourceXamlDocument.Load($sourceReader)
+}
+finally {
+ $sourceReader.Close()
+}
+[System.Xml.XmlDocument]$runtimeXamlDocument = ConvertTo-PowerShellRuntimeXaml -Document $sourceXamlDocument
+
+$reader = [System.Xml.XmlNodeReader]::new($runtimeXamlDocument)
+try {
+ [System.Windows.Window]$Window = [System.Windows.Markup.XamlReader]::Load($reader)
+}
+finally {
+ $reader.Close()
+}
+
+#
+# Named controls are inserted here by PowerShell XAML Designer.
+#
+
+#
+# Event handlers generated by PowerShell XAML Designer are inserted here.
+#
+
+$null = $Window.ShowDialog()
diff --git a/XamlDesigner/Templates/BlankWindow.xaml b/XamlDesigner/Templates/BlankWindow.xaml
new file mode 100644
index 0000000..ed97d8c
--- /dev/null
+++ b/XamlDesigner/Templates/BlankWindow.xaml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/XamlDesigner/XamlDesigner.Core.psm1 b/XamlDesigner/XamlDesigner.Core.psm1
new file mode 100644
index 0000000..eaac686
--- /dev/null
+++ b/XamlDesigner/XamlDesigner.Core.psm1
@@ -0,0 +1,40 @@
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$coreDirectory = Join-Path $PSScriptRoot 'Core'
+foreach ($part in @(
+ 'State.ps1',
+ 'Xml.ps1',
+ 'Preview.ps1',
+ 'Outline.ps1',
+ 'CodeBehind.ps1',
+ 'Documents.ps1',
+ 'History.ps1',
+ 'ToolboxCatalog.ps1',
+ 'ToolboxEditing.ps1',
+ 'Properties.ps1',
+ 'Events.ps1',
+ 'Ui.ps1'
+)) {
+ . (Join-Path $coreDirectory $part)
+}
+
+function Initialize-XamlDesigner {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [System.Windows.Window]$Window,
+
+ [Parameter(Mandatory)]
+ [string]$BaseDirectory
+ )
+
+ $script:State.Window = $Window
+ $script:State.BaseDirectory = $BaseDirectory
+ Initialize-UiReferences -Window $Window
+ Register-UiEvents
+ Refresh-ToolboxCatalog
+ New-XamlDesignerDocument
+}
+
+Export-ModuleMember -Function Initialize-XamlDesigner
diff --git a/XamlDesigner/XamlDesigner.xaml b/XamlDesigner/XamlDesigner.xaml
new file mode 100644
index 0000000..1fef0af
--- /dev/null
+++ b/XamlDesigner/XamlDesigner.xaml
@@ -0,0 +1,201 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+