From a384c6f330bcaf834cad7fcc3b6ded8c50e5c318 Mon Sep 17 00:00:00 2001 From: papanda925 Date: Tue, 1 Sep 2026 14:41:38 +0900 Subject: [PATCH 01/30] Add PowerShell-only WPF XAML designer foundation --- .../workflows/powershell-xaml-designer.yml | 22 ++ README.md | 96 +++++++- Tests/Test-Repository.ps1 | 33 +++ XamlDesigner/Core/CodeBehind.ps1 | 68 ++++++ XamlDesigner/Core/Documents.ps1 | 117 +++++++++ XamlDesigner/Core/Events.ps1 | 84 +++++++ XamlDesigner/Core/Preview.ps1 | 120 +++++++++ XamlDesigner/Core/Properties.ps1 | 179 ++++++++++++++ XamlDesigner/Core/State.ps1 | 52 ++++ XamlDesigner/Core/ToolboxCatalog.ps1 | 112 +++++++++ XamlDesigner/Core/ToolboxEditing.ps1 | 172 +++++++++++++ XamlDesigner/Core/Ui.ps1 | 228 ++++++++++++++++++ XamlDesigner/Core/Xml.ps1 | 211 ++++++++++++++++ XamlDesigner/README.md | 207 ++++++++++++++++ XamlDesigner/Start-XamlDesigner.ps1 | 33 +++ XamlDesigner/Templates/BlankWindow.ps1 | 108 +++++++++ XamlDesigner/Templates/BlankWindow.xaml | 10 + XamlDesigner/XamlDesigner.Core.psm1 | 38 +++ XamlDesigner/XamlDesigner.xaml | 180 ++++++++++++++ 19 files changed, 2069 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/powershell-xaml-designer.yml create mode 100644 Tests/Test-Repository.ps1 create mode 100644 XamlDesigner/Core/CodeBehind.ps1 create mode 100644 XamlDesigner/Core/Documents.ps1 create mode 100644 XamlDesigner/Core/Events.ps1 create mode 100644 XamlDesigner/Core/Preview.ps1 create mode 100644 XamlDesigner/Core/Properties.ps1 create mode 100644 XamlDesigner/Core/State.ps1 create mode 100644 XamlDesigner/Core/ToolboxCatalog.ps1 create mode 100644 XamlDesigner/Core/ToolboxEditing.ps1 create mode 100644 XamlDesigner/Core/Ui.ps1 create mode 100644 XamlDesigner/Core/Xml.ps1 create mode 100644 XamlDesigner/README.md create mode 100644 XamlDesigner/Start-XamlDesigner.ps1 create mode 100644 XamlDesigner/Templates/BlankWindow.ps1 create mode 100644 XamlDesigner/Templates/BlankWindow.xaml create mode 100644 XamlDesigner/XamlDesigner.Core.psm1 create mode 100644 XamlDesigner/XamlDesigner.xaml diff --git a/.github/workflows/powershell-xaml-designer.yml b/.github/workflows/powershell-xaml-designer.yml new file mode 100644 index 0000000..9ce6402 --- /dev/null +++ b/.github/workflows/powershell-xaml-designer.yml @@ -0,0 +1,22 @@ +name: PowerShell XAML Designer checks + +on: + push: + branches: [ main ] + pull_request: + +jobs: + validate: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Parse PowerShell and XAML + shell: pwsh + run: .\Tests\Test-Repository.ps1 + - name: Verify WPF assemblies can load + shell: pwsh + run: | + Add-Type -AssemblyName WindowsBase + Add-Type -AssemblyName PresentationCore + Add-Type -AssemblyName PresentationFramework + [System.Windows.Window] | Out-String | Write-Host diff --git a/README.md b/README.md index cd0fd6f..633c5bd 100644 --- a/README.md +++ b/README.md @@ -1 +1,95 @@ -# PowerShell_WPF_XAML_GUI_SampleCodes +# PowerShell WPF XAML GUI Sample Codes / PowerShell XAML Designer + +This repository is being refocused around a **PowerShell-only WPF/XAML GUI designer** for Windows. + +The goal is to make it possible to create and maintain PowerShell WPF screens even in environments where Visual Studio, Blend, paid IDE add-ons, or third-party GUI designers cannot be installed or used. + +The original WPF sample scripts are kept as compatibility/reference samples. The main tool is now under [`XamlDesigner`](./XamlDesigner/). + +## PowerShell XAML Designer + +The designer follows a two-file model: + +```text +MyWindow.xaml # UI / layout +MyWindow.ps1 # PowerShell control references, event registration, application logic +``` + +This is intentional. A standalone PowerShell `XamlReader` does not use the compiled C#/VB code-behind model that Visual Studio WPF projects use. Therefore the designer generates PowerShell event registration such as: + +```powershell +[System.Windows.Controls.Button]$Button1 = $Window.FindName('Button1') + +$Button1.Add_Click({ + param($sender, $e) + + # Add logic here. +}) +``` + +instead of depending on `Click="Button1_Click"` in XAML. + +### Current features + +- PowerShell + WPF only; no Visual Studio and no third-party module required at runtime. +- New / Open / Save / Save As for paired `.xaml` and `.ps1` files. +- Runtime-discovered toolbox for public, instantiable WPF visual element types. +- Toolbox search and category filtering. +- Drag and drop from the toolbox to the design surface. +- Direct mouse movement for controls whose parent is a `Canvas`. +- Optional 10-pixel snap-to-grid. +- Selection of controls on the preview surface. +- Reflection-based property browser and property editing. +- Reflection-based event browser. +- Double-click an event to generate PowerShell event code. +- Double-click a control on the designer to generate its typical/default event (`Click`, `SelectionChanged`, `TextChanged`, etc.). +- XAML source editing, XML formatting, and well-formedness validation with line/position reporting. +- Live WPF preview through `System.Windows.Markup.XamlReader`. +- Preview-time removal of Visual Studio/Blend build-time attributes such as `x:Class`, `mc:Ignorable`, and `d:*`. +- Preview-time removal of XAML event attributes that cannot be resolved by standalone PowerShell, while preserving the source document. +- Automatic synchronization of named XAML controls into a generated control-reference region in the paired `.ps1` file. +- Existing user event code is not overwritten when control references are refreshed. + +### Start the designer + +Windows PowerShell 5.1: + +```powershell +powershell.exe -STA -ExecutionPolicy Bypass -File .\XamlDesigner\Start-XamlDesigner.ps1 +``` + +PowerShell 7 on Windows: + +```powershell +pwsh.exe -STA -File .\XamlDesigner\Start-XamlDesigner.ps1 +``` + +WPF requires Windows and an STA thread. + +## Design direction + +Visual Studio's XML editor provides features such as XML syntax checking, schema-aware validation, IntelliSense, snippets, and document outlining. The PowerShell XAML Designer aims to provide the most useful subset for standalone PowerShell/WPF work without requiring a Visual Studio project. + +The visual designer intentionally uses a `Canvas` as the default root layout because absolute positioning makes drag/move behavior deterministic. Existing `Grid`, `StackPanel`, `DockPanel`, `WrapPanel`, and `UniformGrid` layouts can still be opened and previewed, and controls can be added to supported root layout containers. Direct coordinate dragging is currently limited to `Canvas` parents. + +See [`XamlDesigner/README.md`](./XamlDesigner/README.md) for architecture, workflow, limitations, and planned editor features. + +## Existing samples + +The original files remain available at the repository root as WPF/PowerShell examples: + +- `WPF_CustomGraphicalInputBoxSample.*` +- `WPF_GraphicalDatePickerSample.*` +- `WPF_InkCanvas.ps1` +- `WPF_OCR_Sample.ps1` +- `WPF_SimpleWeatherFormSample/` + +They are useful as real-world XAML compatibility samples for the designer. + +## Microsoft references + +- Visual Studio XML editor: https://learn.microsoft.com/visualstudio/xml-tools/xml-editor +- Editing XML files: https://learn.microsoft.com/visualstudio/xml-tools/how-to-edit-xml-files +- XML editor IntelliSense: https://learn.microsoft.com/visualstudio/xml-tools/xml-editor-intellisense-features +- WPF drag and drop: https://learn.microsoft.com/dotnet/desktop/wpf/advanced/drag-and-drop-overview +- WPF dependency properties: https://learn.microsoft.com/dotnet/desktop/wpf/properties/dependency-properties-overview diff --git a/Tests/Test-Repository.ps1 b/Tests/Test-Repository.ps1 new file mode 100644 index 0000000..6cb1804 --- /dev/null +++ b/Tests/Test-Repository.ps1 @@ -0,0 +1,33 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$errorsFound = [System.Collections.Generic.List[string]]::new() + +Get-ChildItem -LiteralPath $repositoryRoot -Recurse -File -Include *.ps1,*.psm1 | ForEach-Object { + $tokens = $null + $parseErrors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$parseErrors) + foreach ($parseError in $parseErrors) { + $errorsFound.Add("PowerShell parse error: $($_.FullName):$($parseError.Extent.StartLineNumber):$($parseError.Extent.StartColumnNumber) $($parseError.Message)") + } +} + +Get-ChildItem -LiteralPath $repositoryRoot -Recurse -File -Include *.xaml | ForEach-Object { + try { + [xml]$null = Get-Content -LiteralPath $_.FullName -Raw + } + catch { + $errorsFound.Add("XML parse error: $($_.FullName): $($_.Exception.Message)") + } +} + +if ($errorsFound.Count -gt 0) { + $errorsFound | ForEach-Object { Write-Error $_ } + throw "$($errorsFound.Count) repository validation error(s) found." +} + +Write-Host 'PowerShell syntax and XAML/XML well-formedness checks passed.' diff --git a/XamlDesigner/Core/CodeBehind.ps1 b/XamlDesigner/Core/CodeBehind.ps1 new file mode 100644 index 0000000..0190f68 --- /dev/null +++ b/XamlDesigner/Core/CodeBehind.ps1 @@ -0,0 +1,68 @@ +function Sync-CodeBehindXamlFileName { + param( + [Parameter(Mandatory)] + [string]$Code, + + [Parameter(Mandatory)] + [string]$XamlFileName + ) + + $start = '# ' + $end = '# ' + $replacement = @" +$start +`$xamlFileName = '$($XamlFileName.Replace("'", "''"))' +$end +"@ + + $pattern = [regex]::Escape($start) + '.*?' + [regex]::Escape($end) + if ([regex]::IsMatch($Code, $pattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)) { + return [regex]::Replace($Code, $pattern, [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $replacement }, [System.Text.RegularExpressions.RegexOptions]::Singleline) + } + + return $replacement + "`r`n`r`n" + $Code +} + +function Get-ControlReferenceLines { + $lines = [System.Collections.Generic.List[string]]::new() + foreach ($item in (Get-AllNamedXamlElements | Sort-Object Name)) { + $type = Get-WpfTypeByElementName -ElementName $item.ElementName + $typeName = 'System.Windows.FrameworkElement' + if ($null -ne $type) { + $typeName = $type.FullName + } + $lines.Add("[$typeName]`$$($item.Name) = `$Window.FindName('$($item.Name.Replace("'", "''"))')") + } + return $lines +} + +function Sync-CodeBehindControlReferences { + param( + [Parameter(Mandatory)] + [string]$Code + ) + + $start = '# ' + $end = '# ' + $lines = Get-ControlReferenceLines + $body = if ($lines.Count -gt 0) { $lines -join "`r`n" } else { '# No named child controls.' } + $replacement = "$start`r`n$body`r`n$end" + $pattern = [regex]::Escape($start) + '.*?' + [regex]::Escape($end) + + if ([regex]::IsMatch($Code, $pattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)) { + return [regex]::Replace($Code, $pattern, [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $replacement }, [System.Text.RegularExpressions.RegexOptions]::Singleline) + } + + return $Code + "`r`n`r`n$replacement`r`n" +} + +function Sync-CodeEditor { + $code = $script:State.Ui.CodeEditor.Text + $xamlName = 'Untitled.xaml' + if (-not [string]::IsNullOrWhiteSpace($script:State.CurrentXamlPath)) { + $xamlName = Split-Path -Leaf $script:State.CurrentXamlPath + } + $code = Sync-CodeBehindXamlFileName -Code $code -XamlFileName $xamlName + $code = Sync-CodeBehindControlReferences -Code $code + $script:State.Ui.CodeEditor.Text = $code +} diff --git a/XamlDesigner/Core/Documents.ps1 b/XamlDesigner/Core/Documents.ps1 new file mode 100644 index 0000000..7d1c955 --- /dev/null +++ b/XamlDesigner/Core/Documents.ps1 @@ -0,0 +1,117 @@ +function New-XamlDesignerDocument { + $script:State.XamlDocument = New-XmlDocumentFromText -Text (Get-BlankXamlText) + $script:State.CurrentXamlPath = $null + $script:State.CurrentCodePath = $null + $script:State.SelectedElementName = $null + $script:State.SelectedRuntimeElement = $null + $script:State.Ui.CodeEditor.Text = Get-BlankCodeText + Refresh-XamlTextFromDocument + [void](Refresh-Preview) + Update-DocumentCaption + Set-DesignerStatus -Message 'Created a new XAML + PowerShell document pair.' +} + +function Open-XamlDesignerDocument { + $dialog = [Microsoft.Win32.OpenFileDialog]::new() + $dialog.Filter = 'XAML files (*.xaml)|*.xaml|XML files (*.xml)|*.xml|All files (*.*)|*.*' + $dialog.Title = 'Open XAML file' + if ($dialog.ShowDialog() -ne $true) { + return + } + + try { + $text = Get-Content -LiteralPath $dialog.FileName -Raw + $document = New-XmlDocumentFromText -Text $text + $oldDocument = $script:State.XamlDocument + $script:State.XamlDocument = $document + if (-not (Refresh-Preview)) { + $script:State.XamlDocument = $oldDocument + throw 'The selected file is well-formed XML but could not be loaded as a WPF Window.' + } + + $script:State.CurrentXamlPath = $dialog.FileName + $script:State.CurrentCodePath = [System.IO.Path]::ChangeExtension($dialog.FileName, '.ps1') + Refresh-XamlTextFromDocument + + if (Test-Path -LiteralPath $script:State.CurrentCodePath) { + $script:State.Ui.CodeEditor.Text = Get-Content -LiteralPath $script:State.CurrentCodePath -Raw + } + else { + $script:State.Ui.CodeEditor.Text = Get-BlankCodeText + } + Sync-CodeEditor + Update-DocumentCaption + Set-DesignerStatus -Message "Opened $($dialog.FileName)" + } + catch { + [System.Windows.MessageBox]::Show( + $_.Exception.Message, + 'Open failed', + [System.Windows.MessageBoxButton]::OK, + [System.Windows.MessageBoxImage]::Error + ) | Out-Null + } +} + +function Apply-XamlEditorText { + try { + $candidate = New-XmlDocumentFromText -Text $script:State.Ui.XamlEditor.Text + } + catch [System.Xml.XmlException] { + $ex = $_.Exception + Set-DesignerStatus -Message "XML error at line $($ex.LineNumber), position $($ex.LinePosition): $($ex.Message)" + return $false + } + catch { + Set-DesignerStatus -Message ("XML error: " + $_.Exception.Message) + return $false + } + + $old = $script:State.XamlDocument + $script:State.XamlDocument = $candidate + if (-not (Refresh-Preview -KeepSelection)) { + $script:State.XamlDocument = $old + return $false + } + + Refresh-XamlTextFromDocument + Sync-CodeEditor + return $true +} + +function Save-XamlDesignerDocument { + param( + [switch]$SaveAs + ) + + if (-not (Apply-XamlEditorText)) { + [System.Windows.MessageBox]::Show( + 'The XAML contains an error. Fix the error before saving.', + 'Save blocked', + [System.Windows.MessageBoxButton]::OK, + [System.Windows.MessageBoxImage]::Warning + ) | Out-Null + return + } + + if ($SaveAs -or [string]::IsNullOrWhiteSpace($script:State.CurrentXamlPath)) { + $dialog = [Microsoft.Win32.SaveFileDialog]::new() + $dialog.Filter = 'XAML files (*.xaml)|*.xaml' + $dialog.DefaultExt = '.xaml' + $dialog.AddExtension = $true + $dialog.Title = 'Save XAML and PowerShell code-behind' + if ($dialog.ShowDialog() -ne $true) { + return + } + $script:State.CurrentXamlPath = $dialog.FileName + $script:State.CurrentCodePath = [System.IO.Path]::ChangeExtension($dialog.FileName, '.ps1') + } + + Sync-CodeEditor + $xamlText = ConvertTo-FormattedXml -Document $script:State.XamlDocument + [System.IO.File]::WriteAllText($script:State.CurrentXamlPath, $xamlText, [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText($script:State.CurrentCodePath, $script:State.Ui.CodeEditor.Text, [System.Text.UTF8Encoding]::new($false)) + + Update-DocumentCaption + Set-DesignerStatus -Message "Saved XAML and code-behind: $($script:State.CurrentXamlPath)" +} diff --git a/XamlDesigner/Core/Events.ps1 b/XamlDesigner/Core/Events.ps1 new file mode 100644 index 0000000..968086f --- /dev/null +++ b/XamlDesigner/Core/Events.ps1 @@ -0,0 +1,84 @@ +function Generate-EventHandlerForName { + param( + [Parameter(Mandatory)] + [string]$EventName + ) + + if ([string]::IsNullOrWhiteSpace($script:State.SelectedElementName)) { + return + } + + $name = $script:State.SelectedElementName + $availableEvents = @($script:State.SelectedRuntimeElement.GetType().GetEvents([System.Reflection.BindingFlags]'Public,Instance') | ForEach-Object Name) + if ($availableEvents -notcontains $EventName) { + Set-DesignerStatus -Message "Event '$EventName' is not available on $name." + return + } + + Sync-CodeEditor + $code = $script:State.Ui.CodeEditor.Text + + $pattern = [regex]::Escape("`$$name.Add_$EventName(") + if ([regex]::IsMatch($code, $pattern)) { + Set-DesignerStatus -Message "An $EventName handler for $name already exists." + $script:State.Ui.MainTabs.SelectedIndex = 2 + return + } + + $block = @" + +# $name.$EventName +`$$name.Add_$EventName({ + param(`$sender, `$e) + + # TODO: Add $EventName logic for $name. +}) +"@ + $script:State.Ui.CodeEditor.Text = $code.TrimEnd() + $block + "`r`n" + $script:State.Ui.MainTabs.SelectedIndex = 2 + Set-DesignerStatus -Message "Generated PowerShell event handler: $name.$EventName" +} + +function Generate-SelectedEventHandler { + $selectedEvent = $script:State.Ui.EventGrid.SelectedItem + if ($null -eq $selectedEvent) { + return + } + Generate-EventHandlerForName -EventName ([string]$selectedEvent.Name) +} + +function Get-DefaultDesignerEventName { + param( + [Parameter(Mandatory)] + [System.Windows.FrameworkElement]$Element + ) + + $eventNames = @($Element.GetType().GetEvents([System.Reflection.BindingFlags]'Public,Instance') | ForEach-Object Name) + foreach ($candidate in @('Click','Checked','SelectionChanged','TextChanged','ValueChanged','SelectedDateChanged','MouseDoubleClick','Loaded')) { + if ($eventNames -contains $candidate) { + return $candidate + } + } + return $null +} + +function Update-SelectedCanvasPosition { + param( + [Parameter(Mandatory)] + [double]$Left, + + [Parameter(Mandatory)] + [double]$Top + ) + + if ([string]::IsNullOrWhiteSpace($script:State.SelectedElementName)) { + return + } + $node = Get-XamlElementByName -Name $script:State.SelectedElementName + if ($null -eq $node) { + return + } + $node.SetAttribute('Canvas.Left', $Left.ToString([System.Globalization.CultureInfo]::InvariantCulture)) + $node.SetAttribute('Canvas.Top', $Top.ToString([System.Globalization.CultureInfo]::InvariantCulture)) + Refresh-XamlTextFromDocument +} diff --git a/XamlDesigner/Core/Preview.ps1 b/XamlDesigner/Core/Preview.ps1 new file mode 100644 index 0000000..1119c77 --- /dev/null +++ b/XamlDesigner/Core/Preview.ps1 @@ -0,0 +1,120 @@ +function Get-RuntimePreviewDocument { + $clone = [System.Xml.XmlDocument]$script:State.XamlDocument.CloneNode($true) + Remove-PowerShellUnsupportedXamlAttributes -Document $clone + return $clone +} + +function Find-VisualElementByName { + param( + [Parameter(Mandatory)] + [System.Windows.DependencyObject]$Root, + + [Parameter(Mandatory)] + [string]$Name + ) + + if ($Root -is [System.Windows.FrameworkElement] -and $Root.Name -eq $Name) { + return $Root + } + + $count = [System.Windows.Media.VisualTreeHelper]::GetChildrenCount($Root) + for ($i = 0; $i -lt $count; $i++) { + $child = [System.Windows.Media.VisualTreeHelper]::GetChild($Root, $i) + $match = Find-VisualElementByName -Root $child -Name $Name + if ($null -ne $match) { + return $match + } + } + return $null +} + +function Get-NamedFrameworkElementFromOriginalSource { + param( + [Parameter(Mandatory)] + [object]$OriginalSource + ) + + $current = $OriginalSource + while ($null -ne $current) { + if ($current -is [System.Windows.FrameworkElement] -and -not [string]::IsNullOrWhiteSpace($current.Name)) { + if ($null -ne (Get-XamlElementByName -Name $current.Name)) { + return $current + } + } + if ($current -isnot [System.Windows.DependencyObject]) { + break + } + $current = [System.Windows.Media.VisualTreeHelper]::GetParent($current) + } + return $null +} + +function Refresh-XamlTextFromDocument { + $script:State.Ui.XamlEditor.Text = ConvertTo-FormattedXml -Document $script:State.XamlDocument +} + +function Refresh-Preview { + param( + [switch]$KeepSelection + ) + + $selectionName = $script:State.SelectedElementName + try { + $runtimeDocument = Get-RuntimePreviewDocument + $reader = [System.Xml.XmlNodeReader]::new($runtimeDocument) + try { + $loadedRoot = [System.Windows.Markup.XamlReader]::Load($reader) + } + finally { + $reader.Close() + } + + if ($loadedRoot -isnot [System.Windows.Window]) { + throw 'The root XAML element must be a WPF Window for this designer.' + } + + $content = $loadedRoot.Content + $loadedRoot.Content = $null + + $host = $script:State.Ui.PreviewHost + $host.Children.Clear() + if ($null -ne $content) { + [void]$host.Children.Add($content) + } + + $width = $loadedRoot.Width + $height = $loadedRoot.Height + if ([double]::IsNaN($width) -or $width -lt 200) { $width = 800 } + if ([double]::IsNaN($height) -or $height -lt 150) { $height = 500 } + $script:State.Ui.PreviewBorder.Width = $width + $script:State.Ui.PreviewBorder.Height = $height + + $script:State.SelectedRuntimeElement = $null + if ($KeepSelection -and -not [string]::IsNullOrWhiteSpace($selectionName)) { + $runtime = Find-VisualElementByName -Root $host -Name $selectionName + if ($null -ne $runtime) { + $script:State.SelectedRuntimeElement = $runtime + } + else { + $script:State.SelectedElementName = $null + } + } + + Refresh-SelectionPanels + Set-DesignerStatus -Message 'XAML preview updated successfully.' + return $true + } + catch { + Set-DesignerStatus -Message ("XAML preview error: " + $_.Exception.Message) + return $false + } +} + +function Update-DocumentCaption { + $display = 'Untitled.xaml' + if (-not [string]::IsNullOrWhiteSpace($script:State.CurrentXamlPath)) { + $display = Split-Path -Leaf $script:State.CurrentXamlPath + } + $script:State.Ui.DocumentText.Text = $display + $script:State.Window.Title = "PowerShell XAML Designer - $display" +} diff --git a/XamlDesigner/Core/Properties.ps1 b/XamlDesigner/Core/Properties.ps1 new file mode 100644 index 0000000..3a3aba5 --- /dev/null +++ b/XamlDesigner/Core/Properties.ps1 @@ -0,0 +1,179 @@ +function Get-SimpleEditableProperties { + param( + [Parameter(Mandatory)] + [System.Windows.FrameworkElement]$Element + ) + + $preferredOrder = @( + 'Name', 'Width', 'Height', 'MinWidth', 'MinHeight', 'MaxWidth', 'MaxHeight', + 'Margin', 'HorizontalAlignment', 'VerticalAlignment', 'Visibility', 'IsEnabled', + 'Background', 'Foreground', 'BorderBrush', 'BorderThickness', + 'FontFamily', 'FontSize', 'FontWeight', 'FontStyle', + 'Content', 'Text', 'ToolTip', 'Opacity' + ) + + $items = [System.Collections.Generic.List[object]]::new() + if ($Element.Parent -is [System.Windows.Controls.Canvas]) { + $left = [System.Windows.Controls.Canvas]::GetLeft($Element) + $top = [System.Windows.Controls.Canvas]::GetTop($Element) + if ([double]::IsNaN($left)) { $left = 0 } + if ([double]::IsNaN($top)) { $top = 0 } + $items.Add([pscustomobject]@{ Name = 'Canvas.Left'; Value = [string]$left; TypeName = 'System.Double'; IsAttached = $true }) + $items.Add([pscustomobject]@{ Name = 'Canvas.Top'; Value = [string]$top; TypeName = 'System.Double'; IsAttached = $true }) + $items.Add([pscustomobject]@{ Name = 'Panel.ZIndex'; Value = [string][System.Windows.Controls.Panel]::GetZIndex($Element); TypeName = 'System.Int32'; IsAttached = $true }) + } + + $properties = @($Element.GetType().GetProperties([System.Reflection.BindingFlags]'Public,Instance') | Where-Object { + $_.CanRead -and $_.CanWrite -and $_.GetIndexParameters().Count -eq 0 + }) + + $propertyItems = foreach ($property in $properties) { + $type = $property.PropertyType + $converter = [System.ComponentModel.TypeDescriptor]::GetConverter($type) + $editable = $type.IsEnum -or $type -eq [string] -or $type.IsPrimitive -or $type -eq [decimal] -or $converter.CanConvertFrom([string]) + if (-not $editable) { + continue + } + + try { + $value = $property.GetValue($Element, $null) + if ($null -eq $value) { + $text = '' + } + elseif ($converter.CanConvertTo([string])) { + try { $text = $converter.ConvertToInvariantString($value) } catch { $text = [string]$value } + } + else { + $text = [string]$value + } + + [pscustomobject]@{ + Name = $property.Name + Value = $text + TypeName = $type.FullName + IsAttached = $false + } + } + catch { + # Some WPF properties throw when queried outside a complete visual tree. + } + } + + $ordered = @($propertyItems | Sort-Object @{ Expression = { + $index = [array]::IndexOf($preferredOrder, $_.Name) + if ($index -lt 0) { 1000 } else { $index } + } }, Name) + foreach ($item in $ordered) { + $items.Add($item) + } + return $items +} + +function Refresh-PropertyGrid { + $grid = $script:State.Ui.PropertyGrid + $grid.ItemsSource = $null + $script:State.Ui.PropertyNameText.Text = 'Select a property' + $script:State.Ui.PropertyValueText.Text = '' + + $element = $script:State.SelectedRuntimeElement + if ($element -isnot [System.Windows.FrameworkElement]) { + return + } + $grid.ItemsSource = Get-SimpleEditableProperties -Element $element +} + +function Refresh-EventGrid { + $grid = $script:State.Ui.EventGrid + $grid.ItemsSource = $null + $element = $script:State.SelectedRuntimeElement + if ($element -isnot [System.Windows.FrameworkElement]) { + return + } + + $events = @($element.GetType().GetEvents([System.Reflection.BindingFlags]'Public,Instance') | Sort-Object Name | ForEach-Object { + [pscustomobject]@{ + Name = $_.Name + DeclaringType = $_.DeclaringType.Name + } + }) + $grid.ItemsSource = $events +} + +function Refresh-SelectionPanels { + if ([string]::IsNullOrWhiteSpace($script:State.SelectedElementName) -or $null -eq $script:State.SelectedRuntimeElement) { + $script:State.Ui.SelectedControlText.Text = 'No control selected' + } + else { + $script:State.Ui.SelectedControlText.Text = "$($script:State.SelectedElementName) : $($script:State.SelectedRuntimeElement.GetType().Name)" + } + Refresh-PropertyGrid + Refresh-EventGrid +} + +function Select-DesignerElement { + param( + [Parameter(Mandatory)] + [System.Windows.FrameworkElement]$Element + ) + + $script:State.SelectedRuntimeElement = $Element + $script:State.SelectedElementName = $Element.Name + Refresh-SelectionPanels + Set-DesignerStatus -Message "Selected $($Element.Name)." +} + +function Apply-SelectedProperty { + $selected = $script:State.Ui.PropertyGrid.SelectedItem + if ($null -eq $selected -or [string]::IsNullOrWhiteSpace($script:State.SelectedElementName)) { + return + } + + $node = Get-XamlElementByName -Name $script:State.SelectedElementName + if ($null -eq $node) { + return + } + + $propertyName = [string]$selected.Name + $value = $script:State.Ui.PropertyValueText.Text + $oldXml = ConvertTo-FormattedXml -Document $script:State.XamlDocument + $oldName = $script:State.SelectedElementName + + try { + if ($propertyName -eq 'Name') { + if ([string]::IsNullOrWhiteSpace($value) -or $value -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') { + throw 'x:Name must start with a letter or underscore and contain only letters, digits, and underscores.' + } + $existing = Get-XamlElementByName -Name $value + if ($value -ne $oldName -and $null -ne $existing) { + throw "A control named '$value' already exists." + } + Set-ElementNameOnNode -Node $node -Name $value + $script:State.SelectedElementName = $value + } + elseif ([string]::IsNullOrWhiteSpace($value)) { + $node.RemoveAttribute($propertyName) + } + else { + $node.SetAttribute($propertyName, $value) + } + + if (-not (Refresh-Preview -KeepSelection)) { + throw 'The property value is not valid for this XAML element.' + } + Refresh-XamlTextFromDocument + Sync-CodeEditor + Set-DesignerStatus -Message "Applied $propertyName to $($script:State.SelectedElementName)." + } + catch { + $script:State.XamlDocument = New-XmlDocumentFromText -Text $oldXml + $script:State.SelectedElementName = $oldName + [void](Refresh-Preview -KeepSelection) + Refresh-XamlTextFromDocument + [System.Windows.MessageBox]::Show( + $_.Exception.Message, + 'Property update failed', + [System.Windows.MessageBoxButton]::OK, + [System.Windows.MessageBoxImage]::Warning + ) | Out-Null + } +} diff --git a/XamlDesigner/Core/State.ps1 b/XamlDesigner/Core/State.ps1 new file mode 100644 index 0000000..9fd6fff --- /dev/null +++ b/XamlDesigner/Core/State.ps1 @@ -0,0 +1,52 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:XamlNs = 'http://schemas.microsoft.com/winfx/2006/xaml' +$script:PresentationNs = 'http://schemas.microsoft.com/winfx/2006/xaml/presentation' +$script:McNs = 'http://schemas.openxmlformats.org/markup-compatibility/2006' +$script:DesignNs = 'http://schemas.microsoft.com/expression/blend/2008' + +$script:State = [ordered]@{ + Window = $null + BaseDirectory = $null + Ui = @{} + XamlDocument = $null + CurrentXamlPath = $null + CurrentCodePath = $null + SelectedElementName = $null + SelectedRuntimeElement = $null + ToolboxItems = @() + ToolboxDragOrigin = $null + DesignerDragActive = $false + DesignerDragOrigin = $null + DesignerDragStartLeft = 0.0 + DesignerDragStartTop = 0.0 + DesignerDragCanvas = $null +} + +function Set-DesignerStatus { + param( + [Parameter(Mandatory)] + [string]$Message + ) + + if ($null -ne $script:State.Ui.StatusText) { + $script:State.Ui.StatusText.Text = $Message + } +} + +function Get-UiControl { + param( + [Parameter(Mandatory)] + [System.Windows.Window]$Window, + + [Parameter(Mandatory)] + [string]$Name + ) + + $control = $Window.FindName($Name) + if ($null -eq $control) { + throw "Required UI control '$Name' was not found in XamlDesigner.xaml." + } + return $control +} diff --git a/XamlDesigner/Core/ToolboxCatalog.ps1 b/XamlDesigner/Core/ToolboxCatalog.ps1 new file mode 100644 index 0000000..597a968 --- /dev/null +++ b/XamlDesigner/Core/ToolboxCatalog.ps1 @@ -0,0 +1,112 @@ +function Get-WpfControlCatalog { + $frameworkElementType = [System.Windows.FrameworkElement] + $excluded = @( + 'System.Windows.Window', + 'System.Windows.Navigation.NavigationWindow', + 'System.Windows.Controls.Page' + ) + + $assemblies = @( + [System.Windows.Controls.Control].Assembly, + [System.Windows.Controls.Panel].Assembly, + [System.Windows.Shapes.Shape].Assembly, + [System.Windows.FrameworkElement].Assembly + ) | Select-Object -Unique + + $types = [System.Collections.Generic.List[Type]]::new() + foreach ($assembly in $assemblies) { + foreach ($type in $assembly.GetTypes()) { + if (-not $type.IsPublic -or $type.IsAbstract -or $type.IsGenericTypeDefinition) { + continue + } + if (-not $frameworkElementType.IsAssignableFrom($type)) { + continue + } + if ($excluded -contains $type.FullName) { + continue + } + if ($null -eq $type.GetConstructor([Type]::EmptyTypes)) { + continue + } + if (-not ($type.Namespace -like 'System.Windows.Controls*' -or $type.Namespace -eq 'System.Windows.Shapes')) { + continue + } + if (-not $types.Contains($type)) { + $types.Add($type) + } + } + } + + $catalog = foreach ($type in $types) { + $category = 'Other' + if ([System.Windows.Controls.Panel].IsAssignableFrom($type)) { + $category = 'Panels' + } + elseif ([System.Windows.Shapes.Shape].IsAssignableFrom($type)) { + $category = 'Shapes' + } + elseif ([System.Windows.Controls.Decorator].IsAssignableFrom($type)) { + $category = 'Decorators' + } + elseif ([System.Windows.Controls.Control].IsAssignableFrom($type)) { + $category = 'Controls' + } + + [pscustomobject]@{ + DisplayName = $type.Name + FullName = $type.FullName + Category = $category + Type = $type + } + } + + return @($catalog | Sort-Object DisplayName, FullName) +} + +function Refresh-ToolboxCatalog { + $script:State.ToolboxItems = @(Get-WpfControlCatalog) + Apply-ToolboxFilter + Set-DesignerStatus -Message "Toolbox loaded $($script:State.ToolboxItems.Count) WPF element types discovered at runtime." +} + +function Apply-ToolboxFilter { + $search = $script:State.Ui.ToolboxSearch.Text + $categoryItem = $script:State.Ui.ToolboxCategory.SelectedItem + $category = 'All' + if ($null -ne $categoryItem -and $null -ne $categoryItem.Content) { + $category = [string]$categoryItem.Content + } + + $filtered = $script:State.ToolboxItems + if (-not [string]::IsNullOrWhiteSpace($search)) { + $filtered = @($filtered | Where-Object { + $_.DisplayName -like "*$search*" -or $_.FullName -like "*$search*" + }) + } + if ($category -ne 'All') { + $filtered = @($filtered | Where-Object Category -eq $category) + } + $script:State.Ui.ToolboxList.ItemsSource = $filtered +} + +function New-UniqueControlName { + param( + [Parameter(Mandatory)] + [string]$BaseName + ) + + $safeBase = $BaseName -replace '[^A-Za-z0-9_]', '' + if ([string]::IsNullOrWhiteSpace($safeBase)) { + $safeBase = 'Control' + } + if ($safeBase[0] -match '[0-9]') { + $safeBase = '_' + $safeBase + } + + $index = 1 + do { + $candidate = "$safeBase$index" + $index++ + } while ($null -ne (Get-XamlElementByName -Name $candidate)) + return $candidate +} diff --git a/XamlDesigner/Core/ToolboxEditing.ps1 b/XamlDesigner/Core/ToolboxEditing.ps1 new file mode 100644 index 0000000..03bcf8e --- /dev/null +++ b/XamlDesigner/Core/ToolboxEditing.ps1 @@ -0,0 +1,172 @@ +function Set-DefaultNewElementAttributes { + param( + [Parameter(Mandatory)] + [System.Xml.XmlElement]$Node, + + [Parameter(Mandatory)] + [Type]$Type, + + [double]$Left = 20, + + [double]$Top = 20, + + [switch]$CanvasParent + ) + + $name = New-UniqueControlName -BaseName $Type.Name + Set-ElementNameOnNode -Node $Node -Name $name + + if ($CanvasParent) { + $Node.SetAttribute('Canvas.Left', ([math]::Round($Left)).ToString([System.Globalization.CultureInfo]::InvariantCulture)) + $Node.SetAttribute('Canvas.Top', ([math]::Round($Top)).ToString([System.Globalization.CultureInfo]::InvariantCulture)) + } + + $defaults = switch ($Type.Name) { + 'Button' { @{ Content = 'Button'; Width = '110'; Height = '32' }; break } + 'Label' { @{ Content = 'Label'; Width = '120'; Height = '30' }; break } + 'TextBlock' { @{ Text = 'TextBlock'; Width = '140'; Height = '28' }; break } + 'TextBox' { @{ Text = ''; Width = '180'; Height = '30' }; break } + 'PasswordBox' { @{ Width = '180'; Height = '30' }; break } + 'CheckBox' { @{ Content = 'CheckBox'; Width = '130'; Height = '28' }; break } + 'RadioButton' { @{ Content = 'RadioButton'; Width = '140'; Height = '28' }; break } + 'ComboBox' { @{ Width = '160'; Height = '30' }; break } + 'ListBox' { @{ Width = '180'; Height = '120' }; break } + 'ListView' { @{ Width = '220'; Height = '140' }; break } + 'TreeView' { @{ Width = '220'; Height = '180' }; break } + 'Slider' { @{ Width = '180'; Height = '30'; Minimum = '0'; Maximum = '100'; Value = '50' }; break } + 'ProgressBar' { @{ Width = '180'; Height = '24'; Minimum = '0'; Maximum = '100'; Value = '50' }; break } + 'Image' { @{ Width = '160'; Height = '120'; Stretch = 'Uniform' }; break } + 'Border' { @{ Width = '180'; Height = '120'; BorderBrush = 'Gray'; BorderThickness = '1' }; break } + 'Canvas' { @{ Width = '240'; Height = '160'; Background = 'Transparent' }; break } + 'Grid' { @{ Width = '240'; Height = '160'; Background = 'Transparent' }; break } + 'StackPanel' { @{ Width = '220'; Height = '160' }; break } + 'WrapPanel' { @{ Width = '220'; Height = '160' }; break } + 'DockPanel' { @{ Width = '220'; Height = '160' }; break } + 'Rectangle' { @{ Width = '120'; Height = '80'; Stroke = 'Gray'; Fill = 'Transparent' }; break } + 'Ellipse' { @{ Width = '120'; Height = '80'; Stroke = 'Gray'; Fill = 'Transparent' }; break } + 'Line' { @{ X1 = '0'; Y1 = '0'; X2 = '120'; Y2 = '60'; Stroke = 'Black'; StrokeThickness = '1' }; break } + default { + if ([System.Windows.Controls.Control].IsAssignableFrom($Type)) { + @{ Width = '120'; Height = '32' } + } + elseif ([System.Windows.Shapes.Shape].IsAssignableFrom($Type)) { + @{ Width = '120'; Height = '80'; Stroke = 'Gray' } + } + else { + @{} + } + } + } + + foreach ($key in $defaults.Keys) { + $Node.SetAttribute($key, [string]$defaults[$key]) + } + return $name +} + +function Get-PrimaryDesignContainerNode { + $designCanvas = Get-XamlElementByName -Name 'DesignCanvas' + if ($null -ne $designCanvas) { + return $designCanvas + } + + $root = $script:State.XamlDocument.DocumentElement + if ($null -eq $root) { + return $null + } + + foreach ($child in $root.ChildNodes) { + if ($child -isnot [System.Xml.XmlElement]) { + continue + } + if ($child.LocalName -in @('Canvas', 'Grid', 'StackPanel', 'WrapPanel', 'DockPanel', 'UniformGrid')) { + return $child + } + } + return $null +} + +function Add-ToolboxElementToDocument { + param( + [Parameter(Mandatory)] + [Type]$Type, + + [double]$Left = 20, + + [double]$Top = 20 + ) + + $container = Get-PrimaryDesignContainerNode + if ($null -eq $container) { + Set-DesignerStatus -Message 'No supported root layout container was found. Add a Canvas/Grid/StackPanel first in XAML source.' + return + } + + $node = $script:State.XamlDocument.CreateElement($Type.Name, $script:PresentationNs) + $isCanvas = $container.LocalName -eq 'Canvas' + $name = Set-DefaultNewElementAttributes -Node $node -Type $Type -Left $Left -Top $Top -CanvasParent:$isCanvas + [void]$container.AppendChild($node) + + $script:State.SelectedElementName = $name + Refresh-XamlTextFromDocument + Sync-CodeEditor + [void](Refresh-Preview -KeepSelection) + Set-DesignerStatus -Message "Added $($Type.Name) as $name." +} + +function Copy-XamlElementNode { + param( + [Parameter(Mandatory)] + [System.Xml.XmlElement]$Source + ) + + $copy = [System.Xml.XmlElement]$Source.CloneNode($true) + $newName = New-UniqueControlName -BaseName $Source.LocalName + Set-ElementNameOnNode -Node $copy -Name $newName + + foreach ($attributeName in @('Canvas.Left', 'Canvas.Top')) { + if ($copy.HasAttribute($attributeName)) { + $value = 0.0 + if ([double]::TryParse($copy.GetAttribute($attributeName), [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$value)) { + $copy.SetAttribute($attributeName, ($value + 20).ToString([System.Globalization.CultureInfo]::InvariantCulture)) + } + } + } + + [void]$Source.ParentNode.AppendChild($copy) + return $newName +} + +function Delete-SelectedElement { + if ([string]::IsNullOrWhiteSpace($script:State.SelectedElementName)) { + return + } + $node = Get-XamlElementByName -Name $script:State.SelectedElementName + if ($null -eq $node -or $node -eq $script:State.XamlDocument.DocumentElement) { + return + } + $deletedName = $script:State.SelectedElementName + [void]$node.ParentNode.RemoveChild($node) + $script:State.SelectedElementName = $null + $script:State.SelectedRuntimeElement = $null + Refresh-XamlTextFromDocument + Sync-CodeEditor + [void](Refresh-Preview) + Set-DesignerStatus -Message "Deleted $deletedName. Existing user-written event code is preserved for manual cleanup." +} + +function Duplicate-SelectedElement { + if ([string]::IsNullOrWhiteSpace($script:State.SelectedElementName)) { + return + } + $node = Get-XamlElementByName -Name $script:State.SelectedElementName + if ($null -eq $node -or $node -eq $script:State.XamlDocument.DocumentElement) { + return + } + $newName = Copy-XamlElementNode -Source $node + $script:State.SelectedElementName = $newName + Refresh-XamlTextFromDocument + Sync-CodeEditor + [void](Refresh-Preview -KeepSelection) + Set-DesignerStatus -Message "Duplicated control as $newName." +} diff --git a/XamlDesigner/Core/Ui.ps1 b/XamlDesigner/Core/Ui.ps1 new file mode 100644 index 0000000..1aca988 --- /dev/null +++ b/XamlDesigner/Core/Ui.ps1 @@ -0,0 +1,228 @@ +function Initialize-UiReferences { + param( + [Parameter(Mandatory)] + [System.Windows.Window]$Window + ) + + $names = @( + 'MenuNew','MenuOpen','MenuSave','MenuSaveAs','MenuExit','MenuDelete','MenuDuplicate','MenuValidate','MenuRefreshToolbox','MenuAbout', + 'StatusText','DocumentText','ToolboxSearch','ToolboxCategory','ToolboxList','MainTabs','PreviewBorder','PreviewHost','CheckSnapToGrid', + 'ButtonDelete','ButtonDuplicate','ButtonApplyXaml','ButtonFormatXaml','XamlEditor','CodeEditor','SelectedControlText','PropertyGrid', + 'PropertyNameText','PropertyValueText','ButtonApplyProperty','EventGrid' + ) + + foreach ($name in $names) { + $script:State.Ui[$name] = Get-UiControl -Window $Window -Name $name + } +} + +function Register-UiEvents { + $ui = $script:State.Ui + + $ui.MenuNew.Add_Click({ New-XamlDesignerDocument }) + $ui.MenuOpen.Add_Click({ Open-XamlDesignerDocument }) + $ui.MenuSave.Add_Click({ Save-XamlDesignerDocument }) + $ui.MenuSaveAs.Add_Click({ Save-XamlDesignerDocument -SaveAs }) + $ui.MenuExit.Add_Click({ $script:State.Window.Close() }) + $ui.MenuDelete.Add_Click({ Delete-SelectedElement }) + $ui.MenuDuplicate.Add_Click({ Duplicate-SelectedElement }) + $ui.MenuValidate.Add_Click({ [void](Apply-XamlEditorText) }) + $ui.MenuRefreshToolbox.Add_Click({ Refresh-ToolboxCatalog }) + $ui.MenuAbout.Add_Click({ + [System.Windows.MessageBox]::Show( + "PowerShell XAML Designer`r`n`r`nA dependency-free WPF/XAML visual editor implemented in PowerShell. XAML defines the UI; a paired .ps1 file contains control references and event logic.", + 'About PowerShell XAML Designer', + [System.Windows.MessageBoxButton]::OK, + [System.Windows.MessageBoxImage]::Information + ) | Out-Null + }) + + $ui.ButtonDelete.Add_Click({ Delete-SelectedElement }) + $ui.ButtonDuplicate.Add_Click({ Duplicate-SelectedElement }) + $ui.ButtonApplyXaml.Add_Click({ [void](Apply-XamlEditorText) }) + $ui.ButtonFormatXaml.Add_Click({ + try { + $document = New-XmlDocumentFromText -Text $ui.XamlEditor.Text + $ui.XamlEditor.Text = ConvertTo-FormattedXml -Document $document + Set-DesignerStatus -Message 'XML formatted.' + } + catch [System.Xml.XmlException] { + Set-DesignerStatus -Message "XML format error at line $($_.Exception.LineNumber), position $($_.Exception.LinePosition): $($_.Exception.Message)" + } + }) + + $ui.ToolboxSearch.Add_TextChanged({ Apply-ToolboxFilter }) + $ui.ToolboxCategory.Add_SelectionChanged({ Apply-ToolboxFilter }) + + $ui.ToolboxList.Add_PreviewMouseLeftButtonDown({ + param($sender, $e) + $script:State.ToolboxDragOrigin = $e.GetPosition($sender) + }) + + $ui.ToolboxList.Add_PreviewMouseMove({ + param($sender, $e) + if ($e.LeftButton -ne [System.Windows.Input.MouseButtonState]::Pressed) { + return + } + if ($null -eq $sender.SelectedItem -or $null -eq $script:State.ToolboxDragOrigin) { + return + } + $position = $e.GetPosition($sender) + $deltaX = [math]::Abs($position.X - $script:State.ToolboxDragOrigin.X) + $deltaY = [math]::Abs($position.Y - $script:State.ToolboxDragOrigin.Y) + if ($deltaX -lt [System.Windows.SystemParameters]::MinimumHorizontalDragDistance -and + $deltaY -lt [System.Windows.SystemParameters]::MinimumVerticalDragDistance) { + return + } + + $data = [System.Windows.DataObject]::new() + $data.SetData('WpfTypeFullName', $sender.SelectedItem.FullName) + [void][System.Windows.DragDrop]::DoDragDrop($sender, $data, [System.Windows.DragDropEffects]::Copy) + }) + + $ui.PreviewBorder.Add_DragOver({ + param($sender, $e) + if ($e.Data.GetDataPresent('WpfTypeFullName')) { + $e.Effects = [System.Windows.DragDropEffects]::Copy + $e.Handled = $true + } + }) + + $ui.PreviewBorder.Add_Drop({ + param($sender, $e) + if (-not $e.Data.GetDataPresent('WpfTypeFullName')) { + return + } + $fullName = [string]$e.Data.GetData('WpfTypeFullName') + $item = $script:State.ToolboxItems | Where-Object FullName -eq $fullName | Select-Object -First 1 + if ($null -eq $item) { + return + } + + $canvas = Find-VisualElementByName -Root $ui.PreviewHost -Name 'DesignCanvas' + if ($canvas -is [System.Windows.Controls.Canvas]) { + $point = $e.GetPosition($canvas) + $left = $point.X + $top = $point.Y + } + else { + $left = 20 + $top = 20 + } + if ($ui.CheckSnapToGrid.IsChecked -eq $true) { + $left = [math]::Round($left / 10) * 10 + $top = [math]::Round($top / 10) * 10 + } + Add-ToolboxElementToDocument -Type $item.Type -Left $left -Top $top + $e.Handled = $true + }) + + $ui.PreviewHost.Add_PreviewMouseLeftButtonDown({ + param($sender, $e) + $element = Get-NamedFrameworkElementFromOriginalSource -OriginalSource $e.OriginalSource + if ($null -eq $element) { + return + } + Select-DesignerElement -Element $element + + if ($e.ClickCount -ge 2) { + $defaultEvent = Get-DefaultDesignerEventName -Element $element + if (-not [string]::IsNullOrWhiteSpace($defaultEvent)) { + Generate-EventHandlerForName -EventName $defaultEvent + } + $e.Handled = $true + return + } + + if ($element.Parent -is [System.Windows.Controls.Canvas]) { + $script:State.DesignerDragActive = $true + $script:State.DesignerDragCanvas = $element.Parent + $script:State.DesignerDragOrigin = $e.GetPosition($element.Parent) + $left = [System.Windows.Controls.Canvas]::GetLeft($element) + $top = [System.Windows.Controls.Canvas]::GetTop($element) + if ([double]::IsNaN($left)) { $left = 0 } + if ([double]::IsNaN($top)) { $top = 0 } + $script:State.DesignerDragStartLeft = $left + $script:State.DesignerDragStartTop = $top + [void]$element.CaptureMouse() + } + $e.Handled = $true + }) + + $ui.PreviewHost.Add_PreviewMouseMove({ + param($sender, $e) + if (-not $script:State.DesignerDragActive -or $e.LeftButton -ne [System.Windows.Input.MouseButtonState]::Pressed) { + return + } + $element = $script:State.SelectedRuntimeElement + $canvas = $script:State.DesignerDragCanvas + if ($element -isnot [System.Windows.FrameworkElement] -or $canvas -isnot [System.Windows.Controls.Canvas]) { + return + } + + $point = $e.GetPosition($canvas) + $left = $script:State.DesignerDragStartLeft + ($point.X - $script:State.DesignerDragOrigin.X) + $top = $script:State.DesignerDragStartTop + ($point.Y - $script:State.DesignerDragOrigin.Y) + $left = [math]::Max(0, $left) + $top = [math]::Max(0, $top) + if ($ui.CheckSnapToGrid.IsChecked -eq $true) { + $left = [math]::Round($left / 10) * 10 + $top = [math]::Round($top / 10) * 10 + } + [System.Windows.Controls.Canvas]::SetLeft($element, $left) + [System.Windows.Controls.Canvas]::SetTop($element, $top) + $e.Handled = $true + }) + + $ui.PreviewHost.Add_PreviewMouseLeftButtonUp({ + param($sender, $e) + if (-not $script:State.DesignerDragActive) { + return + } + $element = $script:State.SelectedRuntimeElement + if ($element -is [System.Windows.FrameworkElement]) { + $left = [System.Windows.Controls.Canvas]::GetLeft($element) + $top = [System.Windows.Controls.Canvas]::GetTop($element) + if (-not [double]::IsNaN($left) -and -not [double]::IsNaN($top)) { + Update-SelectedCanvasPosition -Left $left -Top $top + Refresh-PropertyGrid + Set-DesignerStatus -Message "Moved $($script:State.SelectedElementName) to $left, $top." + } + $element.ReleaseMouseCapture() + } + $script:State.DesignerDragActive = $false + $script:State.DesignerDragCanvas = $null + $e.Handled = $true + }) + + $ui.PropertyGrid.Add_SelectionChanged({ + $item = $ui.PropertyGrid.SelectedItem + if ($null -eq $item) { + return + } + $ui.PropertyNameText.Text = "$($item.Name) [$($item.TypeName)]" + $ui.PropertyValueText.Text = [string]$item.Value + }) + $ui.ButtonApplyProperty.Add_Click({ Apply-SelectedProperty }) + $ui.PropertyValueText.Add_KeyDown({ + param($sender, $e) + if ($e.Key -eq [System.Windows.Input.Key]::Enter) { + Apply-SelectedProperty + $e.Handled = $true + } + }) + + $ui.EventGrid.Add_MouseDoubleClick({ Generate-SelectedEventHandler }) + + $script:State.Window.Add_KeyDown({ + param($sender, $e) + $ctrl = ([System.Windows.Input.Keyboard]::Modifiers -band [System.Windows.Input.ModifierKeys]::Control) -ne 0 + if ($ctrl -and $e.Key -eq [System.Windows.Input.Key]::N) { New-XamlDesignerDocument; $e.Handled = $true; return } + if ($ctrl -and $e.Key -eq [System.Windows.Input.Key]::O) { Open-XamlDesignerDocument; $e.Handled = $true; return } + if ($ctrl -and $e.Key -eq [System.Windows.Input.Key]::S) { Save-XamlDesignerDocument; $e.Handled = $true; return } + if ($ctrl -and $e.Key -eq [System.Windows.Input.Key]::D) { Duplicate-SelectedElement; $e.Handled = $true; return } + if ($e.Key -eq [System.Windows.Input.Key]::Delete) { Delete-SelectedElement; $e.Handled = $true; return } + if ($e.Key -eq [System.Windows.Input.Key]::F5) { [void](Apply-XamlEditorText); $e.Handled = $true; return } + }) +} + diff --git a/XamlDesigner/Core/Xml.ps1 b/XamlDesigner/Core/Xml.ps1 new file mode 100644 index 0000000..8db0b67 --- /dev/null +++ b/XamlDesigner/Core/Xml.ps1 @@ -0,0 +1,211 @@ +function ConvertTo-FormattedXml { + param( + [Parameter(Mandatory)] + [System.Xml.XmlDocument]$Document + ) + + $settings = [System.Xml.XmlWriterSettings]::new() + $settings.Indent = $true + $settings.IndentChars = ' ' + $settings.NewLineChars = "`r`n" + $settings.NewLineHandling = [System.Xml.NewLineHandling]::Replace + $settings.OmitXmlDeclaration = $true + + $builder = [System.Text.StringBuilder]::new() + $writer = [System.Xml.XmlWriter]::Create($builder, $settings) + try { + $Document.Save($writer) + } + finally { + $writer.Close() + } + + return $builder.ToString().Trim() + "`r`n" +} + +function New-XmlDocumentFromText { + param( + [Parameter(Mandatory)] + [string]$Text + ) + + $document = [System.Xml.XmlDocument]::new() + $document.PreserveWhitespace = $false + $document.LoadXml($Text) + return $document +} + +function Get-BlankXamlText { + $path = Join-Path $script:State.BaseDirectory 'Templates\BlankWindow.xaml' + return Get-Content -LiteralPath $path -Raw +} + +function Get-BlankCodeText { + $path = Join-Path $script:State.BaseDirectory 'Templates\BlankWindow.ps1' + return Get-Content -LiteralPath $path -Raw +} + +function Get-ElementNameFromNode { + param( + [Parameter(Mandatory)] + [System.Xml.XmlElement]$Node + ) + + $name = $Node.GetAttribute('Name', $script:XamlNs) + if ([string]::IsNullOrWhiteSpace($name)) { + $name = $Node.GetAttribute('Name') + } + return $name +} + +function Set-ElementNameOnNode { + param( + [Parameter(Mandatory)] + [System.Xml.XmlElement]$Node, + + [Parameter(Mandatory)] + [string]$Name + ) + + $Node.RemoveAttribute('Name') + $attribute = $Node.OwnerDocument.CreateAttribute('x', 'Name', $script:XamlNs) + $attribute.Value = $Name + [void]$Node.Attributes.SetNamedItem($attribute) +} + +function Get-XamlElementByName { + param( + [Parameter(Mandatory)] + [string]$Name + ) + + if ([string]::IsNullOrWhiteSpace($Name) -or $null -eq $script:State.XamlDocument) { + return $null + } + + foreach ($node in $script:State.XamlDocument.SelectNodes('//*')) { + if ($node -isnot [System.Xml.XmlElement]) { + continue + } + if ((Get-ElementNameFromNode -Node $node) -eq $Name) { + return $node + } + } + return $null +} + +function Get-AllNamedXamlElements { + $result = [System.Collections.Generic.List[object]]::new() + if ($null -eq $script:State.XamlDocument) { + return $result + } + + $root = $script:State.XamlDocument.DocumentElement + foreach ($node in $script:State.XamlDocument.SelectNodes('//*')) { + if ($node -isnot [System.Xml.XmlElement]) { + continue + } + if ($node -eq $root) { + continue + } + $name = Get-ElementNameFromNode -Node $node + if (-not [string]::IsNullOrWhiteSpace($name)) { + $result.Add([pscustomobject]@{ + Name = $name + ElementName = $node.LocalName + Node = $node + }) + } + } + return $result +} + +function Get-WpfTypeByElementName { + param( + [Parameter(Mandatory)] + [string]$ElementName + ) + + $known = @{ + Window = [System.Windows.Window] + Border = [System.Windows.Controls.Border] + Image = [System.Windows.Controls.Image] + TextBlock = [System.Windows.Controls.TextBlock] + } + + if ($known.ContainsKey($ElementName)) { + return $known[$ElementName] + } + + foreach ($item in $script:State.ToolboxItems) { + if ($item.DisplayName -eq $ElementName) { + return $item.Type + } + } + + foreach ($namespace in @('System.Windows.Controls', 'System.Windows.Shapes', 'System.Windows.Documents')) { + $type = [Type]::GetType("$namespace.$ElementName, PresentationFramework", $false) + if ($null -ne $type) { + return $type + } + } + + return $null +} + +function Remove-PowerShellUnsupportedXamlAttributes { + param( + [Parameter(Mandatory)] + [System.Xml.XmlDocument]$Document + ) + + $root = $Document.DocumentElement + if ($null -eq $root) { + return + } + + # x:Class and mc:Ignorable are design/build-time concepts. PowerShell's + # standalone XamlReader does not require them. + $root.RemoveAttribute('Class', $script:XamlNs) + $root.RemoveAttribute('Ignorable', $script:McNs) + + foreach ($node in $Document.SelectNodes('//*')) { + if ($node -isnot [System.Xml.XmlElement]) { + continue + } + + # Remove d:* design-time attributes only from the preview clone. + $attributesToRemove = [System.Collections.Generic.List[System.Xml.XmlAttribute]]::new() + foreach ($attribute in @($node.Attributes)) { + if ($attribute.NamespaceURI -eq $script:DesignNs) { + $attributesToRemove.Add($attribute) + } + } + foreach ($attribute in $attributesToRemove) { + [void]$node.Attributes.Remove($attribute) + } + + # Visual Studio can emit Click="Handler" style attributes. Those + # handlers cannot be resolved by a standalone PowerShell XamlReader, + # because PowerShell wires events from the .ps1 code-behind instead. + $type = Get-WpfTypeByElementName -ElementName $node.LocalName + if ($null -eq $type) { + continue + } + + $eventNames = @($type.GetEvents([System.Reflection.BindingFlags]'Public,Instance') | ForEach-Object Name) + if ($eventNames.Count -eq 0) { + continue + } + + $eventAttributes = [System.Collections.Generic.List[System.Xml.XmlAttribute]]::new() + foreach ($attribute in @($node.Attributes)) { + if ([string]::IsNullOrWhiteSpace($attribute.NamespaceURI) -and $eventNames -contains $attribute.LocalName) { + $eventAttributes.Add($attribute) + } + } + foreach ($attribute in $eventAttributes) { + [void]$node.Attributes.Remove($attribute) + } + } +} diff --git a/XamlDesigner/README.md b/XamlDesigner/README.md new file mode 100644 index 0000000..ca93bcd --- /dev/null +++ b/XamlDesigner/README.md @@ -0,0 +1,207 @@ +# PowerShell XAML Designer + +A standalone WPF/XAML visual editor implemented with PowerShell and Windows WPF. + +## Why this exists + +PowerShell can build useful Windows desktop tools with WPF and XAML, but the normal graphical WPF authoring experience is closely associated with Visual Studio / Blend. In managed corporate PCs, labs, lightweight admin environments, or personal setups, those tools may be unavailable or restricted. + +This project therefore uses only components normally available to PowerShell on Windows: + +- PowerShell +- `WindowsBase` +- `PresentationCore` +- `PresentationFramework` +- `System.Xml` +- WPF reflection APIs + +There is no required NuGet package, PowerShell Gallery module, web service, or external executable. + +## File model + +A project is normally one pair of files: + +```text +Example.xaml +Example.ps1 +``` + +### XAML file + +Owns UI structure and UI properties. + +```xml + + +