diff --git a/.github/workflows/powershell-xaml-designer.yml b/.github/workflows/powershell-xaml-designer.yml
index 07e51d8..977aa7f 100644
--- a/.github/workflows/powershell-xaml-designer.yml
+++ b/.github/workflows/powershell-xaml-designer.yml
@@ -9,22 +9,36 @@ 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
+ - uses: actions/checkout@v5
+
+ - name: Parse repository with PowerShell 7
+ shell: cmd
+ run: pwsh.exe -NoProfile -STA -File .\Tests\Test-Repository.ps1
+
+ - name: Parse repository with Windows PowerShell 5.1
+ shell: cmd
+ run: powershell.exe -NoProfile -STA -File .\Tests\Test-Repository.ps1
+
+ - name: Core behavior tests with PowerShell 7
+ shell: cmd
+ run: pwsh.exe -NoProfile -STA -File .\Tests\Test-DesignerCore.ps1
+
+ - name: Core behavior tests with Windows PowerShell 5.1
+ shell: cmd
+ run: powershell.exe -NoProfile -STA -File .\Tests\Test-DesignerCore.ps1
+
+ - name: Generated code tests with PowerShell 7
+ shell: cmd
+ run: pwsh.exe -NoProfile -STA -File .\Tests\Test-CodeGeneration.ps1
+
+ - name: Generated code tests with Windows PowerShell 5.1
+ shell: cmd
+ run: powershell.exe -NoProfile -STA -File .\Tests\Test-CodeGeneration.ps1
+
+ - name: Designer startup smoke test with PowerShell 7
+ shell: cmd
+ run: pwsh.exe -NoProfile -STA -File .\Tests\Test-DesignerStartup.ps1
+
+ - name: Designer startup smoke test with Windows PowerShell 5.1
+ shell: cmd
+ run: powershell.exe -NoProfile -STA -File .\Tests\Test-DesignerStartup.ps1
diff --git a/README.md b/README.md
index 2fa695e..991bf55 100644
--- a/README.md
+++ b/README.md
@@ -34,13 +34,14 @@ instead of depending on `Click="Button1_Click"` in XAML.
- 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.
+- Toolbox search plus a beginner-oriented Common category with short control descriptions.
+- Drag-and-drop, double-click, or an explicit Add selected control button for inserting controls.
- 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.
+- Undo/redo for designer-side changes restores XAML and generated PowerShell together, while text editors keep 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.
@@ -52,23 +53,28 @@ instead of depending on `Click="Button1_Click"` in XAML.
- 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.
+- Deleting controls archives their generated event handlers as comments instead of leaving broken references or silently discarding the code.
+- Safe preview blocks risky loose-XAML constructs/custom CLR elements and prevents automatic external resource navigation.
+- Save As warns before replacing an already-existing paired `.ps1` file; paired saves use temporary files/rollback and UTF-8 BOM for Windows PowerShell 5.1/Japanese compatibility.
### Start the designer
-Windows PowerShell 5.1:
+From a PowerShell prompt:
-```powershell
-powershell.exe -STA -ExecutionPolicy Bypass -File .\XamlDesigner\Start-XamlDesigner.ps1
-```
+~~~powershell
+.\XamlDesigner\Start-XamlDesigner.ps1
+~~~
-PowerShell 7 on Windows:
+If the current process is not STA, the launcher attempts to relaunch the same PowerShell executable in STA mode automatically. It does not bypass the machine's execution policy.
-```powershell
-pwsh.exe -STA -File .\XamlDesigner\Start-XamlDesigner.ps1
-```
+Explicit launch examples:
+
+~~~powershell
+powershell.exe -NoProfile -STA -File .\XamlDesigner\Start-XamlDesigner.ps1
+pwsh.exe -NoProfile -STA -File .\XamlDesigner\Start-XamlDesigner.ps1
+~~~
-WPF requires Windows and an STA thread.
+For a Japanese beginner walkthrough, see [XamlDesigner/GETTING_STARTED.ja.md](./XamlDesigner/GETTING_STARTED.ja.md). The requested 50 simulated persona reviews are recorded in [XamlDesigner/REVIEW_50_PERSONAS.md](./XamlDesigner/REVIEW_50_PERSONAS.md).
## Design direction
diff --git a/Tests/Test-CodeGeneration.ps1 b/Tests/Test-CodeGeneration.ps1
index 84617c0..29a5929 100644
--- a/Tests/Test-CodeGeneration.ps1
+++ b/Tests/Test-CodeGeneration.ps1
@@ -20,6 +20,7 @@ $coreDirectory = Join-Path $designerDirectory 'Core'
. (Join-Path $coreDirectory 'State.ps1')
. (Join-Path $coreDirectory 'Xml.ps1')
. (Join-Path $coreDirectory 'CodeBehind.ps1')
+. (Join-Path $coreDirectory 'History.ps1')
. (Join-Path $coreDirectory 'Events.ps1')
$script:State.BaseDirectory = $designerDirectory
@@ -40,7 +41,7 @@ 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
+$code = [System.IO.File]::ReadAllText((Join-Path $designerDirectory 'Templates\BlankWindow.ps1'))
$script:State.Ui = @{
CodeEditor = [pscustomobject]@{ Text = $code }
MainTabs = [pscustomobject]@{ SelectedIndex = 0 }
diff --git a/Tests/Test-DesignerCore.ps1 b/Tests/Test-DesignerCore.ps1
new file mode 100644
index 0000000..79e8e0a
--- /dev/null
+++ b/Tests/Test-DesignerCore.ps1
@@ -0,0 +1,201 @@
+[CmdletBinding()]
+param()
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+if ($env:OS -ne 'Windows_NT') {
+ Write-Host 'Designer core behavior 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'
+
+foreach ($part in @(
+ 'State.ps1',
+ 'Xml.ps1',
+ 'CodeBehind.ps1',
+ 'Documents.ps1',
+ 'History.ps1',
+ 'ToolboxCatalog.ps1',
+ 'ToolboxEditing.ps1',
+ 'Events.ps1'
+)) {
+ . (Join-Path $coreDirectory $part)
+}
+
+$script:State.BaseDirectory = $designerDirectory
+$script:State.ToolboxItems = @(Get-WpfControlCatalog)
+
+$safeDocument = New-XmlDocumentFromText -Text @'
+
+
+
+'@
+if (-not (Test-XamlPreviewSafety -Document $safeDocument)) {
+ throw 'Safe standard XAML was unexpectedly rejected.'
+}
+
+$dangerousDocument = New-XmlDocumentFromText -Text @'
+
+
+
+
+
+
+'@
+$blocked = $false
+try {
+ [void](Test-XamlPreviewSafety -Document $dangerousDocument)
+}
+catch {
+ $blocked = $true
+}
+if (-not $blocked) {
+ throw 'ObjectDataProvider was not blocked by safe preview.'
+}
+
+$customDocument = New-XmlDocumentFromText -Text @'
+
+
+
+'@
+$blocked = $false
+try {
+ [void](Test-XamlPreviewSafety -Document $customDocument)
+}
+catch {
+ $blocked = $true
+}
+if (-not $blocked) {
+ throw 'Custom CLR element was not blocked by safe preview.'
+}
+
+$script:State.XamlDocument = New-XmlDocumentFromText -Text @'
+
+
+
+'@
+$named = @(Get-AllNamedXamlElements | ForEach-Object Name)
+if ($named -notcontains 'Button1' -or $named -notcontains 'DesignCanvas') {
+ throw 'Normal Window namescope controls were not discovered.'
+}
+if ($named -contains 'TemplateTextBox') {
+ throw 'Template NameScope control was incorrectly treated as a Window.FindName control.'
+}
+
+$script:State.XamlDocument = New-XmlDocumentFromText -Text @'
+
+
+
+'@
+$source = Get-XamlElementByName -Name 'Group1'
+$newRootName = Copy-XamlElementNode -Source $source
+if ([string]::IsNullOrWhiteSpace($newRootName)) {
+ throw 'Subtree duplication did not return a new root name.'
+}
+$normalNames = @(Get-AllNamedXamlElements | ForEach-Object Name)
+if (($normalNames | Select-Object -Unique).Count -ne $normalNames.Count) {
+ throw 'Subtree duplication created duplicate x:Name values.'
+}
+$copyNode = Get-XamlElementByName -Name $newRootName
+$boundText = @($copyNode.SelectNodes('.//*') | Where-Object {
+ $_ -is [System.Xml.XmlElement] -and $_.HasAttribute('Text') -and $_.GetAttribute('Text') -like '*ElementName=*'
+} | Select-Object -First 1)
+if ($boundText.Count -ne 1 -or $boundText[0].GetAttribute('Text') -like '*ElementName=Input1,*') {
+ throw 'ElementName binding inside the duplicated subtree was not updated.'
+}
+
+$sampleCode = @'
+#
+#
+${Button1}.Add_Click({
+ param($sender, $e)
+})
+#
+#
+
+Write-Host "user code"
+'@
+$archived = Archive-GeneratedEventsForControl -Code $sampleCode -ControlName 'Button1'
+if ($archived -notmatch 'XamlDesigner:ArchivedEvent') {
+ throw 'Deleted-control event block was not archived.'
+}
+if ($archived -match '(?m)^\s*\$\{Button1\}\.Add_Click\(') {
+ throw 'Archived event handler is still executable.'
+}
+if ($archived -notmatch 'param\(\$sender, \$e\)') {
+ throw 'Archived event handler body was not preserved as comments.'
+}
+if ($archived -notmatch 'Write-Host "user code"') {
+ throw 'User code outside generated blocks was removed unexpectedly.'
+}
+
+$tempDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ('PowerShellXamlDesignerTest_' + [Guid]::NewGuid().ToString('N'))
+[void][System.IO.Directory]::CreateDirectory($tempDirectory)
+try {
+ $xamlPath = Join-Path $tempDirectory '日本語.xaml'
+ $codePath = Join-Path $tempDirectory '日本語.ps1'
+ $xamlText = ''
+ $codeText = 'Write-Output "こんにちは PowerShell"'
+
+ Write-DesignerDocumentPair -XamlPath $xamlPath -XamlText $xamlText -CodePath $codePath -CodeText $codeText
+
+ foreach ($path in @($xamlPath, $codePath)) {
+ [byte[]]$bytes = [System.IO.File]::ReadAllBytes($path)
+ if ($bytes.Length -lt 3 -or $bytes[0] -ne 0xEF -or $bytes[1] -ne 0xBB -or $bytes[2] -ne 0xBF) {
+ throw "UTF-8 BOM was not written: $path"
+ }
+ }
+
+ if ((Read-DesignerTextFile -Path $xamlPath) -cne $xamlText) {
+ throw 'Japanese XAML did not round-trip exactly.'
+ }
+ if ((Read-DesignerTextFile -Path $codePath) -cne $codeText) {
+ throw 'Japanese PowerShell did not round-trip exactly.'
+ }
+}
+finally {
+ Remove-Item -LiteralPath $tempDirectory -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+$script:State.Ui = @{
+ CodeEditor = [pscustomobject]@{ Text = 'Write-Host old' }
+}
+$script:State.SelectedElementName = 'Button1'
+$snapshot = New-DesignerHistorySnapshot -XamlText '' -CodeText 'Write-Host old' -SelectionName 'Button1'
+if ($snapshot.CodeText -ne 'Write-Host old' -or $snapshot.SelectionName -ne 'Button1') {
+ throw 'Designer history snapshot did not retain code/selection state.'
+}
+
+Write-Host 'Safe preview, NameScope, duplication, event archiving, encoding, and history behavior checks passed.'
diff --git a/Tests/Test-DesignerStartup.ps1 b/Tests/Test-DesignerStartup.ps1
index e138ea1..06f6907 100644
--- a/Tests/Test-DesignerStartup.ps1
+++ b/Tests/Test-DesignerStartup.ps1
@@ -22,7 +22,7 @@ $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
+[xml]$designerXaml = [System.IO.File]::ReadAllText($designerXamlPath)
$reader = [System.Xml.XmlNodeReader]::new($designerXaml)
try {
[System.Windows.Window]$window = [System.Windows.Markup.XamlReader]::Load($reader)
@@ -35,12 +35,12 @@ 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',
+ 'MenuUndo','MenuRedo','MenuDelete','MenuDuplicate','MenuValidate','MenuRefreshToolbox','MenuGettingStarted','MenuAbout',
+ 'StatusText','DocumentText','ToolboxSearch','ToolboxCategory','ToolboxList','ButtonAddToolbox','ToolboxHelpText','OutlineTree',
'MainTabs','PreviewBorder','PreviewHost','CheckSnapToGrid',
'ButtonDelete','ButtonDuplicate','ButtonApplyXaml','ButtonFormatXaml','ButtonValidateCode',
'XamlEditor','CodeEditor','SelectedControlText','PropertyGrid',
- 'PropertyNameText','PropertyValueText','ButtonApplyProperty','EventGrid'
+ 'PropertyNameText','PropertyValueText','PropertyHelpText','ButtonApplyProperty','EventGrid'
)
foreach ($name in $requiredControls) {
diff --git a/Tests/Test-Repository.ps1 b/Tests/Test-Repository.ps1
index c203fdc..d01945a 100644
--- a/Tests/Test-Repository.ps1
+++ b/Tests/Test-Repository.ps1
@@ -7,7 +7,9 @@ $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 {
+Get-ChildItem -LiteralPath $repositoryRoot -Recurse -File |
+Where-Object { $_.Extension -in @('.ps1', '.psm1') } |
+ForEach-Object {
$tokens = $null
$parseErrors = $null
[void][System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$parseErrors)
@@ -16,12 +18,15 @@ Get-ChildItem -LiteralPath $repositoryRoot -Recurse -File -Include *.ps1,*.psm1
}
}
-Get-ChildItem -LiteralPath $repositoryRoot -Recurse -File -Include *.xaml | ForEach-Object {
+Get-ChildItem -LiteralPath $repositoryRoot -Recurse -File |
+Where-Object { $_.Extension -eq '.xaml' } |
+ForEach-Object {
+ $xamlFile = $_
try {
$settings = [System.Xml.XmlReaderSettings]::new()
$settings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit
$settings.XmlResolver = $null
- $reader = [System.Xml.XmlReader]::Create($_.FullName, $settings)
+ $reader = [System.Xml.XmlReader]::Create($xamlFile.FullName, $settings)
try {
$document = [System.Xml.XmlDocument]::new()
$document.XmlResolver = $null
@@ -32,7 +37,7 @@ Get-ChildItem -LiteralPath $repositoryRoot -Recurse -File -Include *.xaml | ForE
}
}
catch {
- $errorsFound.Add("XML parse error: $($_.FullName): $($_.Exception.Message)")
+ $errorsFound.Add("XML parse error: $($xamlFile.FullName): $($_.Exception.Message)")
}
}
@@ -41,7 +46,10 @@ $requiredFiles = @(
'XamlDesigner\XamlDesigner.xaml',
'XamlDesigner\XamlDesigner.Core.psm1',
'XamlDesigner\Templates\BlankWindow.xaml',
- 'XamlDesigner\Templates\BlankWindow.ps1'
+ 'XamlDesigner\Templates\BlankWindow.ps1',
+ 'XamlDesigner\GETTING_STARTED.ja.md',
+ 'XamlDesigner\REVIEW_50_PERSONAS.md',
+ 'Tests\Test-DesignerCore.ps1'
)
foreach ($relativePath in $requiredFiles) {
if (-not (Test-Path -LiteralPath (Join-Path $repositoryRoot $relativePath))) {
@@ -51,7 +59,7 @@ foreach ($relativePath in $requiredFiles) {
$templateCodePath = Join-Path $repositoryRoot 'XamlDesigner\Templates\BlankWindow.ps1'
if (Test-Path -LiteralPath $templateCodePath) {
- $templateCode = Get-Content -LiteralPath $templateCodePath -Raw
+ $templateCode = [System.IO.File]::ReadAllText($templateCodePath)
foreach ($marker in @(
'# ',
'# ',
@@ -66,9 +74,44 @@ if (Test-Path -LiteralPath $templateCodePath) {
}
}
+
+$workflowPath = Join-Path $repositoryRoot '.github\workflows\powershell-xaml-designer.yml'
+if (Test-Path -LiteralPath $workflowPath) {
+ $workflowText = [System.IO.File]::ReadAllText($workflowPath)
+ if ($workflowText -match '(?i)ExecutionPolicy\s+Bypass') {
+ $errorsFound.Add('CI must not normalize ExecutionPolicy Bypass for this enterprise-oriented tool.')
+ }
+ foreach ($requiredCommand in @(
+ 'Test-DesignerCore.ps1',
+ 'Test-CodeGeneration.ps1',
+ 'Test-DesignerStartup.ps1',
+ 'powershell.exe',
+ 'pwsh.exe'
+ )) {
+ if (-not $workflowText.Contains($requiredCommand)) {
+ $errorsFound.Add("CI workflow is missing expected coverage: $requiredCommand")
+ }
+ }
+}
+
+$designerUiPath = Join-Path $repositoryRoot 'XamlDesigner\XamlDesigner.xaml'
+if (Test-Path -LiteralPath $designerUiPath) {
+ $designerUiText = [System.IO.File]::ReadAllText($designerUiPath)
+ foreach ($requiredName in @(
+ 'MenuGettingStarted',
+ 'ButtonAddToolbox',
+ 'ToolboxHelpText',
+ 'PropertyHelpText'
+ )) {
+ if (-not $designerUiText.Contains('x:Name="' + $requiredName + '"')) {
+ $errorsFound.Add("Beginner/accessibility UI control is missing: $requiredName")
+ }
+ }
+}
+
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.'
+Write-Host 'PowerShell/XAML syntax, required files, generated markers, beginner UI, and CI policy checks passed.'
diff --git a/XamlDesigner/Core/Documents.ps1 b/XamlDesigner/Core/Documents.ps1
index 07fa006..50a2c94 100644
--- a/XamlDesigner/Core/Documents.ps1
+++ b/XamlDesigner/Core/Documents.ps1
@@ -1,3 +1,131 @@
+function Read-DesignerTextFile {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Path
+ )
+
+ [byte[]]$bytes = [System.IO.File]::ReadAllBytes($Path)
+ if ($bytes.Length -eq 0) {
+ return ''
+ }
+
+ $hasBom = (
+ ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) -or
+ ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) -or
+ ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) -or
+ ($bytes.Length -ge 4 -and $bytes[0] -eq 0x00 -and $bytes[1] -eq 0x00 -and $bytes[2] -eq 0xFE -and $bytes[3] -eq 0xFF)
+ )
+
+ if ($hasBom) {
+ $reader = [System.IO.StreamReader]::new($Path, $true)
+ try {
+ return $reader.ReadToEnd()
+ }
+ finally {
+ $reader.Close()
+ }
+ }
+
+ try {
+ $strictUtf8 = [System.Text.UTF8Encoding]::new($false, $true)
+ return $strictUtf8.GetString($bytes)
+ }
+ catch [System.Text.DecoderFallbackException] {
+ # Legacy Windows PowerShell files are sometimes saved in the current
+ # ANSI code page. Fall back only when the file is not valid UTF-8.
+ return [System.Text.Encoding]::Default.GetString($bytes)
+ }
+}
+
+function Test-CodeBehindHasDesignerRegions {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Code
+ )
+
+ foreach ($marker in @(
+ '# ',
+ '# ',
+ '# ',
+ '# ',
+ '# ',
+ '# '
+ )) {
+ if (-not $Code.Contains($marker)) {
+ return $false
+ }
+ }
+
+ return $true
+}
+
+function Write-DesignerDocumentPair {
+ param(
+ [Parameter(Mandatory)]
+ [string]$XamlPath,
+
+ [Parameter(Mandatory)]
+ [string]$XamlText,
+
+ [Parameter(Mandatory)]
+ [string]$CodePath,
+
+ [Parameter(Mandatory)]
+ [string]$CodeText
+ )
+
+ $id = [Guid]::NewGuid().ToString('N')
+ $xamlTemp = "$XamlPath.$id.tmp"
+ $codeTemp = "$CodePath.$id.tmp"
+ $xamlBackup = "$XamlPath.$id.bak"
+ $codeBackup = "$CodePath.$id.bak"
+ $xamlExisted = Test-Path -LiteralPath $XamlPath
+ $codeExisted = Test-Path -LiteralPath $CodePath
+ $utf8WithBom = [System.Text.UTF8Encoding]::new($true)
+
+ try {
+ # Write both complete temporary files before touching either target.
+ [System.IO.File]::WriteAllText($xamlTemp, $XamlText, $utf8WithBom)
+ [System.IO.File]::WriteAllText($codeTemp, $CodeText, $utf8WithBom)
+
+ if ($xamlExisted) {
+ [System.IO.File]::Copy($XamlPath, $xamlBackup, $true)
+ }
+ if ($codeExisted) {
+ [System.IO.File]::Copy($CodePath, $codeBackup, $true)
+ }
+
+ try {
+ [System.IO.File]::Copy($xamlTemp, $XamlPath, $true)
+ [System.IO.File]::Copy($codeTemp, $CodePath, $true)
+ }
+ catch {
+ if ($xamlExisted -and (Test-Path -LiteralPath $xamlBackup)) {
+ [System.IO.File]::Copy($xamlBackup, $XamlPath, $true)
+ }
+ elseif (-not $xamlExisted -and (Test-Path -LiteralPath $XamlPath)) {
+ [System.IO.File]::Delete($XamlPath)
+ }
+
+ if ($codeExisted -and (Test-Path -LiteralPath $codeBackup)) {
+ [System.IO.File]::Copy($codeBackup, $CodePath, $true)
+ }
+ elseif (-not $codeExisted -and (Test-Path -LiteralPath $CodePath)) {
+ [System.IO.File]::Delete($CodePath)
+ }
+
+ throw
+ }
+ }
+ finally {
+ foreach ($path in @($xamlTemp, $codeTemp, $xamlBackup, $codeBackup)) {
+ if (Test-Path -LiteralPath $path) {
+ Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
+ }
+ }
+ }
+}
+
function New-XamlDesignerDocument {
if (-not (Confirm-ContinueWithUnsavedChanges)) {
return
@@ -14,7 +142,7 @@ function New-XamlDesignerDocument {
[void](Refresh-Preview)
Update-DocumentCaption
Set-DocumentSavedSnapshot
- Set-DesignerStatus -Message 'Created a new XAML + PowerShell document pair.'
+ Set-DesignerStatus -Message 'New document created. Start with a Common toolbox control; Button is a good first choice.'
}
function Open-XamlDesignerDocument {
@@ -30,28 +158,47 @@ function Open-XamlDesignerDocument {
}
try {
- $text = Get-Content -LiteralPath $dialog.FileName -Raw
+ $text = Read-DesignerTextFile -Path $dialog.FileName
$document = New-XmlDocumentFromText -Text $text
+ $candidateCodePath = [System.IO.Path]::ChangeExtension($dialog.FileName, '.ps1')
+
+ if (Test-Path -LiteralPath $candidateCodePath) {
+ $codeText = Read-DesignerTextFile -Path $candidateCodePath
+ if (-not (Test-CodeBehindHasDesignerRegions -Code $codeText)) {
+ $nl = [Environment]::NewLine
+ $answer = [System.Windows.MessageBox]::Show(
+ 'A paired .ps1 file already exists, but it was not created/managed by PowerShell XAML Designer.' +
+ $nl + $nl +
+ 'If you continue, generated marker regions will be added in the editor. The file on disk is not changed until you save.' +
+ $nl + $nl +
+ 'Continue opening this pair?',
+ 'Existing PowerShell file',
+ [System.Windows.MessageBoxButton]::YesNo,
+ [System.Windows.MessageBoxImage]::Warning
+ )
+ if ($answer -ne [System.Windows.MessageBoxResult]::Yes) {
+ Set-DesignerStatus -Message 'Open cancelled. The existing PowerShell file was not modified.'
+ return
+ }
+ }
+ }
+ else {
+ $codeText = Get-BlankCodeText
+ }
+
$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.'
+ throw 'The selected file is XML, but it could not be loaded safely as a WPF Window. See the status bar for the preview reason.'
}
$script:State.CurrentXamlPath = $dialog.FileName
- $script:State.CurrentCodePath = [System.IO.Path]::ChangeExtension($dialog.FileName, '.ps1')
+ $script:State.CurrentCodePath = $candidateCodePath
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
- }
-
+ $script:State.Ui.CodeEditor.Text = $codeText
Sync-CodeEditor
Update-DocumentCaption
Set-DocumentSavedSnapshot
@@ -83,6 +230,8 @@ function Apply-XamlEditorText {
$old = $script:State.XamlDocument
$oldText = if ($null -ne $old) { ConvertTo-FormattedXml -Document $old } else { $null }
+ $oldCode = $script:State.Ui.CodeEditor.Text
+ $oldSelection = $script:State.SelectedElementName
$candidateText = ConvertTo-FormattedXml -Document $candidate
$recordHistory = (
-not $script:State.IsRestoringHistory -and
@@ -97,7 +246,7 @@ function Apply-XamlEditorText {
}
if ($recordHistory) {
- Push-XamlUndoSnapshot -Text $oldText
+ Push-XamlUndoSnapshot -Text $oldText -CodeText $oldCode -SelectionName $oldSelection
}
Refresh-XamlTextFromDocument
@@ -126,8 +275,10 @@ function Confirm-CodeBehindOverwrite {
return $true
}
+ $nl = [Environment]::NewLine
$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?",
+ 'The paired PowerShell file already exists and will also be replaced:' +
+ $nl + $nl + $CodePath + $nl + $nl + 'Continue?',
'Replace paired PowerShell file?',
[System.Windows.MessageBoxButton]::YesNo,
[System.Windows.MessageBoxImage]::Warning
@@ -143,7 +294,7 @@ function Save-XamlDesignerDocument {
if (-not (Apply-XamlEditorText)) {
[System.Windows.MessageBox]::Show(
- 'The XAML contains an error. Fix the error before saving.',
+ 'The XAML contains an error or was blocked by safe preview checks. Fix the issue before saving.',
'Save blocked',
[System.Windows.MessageBoxButton]::OK,
[System.Windows.MessageBoxImage]::Warning
@@ -161,7 +312,10 @@ function Save-XamlDesignerDocument {
return
}
- if ($SaveAs -or [string]::IsNullOrWhiteSpace($script:State.CurrentXamlPath)) {
+ $targetXamlPath = $script:State.CurrentXamlPath
+ $targetCodePath = $script:State.CurrentCodePath
+
+ if ($SaveAs -or [string]::IsNullOrWhiteSpace($targetXamlPath)) {
$dialog = [Microsoft.Win32.SaveFileDialog]::new()
$dialog.Filter = 'XAML files (*.xaml)|*.xaml'
$dialog.DefaultExt = '.xaml'
@@ -173,25 +327,48 @@ function Save-XamlDesignerDocument {
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.'
+ $targetXamlPath = $dialog.FileName
+ $targetCodePath = [System.IO.Path]::ChangeExtension($targetXamlPath, '.ps1')
+ if (-not (Confirm-CodeBehindOverwrite -CodePath $targetCodePath)) {
+ Set-DesignerStatus -Message 'Save As cancelled; the 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)
+ $oldXamlPath = $script:State.CurrentXamlPath
+ $oldCodePath = $script:State.CurrentCodePath
+ $oldCodeText = $script:State.Ui.CodeEditor.Text
- [System.IO.File]::WriteAllText($script:State.CurrentXamlPath, $xamlText, $utf8NoBom)
- [System.IO.File]::WriteAllText($script:State.CurrentCodePath, $script:State.Ui.CodeEditor.Text, $utf8NoBom)
+ try {
+ $script:State.CurrentXamlPath = $targetXamlPath
+ $script:State.CurrentCodePath = $targetCodePath
+ Sync-CodeEditor
- Update-DocumentCaption
- Set-DocumentSavedSnapshot
- Set-DesignerStatus -Message "Saved XAML and code-behind: $($script:State.CurrentXamlPath)"
+ if (-not (Test-CodeEditorPowerShell)) {
+ throw 'Generated PowerShell synchronization produced invalid syntax. The files were not saved.'
+ }
+
+ $xamlText = ConvertTo-FormattedXml -Document $script:State.XamlDocument
+ Write-DesignerDocumentPair -XamlPath $targetXamlPath -XamlText $xamlText -CodePath $targetCodePath -CodeText $script:State.Ui.CodeEditor.Text
+
+ Update-DocumentCaption
+ Set-DocumentSavedSnapshot
+ Set-DesignerStatus -Message "Saved XAML and PowerShell as UTF-8 with BOM for Windows PowerShell 5.1 compatibility: $targetXamlPath"
+ }
+ catch {
+ $script:State.CurrentXamlPath = $oldXamlPath
+ $script:State.CurrentCodePath = $oldCodePath
+ $script:State.Ui.CodeEditor.Text = $oldCodeText
+ Update-DocumentCaption
+ Set-DesignerStatus -Message ("Save failed: " + $_.Exception.Message)
+
+ [System.Windows.MessageBox]::Show(
+ 'Save failed. The designer attempted to restore the previous file pair.' +
+ [Environment]::NewLine + [Environment]::NewLine +
+ $_.Exception.Message,
+ 'Save failed',
+ [System.Windows.MessageBoxButton]::OK,
+ [System.Windows.MessageBoxImage]::Error
+ ) | Out-Null
+ }
}
diff --git a/XamlDesigner/Core/Events.ps1 b/XamlDesigner/Core/Events.ps1
index dec6e79..fd43e8c 100644
--- a/XamlDesigner/Core/Events.ps1
+++ b/XamlDesigner/Core/Events.ps1
@@ -1,17 +1,83 @@
+function Remove-GeneratedEventsForControl {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Code,
+
+ [Parameter(Mandatory)]
+ [string]$ControlName
+ )
+
+ $pattern = '(?ms)^# \r?\n.*?^# \r?\n?'
+
+ return [regex]::Replace($Code, $pattern, '')
+}
+
+function Archive-GeneratedEventsForControl {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Code,
+
+ [Parameter(Mandatory)]
+ [string]$ControlName
+ )
+
+ $pattern = '(?ms)^# \r?\n(?.*?)^# \r?\n?'
+
+ return [regex]::Replace(
+ $Code,
+ $pattern,
+ [System.Text.RegularExpressions.MatchEvaluator]{
+ param($match)
+
+ $eventName = $match.Groups['Event'].Value
+ $bodyLines = $match.Groups['Body'].Value -split '\r?\n'
+ $commentedBody = @(
+ $bodyLines | ForEach-Object {
+ if ([string]::IsNullOrEmpty($_)) {
+ '#'
+ }
+ else {
+ '# ' + $_
+ }
+ }
+ ) -join [Environment]::NewLine
+
+ return (
+ '# ' + [Environment]::NewLine +
+ '# This handler was disabled automatically because its control was deleted.' + [Environment]::NewLine +
+ $commentedBody + [Environment]::NewLine +
+ '# ' + [Environment]::NewLine
+ )
+ }
+ )
+}
+
function Generate-EventHandlerForName {
param(
[Parameter(Mandatory)]
[string]$EventName
)
- if ([string]::IsNullOrWhiteSpace($script:State.SelectedElementName) -or
- $script:State.SelectedRuntimeElement -isnot [System.Windows.FrameworkElement]) {
+ 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)
+ $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
@@ -21,39 +87,48 @@ function Generate-EventHandlerForName {
$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
+ $legacyPattern = '(?m)^\s*' + [regex]::Escape('$' + $name + '.Add_' + $EventName + '(')
+ $bracedPattern = '(?m)^\s*' + [regex]::Escape($variableReference + '.Add_' + $EventName + '(')
+
+ if (
+ $code.Contains($marker) -or
[regex]::IsMatch($code, $legacyPattern) -or
- [regex]::IsMatch($code, $bracedPattern)) {
+ [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
}
+ Push-XamlUndoSnapshot
+
+ $nl = [Environment]::NewLine
+ $block = $marker + $nl +
+ $variableReference + ".Add_$EventName({" + $nl +
+ ' param($sender, $e)' + $nl + $nl +
+ " # TODO: Add $EventName logic for $name here." + $nl +
+ '})' + $nl +
+ '# ' + $nl
+
$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"
+ Set-DesignerStatus -Message "Generated $name.$EventName before ShowDialog. Add your logic inside the new block."
}
function Generate-SelectedEventHandler {
$selectedEvent = $script:State.Ui.EventGrid.SelectedItem
if ($null -eq $selectedEvent) {
+ Set-DesignerStatus -Message 'Select an event first.'
return
}
+
Generate-EventHandlerForName -EventName ([string]$selectedEvent.Name)
}
@@ -63,12 +138,26 @@ function Get-DefaultDesignerEventName {
[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')) {
+ $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
}
@@ -84,11 +173,19 @@ function Update-SelectedCanvasPosition {
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))
+
+ $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
index 3239c87..64ac6d3 100644
--- a/XamlDesigner/Core/History.ps1
+++ b/XamlDesigner/Core/History.ps1
@@ -3,41 +3,84 @@ function Reset-XamlHistory {
$script:State.RedoStack.Clear()
}
+function New-DesignerHistorySnapshot {
+ param(
+ [string]$XamlText,
+ [string]$CodeText,
+ [string]$SelectionName
+ )
+
+ if ([string]::IsNullOrEmpty($XamlText) -and $null -ne $script:State.XamlDocument) {
+ $XamlText = ConvertTo-FormattedXml -Document $script:State.XamlDocument
+ }
+ if ($null -eq $CodeText -and $null -ne $script:State.Ui.CodeEditor) {
+ $CodeText = $script:State.Ui.CodeEditor.Text
+ }
+ if ($null -eq $SelectionName) {
+ $SelectionName = $script:State.SelectedElementName
+ }
+
+ return [pscustomobject]@{
+ XamlText = [string]$XamlText
+ CodeText = [string]$CodeText
+ SelectionName = [string]$SelectionName
+ }
+}
+
+function Test-DesignerHistorySnapshotsEqual {
+ param(
+ [Parameter(Mandatory)]
+ [object]$Left,
+
+ [Parameter(Mandatory)]
+ [object]$Right
+ )
+
+ return (
+ [string]$Left.XamlText -ceq [string]$Right.XamlText -and
+ [string]$Left.CodeText -ceq [string]$Right.CodeText -and
+ [string]$Left.SelectionName -ceq [string]$Right.SelectionName
+ )
+}
+
function Push-XamlUndoSnapshot {
param(
- [string]$Text
+ [string]$Text,
+ [string]$CodeText,
+ [string]$SelectionName
)
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) {
+ $snapshot = New-DesignerHistorySnapshot -XamlText $Text -CodeText $CodeText -SelectionName $SelectionName
+ if (
+ $script:State.UndoStack.Count -gt 0 -and
+ (Test-DesignerHistorySnapshotsEqual -Left $script:State.UndoStack.Peek() -Right $snapshot)
+ ) {
return
}
- $script:State.UndoStack.Push($Text)
+ $script:State.UndoStack.Push($snapshot)
$script:State.RedoStack.Clear()
}
function Restore-XamlHistorySnapshot {
param(
[Parameter(Mandatory)]
- [string]$Text
+ [object]$Snapshot
)
$script:State.IsRestoringHistory = $true
try {
- $script:State.XamlDocument = New-XmlDocumentFromText -Text $Text
- $script:State.SelectedElementName = $null
+ $script:State.XamlDocument = New-XmlDocumentFromText -Text ([string]$Snapshot.XamlText)
+ $script:State.Ui.CodeEditor.Text = [string]$Snapshot.CodeText
+ $script:State.SelectedElementName = [string]$Snapshot.SelectionName
$script:State.SelectedRuntimeElement = $null
Refresh-XamlTextFromDocument
- Sync-CodeEditor
- [void](Refresh-Preview)
+ [void](Refresh-Preview -KeepSelection)
+ Update-DocumentCaption
}
finally {
$script:State.IsRestoringHistory = $false
@@ -50,11 +93,11 @@ function Undo-XamlDesignerChange {
return
}
- $current = ConvertTo-FormattedXml -Document $script:State.XamlDocument
+ $current = New-DesignerHistorySnapshot
$script:State.RedoStack.Push($current)
$previous = $script:State.UndoStack.Pop()
- Restore-XamlHistorySnapshot -Text $previous
- Set-DesignerStatus -Message 'Undo completed.'
+ Restore-XamlHistorySnapshot -Snapshot $previous
+ Set-DesignerStatus -Message 'Undo completed for XAML and generated PowerShell changes.'
}
function Redo-XamlDesignerChange {
@@ -63,9 +106,9 @@ function Redo-XamlDesignerChange {
return
}
- $current = ConvertTo-FormattedXml -Document $script:State.XamlDocument
+ $current = New-DesignerHistorySnapshot
$script:State.UndoStack.Push($current)
$next = $script:State.RedoStack.Pop()
- Restore-XamlHistorySnapshot -Text $next
- Set-DesignerStatus -Message 'Redo completed.'
+ Restore-XamlHistorySnapshot -Snapshot $next
+ Set-DesignerStatus -Message 'Redo completed for XAML and generated PowerShell changes.'
}
diff --git a/XamlDesigner/Core/Preview.ps1 b/XamlDesigner/Core/Preview.ps1
index 683c317..082c5a6 100644
--- a/XamlDesigner/Core/Preview.ps1
+++ b/XamlDesigner/Core/Preview.ps1
@@ -1,9 +1,29 @@
function Get-RuntimePreviewDocument {
+ [void](Test-XamlPreviewSafety -Document $script:State.XamlDocument)
$clone = [System.Xml.XmlDocument]$script:State.XamlDocument.CloneNode($true)
Remove-PowerShellUnsupportedXamlAttributes -Document $clone
return $clone
}
+function Close-DesignerPreviewWindow {
+ param(
+ [System.Windows.Window]$Window
+ )
+
+ if ($null -eq $Window) {
+ return
+ }
+
+ try {
+ $Window.DataContext = $null
+ $Window.Close()
+ }
+ catch {
+ # The preview Window is never shown, and some WPF objects do not need
+ # explicit cleanup. Releasing references is sufficient in that case.
+ }
+}
+
function Find-VisualElementByName {
param(
[Parameter(Mandatory)]
@@ -18,7 +38,6 @@ function Find-VisualElementByName {
return $Root
}
- # FindName is fast when the element belongs to this namescope.
try {
$named = $Root.FindName($Name)
if ($named -is [System.Windows.FrameworkElement]) {
@@ -30,7 +49,6 @@ function Find-VisualElementByName {
}
}
- # 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++) {
@@ -71,10 +89,12 @@ function Get-NamedFrameworkElementFromOriginalSource {
$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 -is [System.Windows.FrameworkElement] -and
+ -not [string]::IsNullOrWhiteSpace($current.Name) -and
+ $null -ne (Get-XamlElementByName -Name $current.Name)
+ ) {
+ return $current
}
if ($current -isnot [System.Windows.DependencyObject]) {
@@ -103,6 +123,7 @@ function Get-NamedFrameworkElementFromOriginalSource {
}
$current = $parent
}
+
return $null
}
@@ -116,7 +137,11 @@ function Refresh-Preview {
)
$selectionName = $script:State.SelectedElementName
+ $previousPreviewWindow = $script:State.PreviewWindow
+
try {
+ # All safety checks and object construction happen before replacing the
+ # currently visible preview, so a failed edit does not blank the screen.
$runtimeDocument = Get-RuntimePreviewDocument
$reader = [System.Xml.XmlNodeReader]::new($runtimeDocument)
try {
@@ -130,7 +155,6 @@ function Refresh-Preview {
throw 'The root XAML element must be a WPF Window for this designer.'
}
- $script:State.PreviewWindow = $loadedRoot
$content = $loadedRoot.Content
$loadedRoot.Content = $null
@@ -140,6 +164,11 @@ function Refresh-Preview {
[void]$host.Children.Add($content)
}
+ $script:State.PreviewWindow = $loadedRoot
+ if ($null -ne $previousPreviewWindow -and $previousPreviewWindow -ne $loadedRoot) {
+ Close-DesignerPreviewWindow -Window $previousPreviewWindow
+ }
+
$width = $loadedRoot.Width
$height = $loadedRoot.Height
if ([double]::IsNaN($width) -or $width -lt 200) { $width = 800 }
@@ -167,12 +196,12 @@ function Refresh-Preview {
Refresh-DocumentOutline
Refresh-SelectionPanels
- Set-DesignerStatus -Message 'XAML preview updated successfully.'
+ Set-DesignerStatus -Message 'XAML preview updated safely.'
return $true
}
catch {
- $script:State.PreviewWindow = $null
- Set-DesignerStatus -Message ("XAML preview error: " + $_.Exception.Message)
+ $script:State.PreviewWindow = $previousPreviewWindow
+ Set-DesignerStatus -Message ("XAML preview blocked/error: " + $_.Exception.Message)
return $false
}
}
@@ -182,6 +211,7 @@ function Update-DocumentCaption {
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
index 7f9caff..e0e3788 100644
--- a/XamlDesigner/Core/Properties.ps1
+++ b/XamlDesigner/Core/Properties.ps1
@@ -1,3 +1,28 @@
+function New-EditablePropertyItem {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Name,
+
+ [Parameter(Mandatory)]
+ [string]$Value,
+
+ [Parameter(Mandatory)]
+ [string]$TypeName,
+
+ [bool]$IsAttached = $false,
+
+ [string]$Help = ''
+ )
+
+ return [pscustomobject]@{
+ Name = $Name
+ Value = $Value
+ TypeName = $TypeName
+ IsAttached = $IsAttached
+ Help = $Help
+ }
+}
+
function Get-SimpleEditableProperties {
param(
[Parameter(Mandatory)]
@@ -25,33 +50,42 @@ function Get-SimpleEditableProperties {
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 })
+ $items.Add((New-EditablePropertyItem -Name 'Canvas.Left' -Value ([string]$left) -TypeName 'System.Double' -IsAttached $true -Help 'Horizontal position in pixels. Enter a number.'))
+ $items.Add((New-EditablePropertyItem -Name 'Canvas.Top' -Value ([string]$top) -TypeName 'System.Double' -IsAttached $true -Help 'Vertical position in pixels. Enter a number.'))
}
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 })
+ $items.Add((New-EditablePropertyItem -Name 'Grid.Row' -Value ([string][System.Windows.Controls.Grid]::GetRow($Element)) -TypeName 'System.Int32' -IsAttached $true -Help 'Zero-based row number, for example 0 or 1.'))
+ $items.Add((New-EditablePropertyItem -Name 'Grid.Column' -Value ([string][System.Windows.Controls.Grid]::GetColumn($Element)) -TypeName 'System.Int32' -IsAttached $true -Help 'Zero-based column number, for example 0 or 1.'))
+ $items.Add((New-EditablePropertyItem -Name 'Grid.RowSpan' -Value ([string][System.Windows.Controls.Grid]::GetRowSpan($Element)) -TypeName 'System.Int32' -IsAttached $true -Help 'Number of Grid rows to span. Minimum is 1.'))
+ $items.Add((New-EditablePropertyItem -Name 'Grid.ColumnSpan' -Value ([string][System.Windows.Controls.Grid]::GetColumnSpan($Element)) -TypeName 'System.Int32' -IsAttached $true -Help 'Number of Grid columns to span. Minimum is 1.'))
}
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 })
+ $items.Add((New-EditablePropertyItem -Name 'DockPanel.Dock' -Value ([string][System.Windows.Controls.DockPanel]::GetDock($Element)) -TypeName 'System.Windows.Controls.Dock' -IsAttached $true -Help 'Suggested values: Left, Top, Right, Bottom.'))
}
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 })
+ $items.Add((New-EditablePropertyItem -Name 'Panel.ZIndex' -Value ([string][System.Windows.Controls.Panel]::GetZIndex($Element)) -TypeName 'System.Int32' -IsAttached $true -Help 'Drawing order. Larger numbers appear above smaller numbers.'))
}
- $properties = @($Element.GetType().GetProperties([System.Reflection.BindingFlags]'Public,Instance') | Where-Object {
- $_.CanRead -and $_.CanWrite -and $_.GetIndexParameters().Count -eq 0
- })
+ $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])
+ $editable = (
+ $type.IsEnum -or
+ $type -eq [string] -or
+ $type.IsPrimitive -or
+ $type -eq [decimal] -or
+ $converter.CanConvertFrom([string])
+ )
if (-not $editable) {
continue
}
@@ -73,22 +107,36 @@ function Get-SimpleEditableProperties {
$text = [string]$value
}
- [pscustomobject]@{
- Name = $property.Name
- Value = $text
- TypeName = $type.FullName
- IsAttached = $false
+ $help = "Type: $($type.Name)."
+ if ($type.IsEnum) {
+ $help += ' Suggested values: ' + ([Enum]::GetNames($type) -join ', ') + '.'
+ }
+ elseif ($type -eq [bool]) {
+ $help += ' Enter True or False.'
+ }
+ elseif ($type -eq [double] -or $type -eq [single] -or $type -eq [decimal]) {
+ $help += ' Enter a number using a dot as the decimal separator.'
+ }
+ elseif ($property.Name -eq 'Name') {
+ $help = 'x:Name must start with a letter or underscore and contain only letters, digits, and underscores.'
}
+
+ New-EditablePropertyItem -Name $property.Name -Value $text -TypeName $type.FullName -Help $help
}
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)
+ $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)
@@ -102,11 +150,13 @@ function Refresh-PropertyGrid {
$grid.ItemsSource = $null
$script:State.Ui.PropertyNameText.Text = 'Select a property'
$script:State.Ui.PropertyValueText.Text = ''
+ $script:State.Ui.PropertyHelpText.Text = 'Select a property to see its type and suggested values.'
$element = $script:State.SelectedRuntimeElement
if ($element -isnot [System.Windows.FrameworkElement]) {
return
}
+
$grid.ItemsSource = Get-SimpleEditableProperties -Element $element
}
@@ -118,22 +168,50 @@ function Refresh-EventGrid {
return
}
- $events = @($element.GetType().GetEvents([System.Reflection.BindingFlags]'Public,Instance') | Sort-Object Name | ForEach-Object {
- [pscustomobject]@{
- Name = $_.Name
- DeclaringType = $_.DeclaringType.Name
- }
- })
+ $commonEvents = @(
+ 'Click',
+ 'Checked',
+ 'Unchecked',
+ 'SelectionChanged',
+ 'TextChanged',
+ 'ValueChanged',
+ 'SelectedDateChanged',
+ 'MouseDoubleClick',
+ 'KeyDown',
+ 'Loaded'
+ )
+
+ $events = @(
+ $element.GetType().GetEvents([System.Reflection.BindingFlags]'Public,Instance') |
+ ForEach-Object {
+ $priority = [array]::IndexOf($commonEvents, $_.Name)
+ if ($priority -lt 0) {
+ $priority = 1000
+ }
+
+ [pscustomobject]@{
+ Name = $_.Name
+ DeclaringType = $_.DeclaringType.Name
+ Priority = $priority
+ }
+ } |
+ Sort-Object Priority, Name
+ )
+
$grid.ItemsSource = $events
}
function Refresh-SelectionPanels {
- if ([string]::IsNullOrWhiteSpace($script:State.SelectedElementName) -or $null -eq $script:State.SelectedRuntimeElement) {
+ 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
}
@@ -147,12 +225,13 @@ function Select-DesignerElement {
$script:State.SelectedRuntimeElement = $Element
$script:State.SelectedElementName = $Element.Name
Refresh-SelectionPanels
- Set-DesignerStatus -Message "Selected $($Element.Name)."
+ Set-DesignerStatus -Message "Selected $($Element.Name). Choose a property on the right, or double-click the control to generate its common event."
}
function Apply-SelectedProperty {
$selected = $script:State.Ui.PropertyGrid.SelectedItem
if ($null -eq $selected -or [string]::IsNullOrWhiteSpace($script:State.SelectedElementName)) {
+ Set-DesignerStatus -Message 'Select a control and a property first.'
return
}
@@ -164,6 +243,7 @@ function Apply-SelectedProperty {
$propertyName = [string]$selected.Name
$value = $script:State.Ui.PropertyValueText.Text
$oldXml = ConvertTo-FormattedXml -Document $script:State.XamlDocument
+ $oldCode = $script:State.Ui.CodeEditor.Text
$oldName = $script:State.SelectedElementName
try {
@@ -171,10 +251,12 @@ function Apply-SelectedProperty {
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
@@ -189,18 +271,21 @@ function Apply-SelectedProperty {
}
if (-not (Refresh-Preview -KeepSelection)) {
- throw 'The property value is not valid for this XAML element.'
+ throw 'WPF rejected the property value. Check the suggested value/type and try again.'
}
- Push-XamlUndoSnapshot -Text $oldXml
+
+ Push-XamlUndoSnapshot -Text $oldXml -CodeText $oldCode -SelectionName $oldName
Refresh-XamlTextFromDocument
Sync-CodeEditor
Set-DesignerStatus -Message "Applied $propertyName to $($script:State.SelectedElementName)."
}
catch {
$script:State.XamlDocument = New-XmlDocumentFromText -Text $oldXml
+ $script:State.Ui.CodeEditor.Text = $oldCode
$script:State.SelectedElementName = $oldName
[void](Refresh-Preview -KeepSelection)
Refresh-XamlTextFromDocument
+
[System.Windows.MessageBox]::Show(
$_.Exception.Message,
'Property update failed',
diff --git a/XamlDesigner/Core/State.ps1 b/XamlDesigner/Core/State.ps1
index e4eae53..23ebd5f 100644
--- a/XamlDesigner/Core/State.ps1
+++ b/XamlDesigner/Core/State.ps1
@@ -5,6 +5,7 @@ $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:PresentationOptionsNs = 'http://schemas.microsoft.com/winfx/2006/xaml/presentation/options'
$script:State = [ordered]@{
Window = $null
@@ -25,7 +26,7 @@ $script:State = [ordered]@{
DesignerDragStartLeft = 0.0
DesignerDragStartTop = 0.0
DesignerDragCanvas = $null
- UndoStack = [System.Collections.Generic.Stack[string]]::new()
+ UndoStack = [System.Collections.Generic.Stack[object]]::new()
RedoStack = [System.Collections.Generic.Stack[string]]::new()
IsRestoringHistory = $false
}
diff --git a/XamlDesigner/Core/ToolboxCatalog.ps1 b/XamlDesigner/Core/ToolboxCatalog.ps1
index 597a968..6413b54 100644
--- a/XamlDesigner/Core/ToolboxCatalog.ps1
+++ b/XamlDesigner/Core/ToolboxCatalog.ps1
@@ -1,3 +1,63 @@
+function Get-WpfControlBeginnerDescription {
+ param(
+ [Parameter(Mandatory)]
+ [Type]$Type
+ )
+
+ $descriptions = @{
+ Button = 'Clickable command button. A good first control for generating a Click event.'
+ Label = 'Short label for describing another input control.'
+ TextBlock = 'Lightweight text display for headings, instructions, or results.'
+ TextBox = 'Editable text input. Use Text or TextChanged when you need user input.'
+ PasswordBox = 'Password-style input that hides typed characters.'
+ CheckBox = 'On/off choice that can be checked independently.'
+ RadioButton = 'One choice in a mutually exclusive group.'
+ ComboBox = 'Drop-down list for choosing one item.'
+ ListBox = 'List that allows one or more items to be selected.'
+ ListView = 'Flexible list with richer item display than ListBox.'
+ TreeView = 'Hierarchical tree for folders, categories, and nested data.'
+ DataGrid = 'Table-style control for rows and columns of data.'
+ Slider = 'Numeric value selector that the user drags.'
+ ProgressBar = 'Read-only visual indicator for progress.'
+ DatePicker = 'Date entry control with a calendar drop-down.'
+ Calendar = 'Full calendar control for choosing dates.'
+ Image = 'Displays an image. Use local files carefully when sharing the XAML.'
+ Border = 'Single-child container that adds a border, background, or padding.'
+ GroupBox = 'Single-child container with a visible heading.'
+ ScrollViewer = 'Single-child container that adds scrolling.'
+ Viewbox = 'Single-child container that scales its content.'
+ Canvas = 'Free-positioning panel. Best choice for drag-and-drop beginner layouts.'
+ Grid = 'Row-and-column layout panel. Best general-purpose WPF layout.'
+ StackPanel = 'Places children in a vertical or horizontal line.'
+ WrapPanel = 'Places children in a line and wraps when space runs out.'
+ DockPanel = 'Docks children to Top, Bottom, Left, or Right.'
+ UniformGrid = 'Grid where every cell has the same size.'
+ TabControl = 'Shows several pages of content as tabs.'
+ Rectangle = 'Rectangle shape. Set Fill, Stroke, Width, and Height.'
+ Ellipse = 'Ellipse or circle shape. Set Fill, Stroke, Width, and Height.'
+ Line = 'Straight line shape controlled by X1, Y1, X2, and Y2.'
+ }
+
+ if ($descriptions.ContainsKey($Type.Name)) {
+ return $descriptions[$Type.Name]
+ }
+
+ if ([System.Windows.Controls.Panel].IsAssignableFrom($Type)) {
+ return 'WPF layout panel. Select it before adding child controls when you want to target this container.'
+ }
+ if ([System.Windows.Controls.Decorator].IsAssignableFrom($Type)) {
+ return 'WPF decorator/container. Some decorators accept only one child.'
+ }
+ if ([System.Windows.Shapes.Shape].IsAssignableFrom($Type)) {
+ return 'WPF vector shape. Adjust its size, Stroke, Fill, and other drawing properties.'
+ }
+ if ([System.Windows.Controls.Control].IsAssignableFrom($Type)) {
+ return 'Standard WPF control. Select it to inspect editable properties and available events.'
+ }
+
+ return 'Advanced WPF visual element. It may require XAML knowledge or additional child content.'
+}
+
function Get-WpfControlCatalog {
$frameworkElementType = [System.Windows.FrameworkElement]
$excluded = @(
@@ -6,6 +66,14 @@ function Get-WpfControlCatalog {
'System.Windows.Controls.Page'
)
+ $commonTypeNames = @(
+ 'Button','Label','TextBlock','TextBox','PasswordBox','CheckBox','RadioButton',
+ 'ComboBox','ListBox','ListView','TreeView','DataGrid','Slider','ProgressBar',
+ 'DatePicker','Calendar','Image','Border','GroupBox','ScrollViewer','Viewbox',
+ 'Canvas','Grid','StackPanel','WrapPanel','DockPanel','UniformGrid','TabControl',
+ 'Rectangle','Ellipse','Line'
+ )
+
$assemblies = @(
[System.Windows.Controls.Control].Assembly,
[System.Windows.Controls.Panel].Assembly,
@@ -56,23 +124,25 @@ function Get-WpfControlCatalog {
DisplayName = $type.Name
FullName = $type.FullName
Category = $category
+ IsCommon = $commonTypeNames -contains $type.Name
+ Description = Get-WpfControlBeginnerDescription -Type $type
Type = $type
}
}
- return @($catalog | Sort-Object DisplayName, FullName)
+ return @($catalog | Sort-Object @{ Expression = { if ($_.IsCommon) { 0 } else { 1 } } }, 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."
+ Set-DesignerStatus -Message "Toolbox loaded $($script:State.ToolboxItems.Count) WPF element types. Common beginner controls are shown first."
}
function Apply-ToolboxFilter {
$search = $script:State.Ui.ToolboxSearch.Text
$categoryItem = $script:State.Ui.ToolboxCategory.SelectedItem
- $category = 'All'
+ $category = 'Common'
if ($null -ne $categoryItem -and $null -ne $categoryItem.Content) {
$category = [string]$categoryItem.Content
}
@@ -80,13 +150,27 @@ function Apply-ToolboxFilter {
$filtered = $script:State.ToolboxItems
if (-not [string]::IsNullOrWhiteSpace($search)) {
$filtered = @($filtered | Where-Object {
- $_.DisplayName -like "*$search*" -or $_.FullName -like "*$search*"
+ $_.DisplayName -like "*$search*" -or
+ $_.FullName -like "*$search*" -or
+ $_.Description -like "*$search*"
})
}
- if ($category -ne 'All') {
+
+ if ($category -eq 'Common') {
+ $filtered = @($filtered | Where-Object IsCommon)
+ }
+ elseif ($category -ne 'All') {
$filtered = @($filtered | Where-Object Category -eq $category)
}
+
$script:State.Ui.ToolboxList.ItemsSource = $filtered
+
+ if ($filtered.Count -eq 0) {
+ $script:State.Ui.ToolboxHelpText.Text = 'No controls match the current search/filter.'
+ }
+ elseif ($null -eq $script:State.Ui.ToolboxList.SelectedItem) {
+ $script:State.Ui.ToolboxHelpText.Text = 'Select a control to see what it is used for. Double-click or press Add selected control to insert it.'
+ }
}
function New-UniqueControlName {
diff --git a/XamlDesigner/Core/ToolboxEditing.ps1 b/XamlDesigner/Core/ToolboxEditing.ps1
index e276ad1..089c434 100644
--- a/XamlDesigner/Core/ToolboxEditing.ps1
+++ b/XamlDesigner/Core/ToolboxEditing.ps1
@@ -33,15 +33,23 @@ function Set-DefaultNewElementAttributes {
'ListBox' { @{ Width = '180'; Height = '120' }; break }
'ListView' { @{ Width = '220'; Height = '140' }; break }
'TreeView' { @{ Width = '220'; Height = '180' }; break }
+ 'DataGrid' { @{ Width = '320'; Height = '180'; AutoGenerateColumns = 'True' }; 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 }
+ 'DatePicker' { @{ Width = '160'; Height = '30' }; break }
+ 'Calendar' { @{ Width = '240'; Height = '180' }; 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 }
+ 'Border' { @{ Width = '200'; Height = '130'; BorderBrush = 'Gray'; BorderThickness = '1'; Padding = '6' }; break }
+ 'GroupBox' { @{ Header = 'Group'; Width = '220'; Height = '150' }; break }
+ 'ScrollViewer' { @{ Width = '220'; Height = '150'; VerticalScrollBarVisibility = 'Auto' }; break }
+ 'Viewbox' { @{ Width = '220'; Height = '150' }; break }
+ 'Canvas' { @{ Width = '260'; Height = '180'; Background = 'Transparent' }; break }
+ 'Grid' { @{ Width = '260'; Height = '180'; Background = 'Transparent' }; break }
'StackPanel' { @{ Width = '220'; Height = '160' }; break }
'WrapPanel' { @{ Width = '220'; Height = '160' }; break }
'DockPanel' { @{ Width = '220'; Height = '160' }; break }
+ 'UniformGrid' { @{ Width = '220'; Height = '160'; Rows = '2'; Columns = '2' }; break }
+ 'TabControl' { @{ Width = '300'; Height = '200' }; 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 }
@@ -82,16 +90,24 @@ function Test-XamlSingleChildContainerNode {
return $Node.LocalName -in @('Border', 'GroupBox', 'ScrollViewer', 'Viewbox')
}
-function Test-XamlNodeHasDirectElementChild {
+function Test-XamlNodeHasDirectContentChild {
param(
[Parameter(Mandatory)]
[System.Xml.XmlElement]$Node
)
foreach ($child in $Node.ChildNodes) {
- if ($child -is [System.Xml.XmlElement]) {
- return $true
+ if ($child -isnot [System.Xml.XmlElement]) {
+ continue
+ }
+
+ # Property elements such as Border.Background or Grid.RowDefinitions
+ # configure the parent and are not the parent's content child.
+ if ($child.LocalName.Contains('.')) {
+ continue
}
+
+ return $true
}
return $false
@@ -107,7 +123,7 @@ function Get-PrimaryDesignContainerNode {
if (
$null -ne $selectedNode -and
(Test-XamlSingleChildContainerNode -Node $selectedNode) -and
- -not (Test-XamlNodeHasDirectElementChild -Node $selectedNode)
+ -not (Test-XamlNodeHasDirectContentChild -Node $selectedNode)
) {
return $selectedNode
}
@@ -130,6 +146,12 @@ function Get-PrimaryDesignContainerNode {
if (Test-XamlLayoutContainerNode -Node $child) {
return $child
}
+ if (
+ (Test-XamlSingleChildContainerNode -Node $child) -and
+ -not (Test-XamlNodeHasDirectContentChild -Node $child)
+ ) {
+ return $child
+ }
}
return $null
}
@@ -146,12 +168,15 @@ function Add-ToolboxElementToDocument {
$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.'
+ Set-DesignerStatus -Message 'No supported target container was found. Select a panel or an empty Border/GroupBox/ScrollViewer/Viewbox.'
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."
+ if (
+ (Test-XamlSingleChildContainerNode -Node $container) -and
+ (Test-XamlNodeHasDirectContentChild -Node $container)
+ ) {
+ Set-DesignerStatus -Message "$($container.LocalName) already has a content child. Select another container or edit XAML source."
return
}
@@ -165,11 +190,97 @@ function Add-ToolboxElementToDocument {
Refresh-XamlTextFromDocument
Sync-CodeEditor
[void](Refresh-Preview -KeepSelection)
+
$targetName = Get-ElementNameFromNode -Node $container
- if ([string]::IsNullOrWhiteSpace($targetName)) { $targetName = $container.LocalName }
+ if ([string]::IsNullOrWhiteSpace($targetName)) {
+ $targetName = $container.LocalName
+ }
Set-DesignerStatus -Message "Added $($Type.Name) as $name to $targetName."
}
+function Add-SelectedToolboxItem {
+ $item = $script:State.Ui.ToolboxList.SelectedItem
+ if ($null -eq $item) {
+ Set-DesignerStatus -Message 'Select a toolbox control first.'
+ return
+ }
+
+ $left = 20.0
+ $top = 20.0
+ $container = Get-PrimaryDesignContainerNode
+ if ($null -ne $container -and $container.LocalName -eq 'Canvas') {
+ $contentCount = 0
+ foreach ($child in $container.ChildNodes) {
+ if ($child -is [System.Xml.XmlElement] -and -not $child.LocalName.Contains('.')) {
+ $contentCount++
+ }
+ }
+ $offset = ($contentCount % 8) * 20
+ $left += $offset
+ $top += $offset
+ }
+
+ Add-ToolboxElementToDocument -Type $item.Type -Left $left -Top $top
+}
+
+function New-UniqueCloneName {
+ param(
+ [Parameter(Mandatory)]
+ [string]$BaseName,
+
+ [Parameter(Mandatory)]
+ [System.Collections.Generic.HashSet[string]]$ReservedNames
+ )
+
+ $safeBase = $BaseName -replace '[^A-Za-z0-9_]', ''
+ if ([string]::IsNullOrWhiteSpace($safeBase)) {
+ $safeBase = 'Control'
+ }
+
+ $index = 1
+ do {
+ $candidate = "$safeBase$index"
+ $index++
+ } while ($ReservedNames.Contains($candidate))
+
+ [void]$ReservedNames.Add($candidate)
+ return $candidate
+}
+
+function Update-ClonedNameReferences {
+ param(
+ [Parameter(Mandatory)]
+ [System.Xml.XmlElement]$Root,
+
+ [Parameter(Mandatory)]
+ [hashtable]$RenameMap
+ )
+
+ foreach ($node in @($Root) + @($Root.SelectNodes('.//*'))) {
+ if ($node -isnot [System.Xml.XmlElement]) {
+ continue
+ }
+
+ foreach ($attribute in @($node.Attributes)) {
+ $updated = $attribute.Value
+ foreach ($oldName in $RenameMap.Keys) {
+ $newName = [string]$RenameMap[$oldName]
+ if ($updated -eq $oldName -and $attribute.LocalName -in @('TargetName','SourceName','Storyboard.TargetName')) {
+ $updated = $newName
+ }
+ $updated = [regex]::Replace(
+ $updated,
+ '(?i)(ElementName\s*=\s*)' + [regex]::Escape($oldName) + '(?=\s*[,}])',
+ '$1' + $newName
+ )
+ }
+ if ($updated -cne $attribute.Value) {
+ $attribute.Value = $updated
+ }
+ }
+ }
+}
+
function Copy-XamlElementNode {
param(
[Parameter(Mandatory)]
@@ -177,59 +288,125 @@ function Copy-XamlElementNode {
)
$copy = [System.Xml.XmlElement]$Source.CloneNode($true)
- $newName = New-UniqueControlName -BaseName $Source.LocalName
- Set-ElementNameOnNode -Node $copy -Name $newName
+ $reservedNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
+ foreach ($item in Get-AllNamedXamlElements) {
+ [void]$reservedNames.Add($item.Name)
+ }
+
+ $renameMap = @{}
+ $newRootName = $null
+ foreach ($node in @($copy) + @($copy.SelectNodes('.//*'))) {
+ if ($node -isnot [System.Xml.XmlElement]) {
+ continue
+ }
+
+ $oldName = Get-ElementNameFromNode -Node $node
+ if ([string]::IsNullOrWhiteSpace($oldName)) {
+ continue
+ }
+
+ if (Test-XamlNodeInSeparateNameScope -Node $node) {
+ continue
+ }
+
+ $newName = New-UniqueCloneName -BaseName $node.LocalName -ReservedNames $reservedNames
+ Set-ElementNameOnNode -Node $node -Name $newName
+ $renameMap[$oldName] = $newName
+
+ if ($node -eq $copy) {
+ $newRootName = $newName
+ }
+ }
+
+ Update-ClonedNameReferences -Root $copy -RenameMap $renameMap
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))
+ 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
+ return $newRootName
}
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
+ $deletedControlNames = [System.Collections.Generic.List[string]]::new()
+ foreach ($subtreeNode in @($node) + @($node.SelectNodes('.//*'))) {
+ if ($subtreeNode -isnot [System.Xml.XmlElement]) {
+ continue
+ }
+ if (Test-XamlNodeInSeparateNameScope -Node $subtreeNode) {
+ continue
+ }
+ $subtreeName = Get-ElementNameFromNode -Node $subtreeNode
+ if (-not [string]::IsNullOrWhiteSpace($subtreeName)) {
+ $deletedControlNames.Add($subtreeName)
+ }
+ }
+
[void]$node.ParentNode.RemoveChild($node)
+
+ if (Get-Command Archive-GeneratedEventsForControl -ErrorAction SilentlyContinue) {
+ foreach ($controlName in $deletedControlNames) {
+ $script:State.Ui.CodeEditor.Text = Archive-GeneratedEventsForControl -Code $script:State.Ui.CodeEditor.Text -ControlName $controlName
+ }
+ }
+
$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."
+ Set-DesignerStatus -Message "Deleted $deletedName. Generated handlers for $($deletedControlNames.Count) named control(s) were disabled and archived as comments so your event code is not lost."
}
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
+ if ([string]::IsNullOrWhiteSpace($newName)) {
+ Set-DesignerStatus -Message 'The selected XAML element has no x:Name and could not be selected after duplication.'
+ return
+ }
+
$script:State.SelectedElementName = $newName
Refresh-XamlTextFromDocument
Sync-CodeEditor
[void](Refresh-Preview -KeepSelection)
- Set-DesignerStatus -Message "Duplicated control as $newName."
+ Set-DesignerStatus -Message "Duplicated the control subtree as $newName. Named child controls were also renamed to keep x:Name values unique."
}
-
function Move-SelectedCanvasElementBy {
param(
[double]$DeltaX,
diff --git a/XamlDesigner/Core/Ui.ps1 b/XamlDesigner/Core/Ui.ps1
index 5c30e90..6a739f2 100644
--- a/XamlDesigner/Core/Ui.ps1
+++ b/XamlDesigner/Core/Ui.ps1
@@ -1,14 +1,14 @@
-function Initialize-UiReferences {
+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',
+ 'MenuNew','MenuOpen','MenuSave','MenuSaveAs','MenuExit','MenuUndo','MenuRedo','MenuDelete','MenuDuplicate','MenuValidate','MenuRefreshToolbox','MenuGettingStarted','MenuAbout',
+ 'StatusText','DocumentText','ToolboxSearch','ToolboxCategory','ToolboxList','ButtonAddToolbox','ToolboxHelpText','OutlineTree','MainTabs','PreviewBorder','PreviewHost','CheckSnapToGrid',
'ButtonDelete','ButtonDuplicate','ButtonApplyXaml','ButtonFormatXaml','ButtonValidateCode','XamlEditor','CodeEditor','SelectedControlText','PropertyGrid',
- 'PropertyNameText','PropertyValueText','ButtonApplyProperty','EventGrid'
+ 'PropertyNameText','PropertyValueText','PropertyHelpText','ButtonApplyProperty','EventGrid'
)
foreach ($name in $names) {
@@ -37,6 +37,30 @@ function Register-UiEvents {
$ui.MenuDuplicate.Add_Click({ Duplicate-SelectedElement })
$ui.MenuValidate.Add_Click({ [void](Apply-XamlEditorText) })
$ui.MenuRefreshToolbox.Add_Click({ Refresh-ToolboxCatalog })
+ $ui.MenuGettingStarted.Add_Click({
+ $message = @'
+PowerShell XAML Designer - Getting Started
+
+1. Choose a control in Toolbox. "Common" is recommended for beginners.
+2. Double-click it, press "Add selected control", or drag it onto the Designer.
+3. Click the new control on the Designer.
+4. Choose a property on the right, edit its value, and press "Apply property".
+5. Double-click a control to create its common PowerShell event handler.
+6. Press Ctrl+S to save the .xaml and paired .ps1 files.
+
+Tip: Canvas is easiest for free positioning. Grid is better when you are ready to build structured layouts.
+
+日本語:
+左の Toolbox から部品を選び、ダブルクリックまたは「Add selected control」で追加できます。
+右の Properties で値を変更し、部品をダブルクリックすると代表的なイベントコードを生成できます。
+'@
+ [System.Windows.MessageBox]::Show(
+ $message,
+ 'Getting Started',
+ [System.Windows.MessageBoxButton]::OK,
+ [System.Windows.MessageBoxImage]::Information
+ ) | Out-Null
+ })
$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.",
@@ -89,6 +113,22 @@ function Register-UiEvents {
$ui.ToolboxSearch.Add_TextChanged({ Apply-ToolboxFilter })
$ui.ToolboxCategory.Add_SelectionChanged({ Apply-ToolboxFilter })
+ $ui.ToolboxList.Add_SelectionChanged({
+ $item = $ui.ToolboxList.SelectedItem
+ if ($null -eq $item) {
+ $ui.ToolboxHelpText.Text = 'Select a control to see what it is used for.'
+ return
+ }
+
+ $ui.ToolboxHelpText.Text = "$($item.DisplayName): $($item.Description)"
+ })
+ $ui.ButtonAddToolbox.Add_Click({ Add-SelectedToolboxItem })
+ $ui.ToolboxList.Add_MouseDoubleClick({
+ if ($null -ne $ui.ToolboxList.SelectedItem) {
+ Add-SelectedToolboxItem
+ }
+ })
+
$ui.ToolboxList.Add_PreviewMouseLeftButtonDown({
param($sender, $e)
$script:State.ToolboxDragOrigin = $e.GetPosition($sender)
@@ -252,6 +292,7 @@ function Register-UiEvents {
}
$ui.PropertyNameText.Text = "$($item.Name) [$($item.TypeName)]"
$ui.PropertyValueText.Text = [string]$item.Value
+ $ui.PropertyHelpText.Text = [string]$item.Help
})
$ui.ButtonApplyProperty.Add_Click({ Apply-SelectedProperty })
$ui.PropertyValueText.Add_KeyDown({
diff --git a/XamlDesigner/Core/Xml.ps1 b/XamlDesigner/Core/Xml.ps1
index b97f5ff..c80aa2d 100644
--- a/XamlDesigner/Core/Xml.ps1
+++ b/XamlDesigner/Core/Xml.ps1
@@ -7,7 +7,7 @@ function ConvertTo-FormattedXml {
$settings = [System.Xml.XmlWriterSettings]::new()
$settings.Indent = $true
$settings.IndentChars = ' '
- $settings.NewLineChars = "`r`n"
+ $settings.NewLineChars = [Environment]::NewLine
$settings.NewLineHandling = [System.Xml.NewLineHandling]::Replace
$settings.OmitXmlDeclaration = $true
@@ -20,7 +20,7 @@ function ConvertTo-FormattedXml {
$writer.Close()
}
- return $builder.ToString().Trim() + "`r`n"
+ return $builder.ToString().Trim() + [Environment]::NewLine
}
function New-XmlDocumentFromText {
@@ -29,9 +29,6 @@ function New-XmlDocumentFromText {
[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
@@ -53,12 +50,18 @@ function New-XmlDocumentFromText {
function Get-BlankXamlText {
$path = Join-Path $script:State.BaseDirectory 'Templates\BlankWindow.xaml'
- return Get-Content -LiteralPath $path -Raw
+ if (Get-Command Read-DesignerTextFile -ErrorAction SilentlyContinue) {
+ return Read-DesignerTextFile -Path $path
+ }
+ return [System.IO.File]::ReadAllText($path)
}
function Get-BlankCodeText {
$path = Join-Path $script:State.BaseDirectory 'Templates\BlankWindow.ps1'
- return Get-Content -LiteralPath $path -Raw
+ if (Get-Command Read-DesignerTextFile -ErrorAction SilentlyContinue) {
+ return Read-DesignerTextFile -Path $path
+ }
+ return [System.IO.File]::ReadAllText($path)
}
function Get-ElementNameFromNode {
@@ -110,6 +113,37 @@ function Get-XamlElementByName {
return $null
}
+function Test-XamlNodeInSeparateNameScope {
+ param(
+ [Parameter(Mandatory)]
+ [System.Xml.XmlElement]$Node
+ )
+
+ $separateScopeNames = @(
+ 'ControlTemplate',
+ 'DataTemplate',
+ 'ItemsPanelTemplate',
+ 'HierarchicalDataTemplate',
+ 'Style',
+ 'Setter',
+ 'Trigger',
+ 'MultiTrigger',
+ 'DataTrigger',
+ 'MultiDataTrigger',
+ 'ResourceDictionary'
+ )
+
+ $current = $Node.ParentNode
+ while ($current -is [System.Xml.XmlElement]) {
+ if ($current.LocalName -in $separateScopeNames -or $current.LocalName -like '*.Resources') {
+ return $true
+ }
+ $current = $current.ParentNode
+ }
+
+ return $false
+}
+
function Get-AllNamedXamlElements {
$result = [System.Collections.Generic.List[object]]::new()
if ($null -eq $script:State.XamlDocument) {
@@ -118,12 +152,14 @@ function Get-AllNamedXamlElements {
$root = $script:State.XamlDocument.DocumentElement
foreach ($node in $script:State.XamlDocument.SelectNodes('//*')) {
- if ($node -isnot [System.Xml.XmlElement]) {
+ if ($node -isnot [System.Xml.XmlElement] -or $node -eq $root) {
continue
}
- if ($node -eq $root) {
+
+ if (Test-XamlNodeInSeparateNameScope -Node $node) {
continue
}
+
$name = Get-ElementNameFromNode -Node $node
if (-not [string]::IsNullOrWhiteSpace($name)) {
$result.Add([pscustomobject]@{
@@ -133,6 +169,7 @@ function Get-AllNamedXamlElements {
})
}
}
+
return $result
}
@@ -159,8 +196,9 @@ function Get-WpfTypeByElementName {
}
}
+ $presentationAssembly = [System.Windows.Controls.Control].Assembly
foreach ($namespace in @('System.Windows.Controls', 'System.Windows.Shapes', 'System.Windows.Documents')) {
- $type = [Type]::GetType("$namespace.$ElementName, PresentationFramework", $false)
+ $type = $presentationAssembly.GetType("$namespace.$ElementName", $false, $false)
if ($null -ne $type) {
return $type
}
@@ -169,6 +207,82 @@ function Get-WpfTypeByElementName {
return $null
}
+function Test-XamlPreviewSafety {
+ param(
+ [Parameter(Mandatory)]
+ [System.Xml.XmlDocument]$Document
+ )
+
+ $blockedElements = @(
+ 'ObjectDataProvider',
+ 'XmlDataProvider'
+ )
+
+ foreach ($node in $Document.SelectNodes('//*')) {
+ if ($node -isnot [System.Xml.XmlElement]) {
+ continue
+ }
+
+ if ($node.NamespaceURI -eq $script:XamlNs -and $node.LocalName -in @('Code','FactoryMethod','Arguments')) {
+ throw "Safe preview blocked x:$($node.LocalName). Loose XAML preview must not execute embedded code or factory-method construction."
+ }
+
+ if (
+ -not [string]::IsNullOrWhiteSpace($node.NamespaceURI) -and
+ $node.NamespaceURI -ne $script:PresentationNs -and
+ $node.NamespaceURI -ne $script:XamlNs
+ ) {
+ throw "Safe preview blocked element '$($node.Name)' from non-standard namespace '$($node.NamespaceURI)'. Custom CLR controls are not loaded automatically."
+ }
+
+ if ($node.LocalName -in $blockedElements) {
+ throw "Safe preview blocked '$($node.LocalName)' because it can load data or invoke methods during XAML object construction."
+ }
+
+ if ($node.LocalName -eq 'ResourceDictionary' -and $node.HasAttribute('Source')) {
+ throw 'Safe preview blocked ResourceDictionary Source because external XAML dictionaries can load files or network resources. Inline the resources for preview.'
+ }
+
+ foreach ($attribute in @($node.Attributes)) {
+ if ($attribute.Prefix -eq 'xmlns' -or $attribute.Name -eq 'xmlns') {
+ continue
+ }
+
+ if (
+ -not [string]::IsNullOrWhiteSpace($attribute.NamespaceURI) -and
+ $attribute.NamespaceURI -notin @(
+ $script:XamlNs,
+ $script:McNs,
+ $script:DesignNs,
+ $script:PresentationNs,
+ $script:PresentationOptionsNs,
+ 'http://www.w3.org/XML/1998/namespace'
+ )
+ ) {
+ throw "Safe preview blocked attribute '$($attribute.Name)' from namespace '$($attribute.NamespaceURI)'."
+ }
+
+ if ($attribute.NamespaceURI -eq $script:XamlNs -and $attribute.LocalName -in @('FactoryMethod','Arguments')) {
+ throw "Safe preview blocked x:$($attribute.LocalName)."
+ }
+
+ $staticMatch = [regex]::Match(
+ $attribute.Value,
+ '\{x:(?:Static|Type)\s+(?[A-Za-z_][A-Za-z0-9_.-]*):'
+ )
+ if ($staticMatch.Success) {
+ $prefix = $staticMatch.Groups['Prefix'].Value
+ $resolvedNamespace = $node.GetNamespaceOfPrefix($prefix)
+ if ($resolvedNamespace -like 'clr-namespace:*') {
+ throw "Safe preview blocked x:Static/x:Type reference to custom CLR namespace prefix '$prefix'."
+ }
+ }
+ }
+ }
+
+ return $true
+}
+
function Remove-PowerShellUnsupportedXamlAttributes {
param(
[Parameter(Mandatory)]
@@ -180,8 +294,6 @@ function Remove-PowerShellUnsupportedXamlAttributes {
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)
@@ -190,33 +302,44 @@ function Remove-PowerShellUnsupportedXamlAttributes {
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)
+ continue
+ }
+
+ if (
+ $attribute.LocalName -in @('Source','UriSource','NavigateUri') -and
+ $attribute.Value -match '^(?i)(https?|ftp|file):|^(\\\\|//)'
+ ) {
+ # Avoid automatic network/file navigation while merely previewing XAML.
+ $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)
+ $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) {
+ if (
+ [string]::IsNullOrWhiteSpace($attribute.NamespaceURI) -and
+ $eventNames -contains $attribute.LocalName
+ ) {
$eventAttributes.Add($attribute)
}
}
diff --git a/XamlDesigner/GETTING_STARTED.ja.md b/XamlDesigner/GETTING_STARTED.ja.md
new file mode 100644
index 0000000..dd2a513
--- /dev/null
+++ b/XamlDesigner/GETTING_STARTED.ja.md
@@ -0,0 +1,219 @@
+# はじめての PowerShell XAML Designer
+
+このページは、PowerShell や WPF/XAML にまだ慣れていない方向けの手順です。
+
+## 1. まず知っておくこと
+
+このツールは Windows の WPF を使うため、**Windows 専用**です。
+
+Visual Studio や Blend、PowerShell Gallery の追加モジュールは必要ありません。基本的に Windows と PowerShell の機能だけで動作します。
+
+画面と処理は、次の2ファイルに分けます。
+
+~~~text
+MyWindow.xaml ← 画面・レイアウト・プロパティ
+MyWindow.ps1 ← ボタンのクリック等のイベント・処理
+~~~
+
+この2ファイルは同じフォルダーに置き、同じ基本ファイル名で扱います。
+
+## 2. 起動する
+
+リポジトリのフォルダーで PowerShell を開き、次を実行します。
+
+~~~powershell
+.\XamlDesigner\Start-XamlDesigner.ps1
+~~~
+
+WPFに必要なSTAモードでなかった場合、起動スクリプトが同じPowerShellをSTAモードで自動的に起動し直します。
+
+会社PCで実行ポリシーによってスクリプトが禁止されている場合は、組織のルールや管理者の案内に従ってください。ツール側から実行ポリシーを無理に回避することはしません。
+
+## 3. 最初のボタンを置く
+
+1. 左側の **Toolbox** を開く。
+2. Category は最初 **Common** になっている。
+3. **Button** を選ぶ。
+4. **Add selected control** を押す。
+ - Button をダブルクリックしても追加可能。
+ - Designerへドラッグ&ドロップしても追加可能。
+5. 中央のDesignerに Button1 が表示される。
+
+初心者の場合、最初は Canvas レイアウトのままで問題ありません。マウスや矢印キーで部品を自由に移動できます。
+
+## 4. ボタンの文字を変える
+
+1. Designer上の Button1 をクリック。
+2. 右側の **Properties** を開く。
+3. Content を選ぶ。
+4. 下の入力欄へ、たとえば「実行」と入力。
+5. **Apply property** を押す。
+
+プロパティを選ぶと、入力欄の下に型や候補値のヒントが表示されます。
+
+たとえば HorizontalAlignment のような列挙型では、使用可能な候補が表示されます。
+
+## 5. ボタンを押したときの処理を作る
+
+もっとも簡単な方法は、Designer上のボタンを**ダブルクリック**することです。
+
+Buttonの場合は代表イベントである Click が選ばれ、PowerShellタブに次のようなコードが生成されます。
+
+~~~powershell
+#
+${Button1}.Add_Click({
+ param($sender, $e)
+
+ # TODO: Add Click logic for Button1 here.
+})
+#
+~~~
+
+たとえば、TODO部分を次のように変更できます。
+
+~~~powershell
+[System.Windows.MessageBox]::Show('こんにちは')
+~~~
+
+イベント一覧から作りたい場合は、右側の **Events** タブを開き、イベント名をダブルクリックします。初心者がよく使うイベントは上のほうに表示されます。
+
+## 6. 保存する
+
+Ctrl+S、または File → Save を使います。
+
+初回はXAMLファイル名を指定します。たとえば、
+
+~~~text
+SampleWindow.xaml
+SampleWindow.ps1
+~~~
+
+の2ファイルが保存されます。
+
+Windows PowerShell 5.1で日本語が文字化けしにくいよう、保存時はUTF-8 BOM付きで書き込みます。
+
+Save As時に同名の .ps1 がすでに存在する場合は、上書き前に確認されます。
+
+保存中に2ファイルの片方でエラーが起きた場合は、可能な限り保存前のペアへ戻す処理を行います。
+
+## 7. 作成した画面を実行する
+
+保存した .ps1 を実行します。
+
+~~~powershell
+powershell.exe -NoProfile -STA -File .\SampleWindow.ps1
+~~~
+
+PowerShell 7の場合:
+
+~~~powershell
+pwsh.exe -NoProfile -STA -File .\SampleWindow.ps1
+~~~
+
+## 8. よく使う部品
+
+| 部品 | 用途 |
+|---|---|
+| Button | ボタン |
+| TextBlock | 説明文や結果表示 |
+| TextBox | 文字入力 |
+| CheckBox | ON/OFF選択 |
+| RadioButton | 複数候補から1つ選択 |
+| ComboBox | ドロップダウン |
+| ListBox | 一覧 |
+| DataGrid | 表形式 |
+| DatePicker | 日付入力 |
+| ProgressBar | 進捗表示 |
+| Image | 画像 |
+| Canvas | 自由配置 |
+| Grid | 行・列を使った本格的な配置 |
+| StackPanel | 縦または横に順番に配置 |
+| Border | 枠・背景・余白を付ける |
+
+Toolboxで部品を選択すると、その部品の簡単な説明も表示されます。
+
+## 9. Canvasでの移動
+
+Canvas上の部品は次の方法で移動できます。
+
+- マウスでドラッグ
+- 矢印キー: 1px
+- Shift + 矢印キー: 10px
+
+Snap 10 px がONの場合、マウス移動やドロップ位置が10px単位に揃います。
+
+## 10. Undo / Redo
+
+- Ctrl+Z: Undo
+- Ctrl+Y: Redo
+
+Designerの変更では、XAMLだけでなくDesignerが生成・変更したPowerShellコードも一緒に戻すようにしています。
+
+XAMLソースやPowerShellコードのテキストボックスへ直接入力している最中は、通常のテキスト編集のUndo/Redoとして動作します。
+
+## 11. コントロールを削除するとイベントはどうなるか
+
+コントロールを削除したとき、そのコントロール用にDesignerが生成したイベントコードをそのまま残すと、存在しない変数を参照して実行エラーになります。
+
+一方、イベント内に自分で書いた処理を消してしまうのも危険です。
+
+そのため、イベントコードは削除せず、**コメント化して ArchivedEvent として保存**します。
+
+必要ならそこからコードをコピーできます。間違えて削除した直後なら Ctrl+Z でも戻せます。
+
+## 12. Safe Previewについて
+
+XAMLは単なる見た目のXMLとは限らず、読み込み時に.NETオブジェクトを生成します。
+
+そのためDesignerのプレビューでは、安全側に次を制限します。
+
+- ObjectDataProvider
+- XmlDataProvider
+- カスタム clr-namespace: のコントロールを自動生成するXAML
+- 外部 ResourceDictionary Source
+- 一部の外部URL/UNCパスを自動読込する属性
+- x:Code やファクトリメソッド構築
+
+Visual Studioが付けるだけで実際には未使用の xmlns:local="clr-namespace:..." 宣言は、標準WPF部品だけのXAMLであれば許容します。
+
+この制限は**Designerがファイルを開いただけで意図しないコードや外部リソースを読み込まないため**のものです。
+
+## 13. XAMLエラーが出たら
+
+まず **XAML source** タブを開きます。
+
+- **Validate / Apply**: XAMLを検査してプレビュー更新
+- **Format XML**: XMLとして正しければ整形
+- F5: Validate / Apply
+
+失敗しても、直前に表示できていたプレビューは可能な限り残します。
+
+プロパティ編集で不正な値を入れた場合も、変更前のXAMLとPowerShellへ戻してからエラーを表示します。
+
+## 14. 最初は触らなくてよいもの
+
+初心者の段階では、次は後回しで構いません。
+
+- ControlTemplate / DataTemplate
+- ResourceDictionary
+- Bindingの複雑な設定
+- Style / Trigger
+- Gridの複雑なRow/Column定義
+
+まずは Canvas + Button + TextBox + TextBlock の組み合わせから始めると分かりやすいです。
+
+## 15. まだ実装していない主な機能
+
+現時点ではVisual Studioの完全な代替ではありません。
+
+今後の候補:
+
+- リサイズハンドル
+- Grid行・列のGUI編集
+- XAML入力補完
+- XAML構文色分け
+- Style / Resource / Templateの専用GUI
+- カスタムコントロールを安全に明示ロードする仕組み
+- 複数画面をまとめるプロジェクト管理
+
+現在の目標は、**Visual Studioを導入できない環境でも、標準WPF/XAMLの画面とPowerShell処理を分離して、できるだけGUIで作成できること**です。
diff --git a/XamlDesigner/README.md b/XamlDesigner/README.md
index f97e33c..a7f0433 100644
--- a/XamlDesigner/README.md
+++ b/XamlDesigner/README.md
@@ -2,6 +2,8 @@
A standalone WPF/XAML visual editor implemented with PowerShell and Windows WPF.
+**New to PowerShell/WPF?** Start with [GETTING_STARTED.ja.md](./GETTING_STARTED.ja.md). The latest 50-pass simulated persona review is documented in [REVIEW_50_PERSONAS.md](./REVIEW_50_PERSONAS.md).
+
## 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.
@@ -69,7 +71,9 @@ The toolbox is not a small hard-coded list. At startup, PowerShell reflects over
- 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.
+Beginners start in the **Common** category, which contains frequently used controls with a short description. **All** still exposes the full runtime-discovered catalog. Search matches the type name, full type name, and beginner description.
+
+A control can be inserted by drag-and-drop, double-clicking the toolbox item, or pressing **Add selected control**, so drag-and-drop is not required.
Root-only objects such as another `Window` are intentionally excluded from drag/drop insertion.
@@ -106,7 +110,7 @@ Selecting a visual element uses .NET reflection and `TypeDescriptor` to enumerat
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.
+A property change is first applied to the XML DOM and then reloaded into WPF. If WPF rejects the value, both the XAML and generated PowerShell state are rolled back. The editor also displays type/enum hints for common beginner mistakes.
### Events
@@ -135,6 +139,8 @@ Double-clicking a control on the designer chooses a typical event when possible,
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.
+When a control is deleted, its generated handlers are disabled and converted to commented `ArchivedEvent` blocks. This avoids runtime references to missing controls while preserving logic the user may have written inside the handler.
+
## Visual Studio / Blend XAML compatibility
XAML exported from Visual Studio can contain information intended for a compiled WPF project, for example:
@@ -148,6 +154,20 @@ A standalone PowerShell `XamlReader` has no compiled code-behind class that can
The paired PowerShell template uses the same principle at runtime. UI structure remains in `.xaml`; executable event registration remains in `.ps1`.
+## Safe preview
+
+Loose XAML can construct .NET objects while it is being loaded. Opening an untrusted file therefore should not be treated like opening inert text.
+
+Before XamlReader is called, the designer performs a safe-preview check. It blocks or neutralizes, among other things:
+
+- ObjectDataProvider and XmlDataProvider;
+- custom CLR elements/attached properties from clr-namespace:;
+- x:Code, factory-method/argument construction;
+- external ResourceDictionary Source;
+- automatic external/UNC Source, UriSource, or NavigateUri loading in the preview clone.
+
+Unused Visual Studio-style xmlns:local="clr-namespace:..." declarations are allowed when the actual elements are standard WPF controls. The original source document is preserved; preview-only cleanup is applied to an in-memory clone.
+
## Keyboard shortcuts
| Shortcut | Action |
@@ -209,3 +229,9 @@ The next editor features with the highest value are:
6. Project folder mode for multiple `.xaml` / `.ps1` pairs.
These can remain PowerShell-only; no C# helper assembly is required for the core design.
+
+## File encoding and recovery
+
+Saved XAML/PowerShell pairs use UTF-8 with BOM so Japanese and other non-ASCII PowerShell code remains compatible with Windows PowerShell 5.1. On open, valid UTF-8 is preferred; legacy Windows code-page text is used only as a fallback for invalid UTF-8.
+
+Both temporary files are written before the target pair is overwritten. Existing targets are backed up during the save operation and are restored on a partial failure when possible.
diff --git a/XamlDesigner/REVIEW_50_PERSONAS.md b/XamlDesigner/REVIEW_50_PERSONAS.md
new file mode 100644
index 0000000..3faef9e
--- /dev/null
+++ b/XamlDesigner/REVIEW_50_PERSONAS.md
@@ -0,0 +1,71 @@
+# 50 Persona Self-Review Record
+
+This file records the requested 50-pass review. These are **simulated reviewer personas** used to force different perspectives; they are not independent human reviewers.
+
+| # | Simulated reviewer persona | Main finding | Disposition |
+|---:|---|---|---|
+| 1 | First-time PowerShell learner | Drag-and-drop alone is not an obvious way to add a control. | Fixed: Add selected control button and double-click insertion. |
+| 2 | Excel/VBA user new to WPF | Raw WPF type names do not explain what a control is for. | Fixed: beginner descriptions for toolbox items. |
+| 3 | Office administrator without Visual Studio | Startup should not require knowing STA terminology. | Fixed: startup script automatically relaunches in STA when possible. |
+| 4 | Japanese beginner | Japanese UI/code can be damaged by Windows PowerShell 5.1 encoding behavior. | Fixed: UTF-8 BOM save plus UTF-8/legacy read detection. |
+| 5 | Mouse/drag beginner | Precise drag-and-drop is difficult. | Fixed: button/double-click insertion and sensible default positions. |
+| 6 | Keyboard-only beginner | Toolbox was effectively mouse-only. | Fixed: keyboard-selectable toolbox plus Add button; Canvas arrow movement retained. |
+| 7 | Small corporate laptop user | 1500x920 with large minimum size was intimidating on small/high-DPI screens. | Fixed: smaller minimum dimensions and narrower side panes. |
+| 8 | User creating a first button event | Alphabetical event lists hide common events. | Fixed: common events are sorted first. |
+| 9 | User afraid of overwriting files | Save As can also overwrite the paired .ps1 unexpectedly. | Fixed: explicit paired-file overwrite confirmation. |
+| 10 | User entering an invalid property | Property editor did not explain legal values. | Fixed: type/enum/boolean hints and rollback message. |
+| 11 | Windows PowerShell 5.1 expert | UTF-8 without BOM can break Japanese .ps1 execution in 5.1. | Fixed: write XAML and PS1 with UTF-8 BOM. |
+| 12 | PowerShell 7 expert | WPF STA requirements should not be a manual setup trap. | Fixed: automatic STA relaunch and dual-version CI. |
+| 13 | WPF layout specialist | Single-child containers with property elements could be mistaken for already-filled containers. | Fixed: property elements are not treated as content children. |
+| 14 | WPF NameScope specialist | Template/resource x:Name values must not become Window.FindName variables. | Fixed: separate NameScope nodes are excluded from generated references. |
+| 15 | PowerShell code-behind reviewer | XAML and generated code must remain one logical edit during undo/redo. | Fixed: unified XAML+code+selection history snapshots. |
+| 16 | Encoding/internationalization reviewer | Existing no-BOM legacy files need a reasonable fallback. | Fixed: strict UTF-8 first, system code page fallback only when invalid UTF-8. |
+| 17 | WPF Binding specialist | Duplicating a subtree can leave ElementName bindings pointing at the original control. | Fixed: duplicated known name references are rewritten. |
+| 18 | Toolbox UX reviewer | Showing every reflected WPF type first overwhelms beginners. | Fixed: Common category is default while All remains available. |
+| 19 | Resource/template specialist | Template names and ResourceDictionary behaviors can differ from Window content. | Fixed: NameScope filtering; external ResourceDictionary Source is blocked in safe preview. |
+| 20 | WPF memory/performance reviewer | Repeated preview reloads kept old Window references. | Fixed: previous preview Window is released/closed after successful replacement. |
+| 21 | Application security engineer | ObjectDataProvider can invoke methods during XAML construction. | Fixed: blocked by safe preview. |
+| 22 | Corporate network administrator | Merely previewing XAML should not automatically contact remote URLs/UNC paths. | Fixed: external Source/UriSource/NavigateUri values are stripped in the preview clone. |
+| 23 | File-system reliability reviewer | Two-file saves can leave an inconsistent pair after a partial failure. | Fixed: both temporary files are written first and previous files are backed up/restored on failure. |
+| 24 | Enterprise execution-policy reviewer | Documentation/CI should not normalize ExecutionPolicy Bypass. | Fixed in new startup guidance and CI commands; organizational policy is respected. |
+| 25 | Untrusted-file reviewer | Custom clr-namespace elements should not instantiate automatically when a file is opened. | Fixed: custom CLR elements/attached attributes are blocked by safe preview. |
+| 26 | Keyboard accessibility reviewer | Core add/move operations need non-mouse paths. | Fixed: Add button, double-click alternative, arrow movement, existing shortcuts. |
+| 27 | Screen-reader reviewer | Unlabelled source/search/property controls are difficult to identify. | Fixed: AutomationProperties.Name added to key controls. |
+| 28 | High-DPI reviewer | Fixed large minimum window dimensions reduce usability under scaling. | Fixed: lower minimum dimensions and flexible columns. |
+| 29 | High-contrast reviewer | Designer chrome should avoid unnecessary hard-coded colors. | Improved: shell uses defaults/system border brush; white remains only where it represents the designed surface. |
+| 30 | Cognitive-load reviewer | First-run UI lacked a short sequence of what to do next. | Fixed: Getting Started dialog, Common toolbox, contextual status text. |
+| 31 | QA engineer | Critical behavior was reviewed manually but not locked in tests. | Fixed: Test-DesignerCore.ps1 added. |
+| 32 | Regression-test engineer | Duplication, NameScope, encoding, and event cleanup need direct regression cases. | Fixed: dedicated core behavior checks. |
+| 33 | CI maintainer | Only part of behavior was exercised across PowerShell versions. | Fixed: PowerShell 7 and Windows PowerShell 5.1 run repository/core/codegen/startup tests. |
+| 34 | Maintainability reviewer | Safety/encoding logic should be explicit named helpers rather than repeated snippets. | Improved: Read-DesignerTextFile, Write-DesignerDocumentPair, Test-XamlPreviewSafety and related helpers. |
+| 35 | Module-structure reviewer | Responsibilities should remain separated among Documents, Xml, Preview, Events, History, and UI. | Maintained: new logic was placed in the corresponding modules rather than one monolith. |
+| 36 | Technical writer | README alone is too dense for a novice tutorial. | Fixed: Japanese getting-started guide added. |
+| 37 | Internal trainer | A user should be able to learn the six basic steps without leaving the app. | Fixed: bilingual Getting Started message. |
+| 38 | OSS maintainer | Repository has no explicit license, which makes reuse rights unclear. | Deferred: license choice is an owner/legal decision and was not selected automatically. |
+| 39 | Backward-compatibility reviewer | Legacy ANSI PowerShell files should not be immediately corrupted on open. | Improved: fallback decoding for non-UTF-8 files, with standardized UTF-8 BOM on save. |
+| 40 | Error-recovery reviewer | A failed preview should not blank the last working design. | Fixed: previous successful preview remains until a new preview succeeds. |
+| 41 | Visual Studio XAML compatibility reviewer | presentation/options is a standard WPF namespace and should not be treated as custom code. | Fixed: standard presentation-options attributes are allowed. |
+| 42 | Existing-sample user | Visual Studio x:Class/d:/mc: metadata should remain source-compatible while PowerShell previews work. | Preserved: unsupported build/design attributes are removed only from the runtime clone. |
+| 43 | Drag-and-drop usability reviewer | Newly added advanced controls can be invisible or too small with generic defaults. | Improved: practical defaults for DataGrid, DatePicker, Calendar, GroupBox, ScrollViewer, Viewbox, UniformGrid, TabControl, etc. |
+| 44 | Non-mouse corporate user | There must be a way to insert a control without drag/drop precision. | Fixed: Add selected control. |
+| 45 | Property-editor novice | Grid.Row and DockPanel.Dock are not self-explanatory. | Fixed: attached-property help text and examples. |
+| 46 | Event-programming novice | Double-click generation is useful but should clearly say where code was created. | Fixed: status guidance and event ordering/help text. |
+| 47 | Generated-code safety reviewer | Deleting a control can leave executable handlers referencing missing variables. | Fixed: handlers for deleted subtree controls are disabled. |
+| 48 | Data-loss reviewer | Simply deleting generated event blocks would also delete user-written logic inside them. | Fixed: deleted-control handlers are archived as commented ArchivedEvent blocks instead of discarded. |
+| 49 | Product UX reviewer | A beginner needs a safe path from “empty window” to “saved working button” with minimal concepts. | Fixed: Common controls, descriptions, beginner dialog/guide, defaults, hints, and safer save flow. |
+| 50 | Release-gate reviewer | Merge should require syntax, security regression, code generation, startup, PS5.1 and PS7 checks. | Implemented in CI; final merge is gated on successful workflow results. |
+
+## Remaining intentional boundaries
+
+The review did not convert the project into a complete Visual Studio clone. The following remain future work rather than hidden defects:
+
+- visual resize handles/adorners;
+- a graphical Grid row/column editor;
+- syntax coloring and XAML completion;
+- dedicated Style/Resource/Template designers;
+- explicit user-approved custom-control assembly loading;
+- multi-window/project explorer support.
+
+## Review principle for beginners
+
+Where ease-of-use and power conflicted, the review preferred a safe default with an advanced path still available. Examples include Common versus All toolbox categories, safe preview versus automatic custom CLR loading, and archiving deleted event code instead of silently discarding it.
diff --git a/XamlDesigner/Start-XamlDesigner.ps1 b/XamlDesigner/Start-XamlDesigner.ps1
index c49ed0d..9e1fdd9 100644
--- a/XamlDesigner/Start-XamlDesigner.ps1
+++ b/XamlDesigner/Start-XamlDesigner.ps1
@@ -9,25 +9,60 @@ if ($env:OS -ne 'Windows_NT') {
}
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.'
-}
+ if ($env:POWERSHELL_XAML_DESIGNER_STA_RELAUNCH -eq '1') {
+ throw 'The designer could not start an STA PowerShell process. Start powershell.exe or pwsh.exe with -STA manually.'
+ }
-Add-Type -AssemblyName WindowsBase
-Add-Type -AssemblyName PresentationCore
-Add-Type -AssemblyName PresentationFramework
+ $enginePath = (Get-Process -Id $PID).Path
+ if ([string]::IsNullOrWhiteSpace($enginePath)) {
+ throw 'WPF requires an STA thread, and the current PowerShell executable could not be identified.'
+ }
-$modulePath = Join-Path $PSScriptRoot 'XamlDesigner.Core.psm1'
-Import-Module -Name $modulePath -Force
+ $env:POWERSHELL_XAML_DESIGNER_STA_RELAUNCH = '1'
+ $quotedScriptPath = '"' + $PSCommandPath + '"'
+ Start-Process -FilePath $enginePath -ArgumentList @(
+ '-NoProfile',
+ '-STA',
+ '-File',
+ $quotedScriptPath
+ )
+ return
+}
-$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)
+ 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 = [System.IO.File]::ReadAllText($designerXamlPath)
+ $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()
}
-finally {
- $reader.Close()
+catch {
+ try {
+ [System.Windows.MessageBox]::Show(
+ 'PowerShell XAML Designer could not start.' +
+ [Environment]::NewLine + [Environment]::NewLine +
+ $_.Exception.Message,
+ 'PowerShell XAML Designer',
+ [System.Windows.MessageBoxButton]::OK,
+ [System.Windows.MessageBoxImage]::Error
+ ) | Out-Null
+ }
+ catch {
+ Write-Error $_.Exception.Message
+ }
+ throw
}
-
-Initialize-XamlDesigner -Window $designerWindow -BaseDirectory $PSScriptRoot
-$null = $designerWindow.ShowDialog()
diff --git a/XamlDesigner/Templates/BlankWindow.ps1 b/XamlDesigner/Templates/BlankWindow.ps1
index 3aa1b10..e5b8c5c 100644
--- a/XamlDesigner/Templates/BlankWindow.ps1
+++ b/XamlDesigner/Templates/BlankWindow.ps1
@@ -40,6 +40,7 @@ function ConvertTo-PowerShellRuntimeXaml {
'System.Windows.Shapes',
'System.Windows.Documents'
)
+ $presentationAssembly = [System.Windows.Controls.Control].Assembly
foreach ($node in $runtimeDocument.SelectNodes('//*')) {
if ($node -isnot [System.Xml.XmlElement]) {
@@ -58,7 +59,7 @@ function ConvertTo-PowerShellRuntimeXaml {
}
else {
foreach ($namespace in $typeNamespaces) {
- $candidate = [Type]::GetType("$namespace.$($node.LocalName), PresentationFramework", $false)
+ $candidate = $presentationAssembly.GetType("$namespace.$($node.LocalName)", $false, $false)
if ($null -ne $candidate) {
$wpfType = $candidate
break
diff --git a/XamlDesigner/XamlDesigner.xaml b/XamlDesigner/XamlDesigner.xaml
index 1fef0af..e5c22aa 100644
--- a/XamlDesigner/XamlDesigner.xaml
+++ b/XamlDesigner/XamlDesigner.xaml
@@ -2,10 +2,10 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Name="XamlDesignerWindow"
Title="PowerShell XAML Designer"
- Width="1500"
- Height="920"
- MinWidth="1100"
- MinHeight="700"
+ Width="1400"
+ Height="850"
+ MinWidth="900"
+ MinHeight="600"
WindowStartupLocation="CenterScreen">
-
+
-
+
-
+
-
+
-
+
-
-
+
-
-
-
-
+
+
+
+
+
+
+
@@ -67,54 +91,97 @@
-
+
+
+
+
+
-
+
-
+
-
+
-
-
+
-
-
+
+
-
+
-
-
+
+
+ MinWidth="500"
+ MinHeight="340"
+ AllowDrop="True"
+ ToolTip="Design surface. Select controls here; double-click a control to create its common event handler.">
-
+
-
-
+
+
+ SpellCheck.IsEnabled="False"
+ AutomationProperties.Name="XAML source editor"/>
-
+
-
+ Margin="12,0,0,0"
+ ToolTip="Check PowerShell syntax without running the code."/>
+ SpellCheck.IsEnabled="False"
+ AutomationProperties.Name="PowerShell code editor"/>
-
+
-
-
-
+
+
-
+
-
-
-
+
+
+
+
+ HeadersVisibility="Column"
+ AutomationProperties.Name="Properties">
@@ -177,16 +269,17 @@
-
+
+ HeadersVisibility="Column"
+ AutomationProperties.Name="Events">