Files
winutil/functions/public/Invoke-WPFUIElements.ps1
T
makhlwfandGitHub afc3e1eec2 Fix screen reader button accessibility and category navigation (#4944)
* Fix UI automation peer wrapping and category focusability

* Add accessibility name to SettingsButton

* Avoid nesting ScrollViewer in generated panels with outer viewers

Inspect targetGrid and its parent hierarchy in Invoke-WPFUIElements to avoid adding an inner ScrollViewer when an outer ScrollViewer already exists.
2026-08-09 18:02:53 -05:00

559 lines
29 KiB
PowerShell

function Invoke-WPFUIElements {
<#
.SYNOPSIS
Adds UI elements to a specified Grid in the WinUtil GUI based on a JSON configuration.
.PARAMETER configVariable
The variable/link containing the JSON configuration.
.PARAMETER targetGridName
The name of the grid to which the UI elements should be added.
.PARAMETER columncount
The number of columns to be used in the Grid. If not provided, a default value is used based on the panel.
.EXAMPLE
Invoke-WPFUIElements -configVariable $sync.configs.applications -targetGridName "install" -columncount 5
.NOTES
Future me/contributor: If possible, please wrap this into a runspace to make it load all panels at the same time.
#>
param(
[Parameter(Mandatory, Position = 0)]
[PSCustomObject]$configVariable,
[Parameter(Mandatory, Position = 1)]
[string]$targetGridName,
[Parameter(Mandatory, Position = 2)]
[int]$columncount
)
$window = $sync.form
$borderstyle = $window.FindResource("BorderStyle")
$HoverTextBlockStyle = $window.FindResource("HoverTextBlockStyle")
$ColorfulToggleSwitchStyle = $window.FindResource("ColorfulToggleSwitchStyle")
$ToggleButtonStyle = $window.FindResource("ToggleButtonStyle")
if (!$borderstyle -or !$HoverTextBlockStyle -or !$ColorfulToggleSwitchStyle) {
throw "Failed to retrieve Styles using 'FindResource' from main window element."
}
$targetGrid = $window.FindName($targetGridName)
if (!$targetGrid) {
throw "Failed to retrieve Target Grid by name, provided name: $targetGrid"
}
# Clear existing ColumnDefinitions and Children
$targetGrid.ColumnDefinitions.Clear() | Out-Null
$targetGrid.Children.Clear() | Out-Null
# Add ColumnDefinitions to the target Grid
for ($i = 0; $i -lt $columncount; $i++) {
$colDef = New-Object Windows.Controls.ColumnDefinition
$colDef.Width = New-Object System.Windows.GridLength([double]1, [System.Windows.GridUnitType]::Star)
$targetGrid.ColumnDefinitions.Add($colDef) | Out-Null
}
# Convert PSCustomObject to Hashtable
$configHashtable = @{}
$configVariable.PSObject.Properties.Name | ForEach-Object {
$configHashtable[$_] = $configVariable.$_
}
$radioButtonGroups = @{}
$organizedData = @{}
# Iterate through JSON data and organize by panel and category
foreach ($entry in $configHashtable.Keys) {
$entryInfo = $configHashtable[$entry]
# Create an object for the application
$entryObject = [PSCustomObject]@{
Name = $entry
Category = $entryInfo.Category
Content = $entryInfo.Content
Panel = if ($entryInfo.Panel) { $entryInfo.Panel } else { "0" }
Link = $entryInfo.link
Description = $entryInfo.description
Type = $entryInfo.type
ComboItems = $entryInfo.ComboItems
ComboDescriptions = $entryInfo.ComboDescriptions
Registry = $entryInfo.registry
Checked = $entryInfo.Checked
ButtonWidth = $entryInfo.ButtonWidth
GroupName = $entryInfo.GroupName # Added for RadioButton groupings
}
if (-not $organizedData.ContainsKey($entryObject.Panel)) {
$organizedData[$entryObject.Panel] = @{}
}
if (-not $organizedData[$entryObject.Panel].ContainsKey($entryObject.Category)) {
$organizedData[$entryObject.Panel][$entryObject.Category] = @()
}
# Store application data in an array under the category
$organizedData[$entryObject.Panel][$entryObject.Category] += $entryObject
}
# Initialize panel count
$panelcount = 0
# Iterate through 'organizedData' by panel, category, and application
$count = 0
foreach ($panelKey in ($organizedData.Keys | Sort-Object)) {
# Create a Border for each column
$border = New-Object Windows.Controls.Border
$border.VerticalAlignment = "Stretch"
[System.Windows.Controls.Grid]::SetColumn($border, $panelcount)
$border.style = $borderstyle
$targetGrid.Children.Add($border) | Out-Null
# Use a DockPanel to contain the content
$dockPanelContainer = New-Object Windows.Controls.DockPanel
$border.Child = $dockPanelContainer
# Create a StackPanel for application content controls
$stackPanelContainer = New-Object Windows.Controls.StackPanel
$stackPanelContainer.HorizontalAlignment = 'Stretch'
$stackPanelContainer.VerticalAlignment = 'Stretch'
# Check if the target grid (or any ancestor) is already inside a ScrollViewer
$hasOuterScrollViewer = $false
$currentElement = $targetGrid
while ($null -ne $currentElement) {
if ($currentElement -is [System.Windows.Controls.ScrollViewer] -or $currentElement.GetType().Name -eq "ScrollViewer") {
$hasOuterScrollViewer = $true
break
}
$currentElement = $currentElement.Parent
}
if ($hasOuterScrollViewer) {
# Add StackPanel directly to DockPanel without nesting a ScrollViewer
[Windows.Controls.DockPanel]::SetDock($stackPanelContainer, [Windows.Controls.Dock]::Bottom)
$dockPanelContainer.Children.Add($stackPanelContainer) | Out-Null
}
else {
# Create a ScrollViewer for targets that do not already have an outer ScrollViewer
$scrollViewer = New-Object Windows.Controls.ScrollViewer
$scrollViewer.VerticalScrollBarVisibility = "Auto"
$scrollViewer.HorizontalScrollBarVisibility = "Disabled"
$scrollViewer.HorizontalAlignment = 'Stretch'
$scrollViewer.VerticalAlignment = 'Stretch'
$scrollViewer.Content = $stackPanelContainer
[Windows.Controls.DockPanel]::SetDock($scrollViewer, [Windows.Controls.Dock]::Bottom)
$dockPanelContainer.Children.Add($scrollViewer) | Out-Null
}
$panelcount++
# Now proceed with adding category labels and entries to $stackPanelContainer
foreach ($category in ($organizedData[$panelKey].Keys | Sort-Object)) {
$count++
$label = New-Object Windows.Controls.Label
$categoryCleanName = $category -replace ".*__", ""
$label.Content = $categoryCleanName
$label.Focusable = $true
$label.IsTabStop = $true
[System.Windows.Automation.AutomationProperties]::SetName($label, $categoryCleanName)
$label.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "HeaderFontSize")
$label.SetResourceReference([Windows.Controls.Control]::FontFamilyProperty, "HeaderFontFamily")
$label.UseLayoutRounding = $true
$stackPanelContainer.Children.Add($label) | Out-Null
$sync[$category] = $label
# Sort entries by type (checkboxes first, then buttons, then comboboxes, notes last) and then alphabetically by Content
$entries = $organizedData[$panelKey][$category] | Sort-Object @{Expression = {
switch ($_.Type) {
'Button' { 1 }
'Combobox' { 2 }
'Note' { 3 }
default { 0 }
}
}}, Content
foreach ($entryInfo in $entries) {
$count++
# Create the UI elements based on the entry type
switch ($entryInfo.Type) {
"Toggle" {
$dockPanel = New-Object Windows.Controls.DockPanel
[System.Windows.Automation.AutomationProperties]::SetName($dockPanel, $entryInfo.Content)
$checkBox = New-Object Windows.Controls.CheckBox
$checkBox.Name = $entryInfo.Name
$checkBox.HorizontalAlignment = "Right"
$checkBox.UseLayoutRounding = $true
[System.Windows.Automation.AutomationProperties]::SetName($checkBox, $entryInfo.Content)
$dockPanel.Children.Add($checkBox) | Out-Null
$checkBox.Style = $ColorfulToggleSwitchStyle
$label = New-Object Windows.Controls.Label
$label.Content = $entryInfo.Content
$label.ToolTip = $entryInfo.Description
$label.HorizontalAlignment = "Left"
$label.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "FontSize")
$label.SetResourceReference([Windows.Controls.Control]::ForegroundProperty, "MainForegroundColor")
$label.UseLayoutRounding = $true
$dockPanel.Children.Add($label) | Out-Null
$stackPanelContainer.Children.Add($dockPanel) | Out-Null
$sync[$entryInfo.Name] = $checkBox
$sync[$entryInfo.Name].IsChecked = (Get-WinUtilToggleStatus $entryInfo.Name)
$sync[$entryInfo.Name].Add_Checked({
[System.Object]$Sender = $args[0]
Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName $Sender.name
# Skip applying tweaks while an import is restoring toggle states
if (-not $sync.ImportInProgress) {
Invoke-WinUtilTweaks $Sender.name
}
})
$sync[$entryInfo.Name].Add_Unchecked({
[System.Object]$Sender = $args[0]
Invoke-WPFSelectedCheckboxesUpdate -type "Remove" -checkboxName $Sender.name
# Skip undoing tweaks while an import is restoring toggle states
if (-not $sync.ImportInProgress) {
Invoke-WinUtiltweaks $Sender.name -undo $true
}
})
}
"ToggleButton" {
$toggleButton = New-Object Windows.Controls.Primitives.ToggleButton
$toggleButton.Name = $entryInfo.Name
$toggleButton.Content = $entryInfo.Content[1]
$toggleButton.ToolTip = $entryInfo.Description
$toggleButton.HorizontalAlignment = "Left"
$toggleButton.Style = $ToggleButtonStyle
[System.Windows.Automation.AutomationProperties]::SetName($toggleButton, $entryInfo.Content[0])
$toggleButton.Tag = @{
contentOn = if ($entryInfo.Content.Count -ge 1) { $entryInfo.Content[0] } else { "" }
contentOff = if ($entryInfo.Content.Count -ge 2) { $entryInfo.Content[1] } else { $contentOn }
}
$stackPanelContainer.Children.Add($toggleButton) | Out-Null
$sync[$entryInfo.Name] = $toggleButton
$sync[$entryInfo.Name].Add_Checked({
$this.Content = $this.Tag.contentOn
})
$sync[$entryInfo.Name].Add_Unchecked({
$this.Content = $this.Tag.contentOff
})
if ($null -eq $sync.Buttons) {
$sync.Buttons = [System.Collections.Generic.List[PSObject]]::new()
}
if ($sync.Buttons -notcontains $toggleButton.Name) {
$toggleButton.Add_Click({
[System.Object]$Sender = $args[0]
Invoke-WPFButton $Sender.name
})
$sync.Buttons.Add($toggleButton.Name) | Out-Null
}
}
"Combobox" {
$horizontalStackPanel = New-Object Windows.Controls.StackPanel
$horizontalStackPanel.Orientation = "Horizontal"
$horizontalStackPanel.Margin = "0,5,0,0"
[System.Windows.Automation.AutomationProperties]::SetName($horizontalStackPanel, $entryInfo.Content)
$label = New-Object Windows.Controls.Label
$label.Content = $entryInfo.Content
$label.HorizontalAlignment = "Left"
$label.ToolTip = $entryInfo.Description
$label.VerticalAlignment = "Center"
$label.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "ButtonFontSize")
$label.UseLayoutRounding = $true
$horizontalStackPanel.Children.Add($label) | Out-Null
$comboBox = New-Object Windows.Controls.ComboBox
$comboBox.Name = $entryInfo.Name
$comboBox.SetResourceReference([Windows.Controls.Control]::HeightProperty, "ButtonHeight")
$comboBox.SetResourceReference([Windows.Controls.Control]::WidthProperty, "ButtonWidth")
$comboBox.HorizontalAlignment = "Left"
$comboBox.VerticalAlignment = "Center"
$comboBox.SetResourceReference([Windows.Controls.Control]::MarginProperty, "ButtonMargin")
$comboBox.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "ButtonFontSize")
$comboBox.UseLayoutRounding = $true
$comboBox.Tag = [pscustomobject]@{
Registry = $entryInfo.Registry
State = $null
}
[System.Windows.Automation.AutomationProperties]::SetName($comboBox, $entryInfo.Content)
$comboItems = if ($entryInfo.ComboItems -is [string]) {
if ($entryInfo.ComboItems.Contains("|")) {
$entryInfo.ComboItems -split "\|"
} else {
$entryInfo.ComboItems -split " "
}
} else {
@($entryInfo.ComboItems)
}
foreach ($comboitem in $comboItems) {
$comboBoxItem = New-Object Windows.Controls.ComboBoxItem
$comboBoxItem.Content = $comboitem
if ($entryInfo.ComboDescriptions) {
$comboDescription = $entryInfo.ComboDescriptions.PSObject.Properties[$comboitem].Value
if ($comboDescription) {
$comboBoxItem.ToolTip = $comboDescription
}
}
$comboBoxItem.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "ButtonFontSize")
$comboBoxItem.UseLayoutRounding = $true
$comboBox.Items.Add($comboBoxItem) | Out-Null
}
$horizontalStackPanel.Children.Add($comboBox) | Out-Null
$stackPanelContainer.Children.Add($horizontalStackPanel) | Out-Null
if ($entryInfo.Registry -and @($entryInfo.Registry)[0].Values) {
try {
$comboBox.Tag.State = Get-WinUtilRegistryComboState -Registry $entryInfo.Registry
$comboBox.SelectedIndex = @($comboBox.Items.Content).IndexOf([string]$comboBox.Tag.State)
} catch {
$unknownStateItem = New-Object Windows.Controls.ComboBoxItem
$unknownStateItem.Content = "Custom / Unknown - select a state"
$unknownStateItem.IsEnabled = $false
$unknownStateItem.ToolTip = "$($_.Exception.Message) Select one of the supported states to replace these values."
$comboBox.Items.Add($unknownStateItem) | Out-Null
$comboBox.SelectedItem = $unknownStateItem
$comboBox.ToolTip = $unknownStateItem.ToolTip
}
} else {
$comboBox.SelectedIndex = 0
}
# Set initial text
if ($comboBox.Items.Count -gt 0) {
$comboBox.Text = $comboBox.SelectedItem.Content
}
$sync[$entryInfo.Name] = $comboBox
# Add SelectionChanged event handler to update the text property
$comboBox.Add_SelectionChanged({
$selectedItem = $this.SelectedItem
if ($selectedItem) {
$this.Text = $selectedItem.Content
$registry = $this.Tag.Registry
if ($registry -and $selectedItem.IsEnabled -and $selectedItem.Content -ne $this.Tag.State) {
try {
Set-WinUtilRegistryComboState -Registry $registry -State $selectedItem.Content
$this.Tag.State = $selectedItem.Content
$this.ToolTip = $null
$unknownStateItem = @($this.Items) | Where-Object Content -EQ "Custom / Unknown - select a state" | Select-Object -First 1
if ($unknownStateItem) {
$this.Items.Remove($unknownStateItem)
}
} catch {
$applyError = $_.Exception.Message
if ([string]::IsNullOrWhiteSpace($applyError)) {
$applyError = "Unable to apply registry state '$($selectedItem.Content)'."
}
$previousState = if ($this.Tag.State) { $this.Tag.State } else { "Custom / Unknown - select a state" }
$this.SelectedItem = @($this.Items) | Where-Object Content -EQ $previousState | Select-Object -First 1
[System.Windows.MessageBox]::Show(
$applyError,
"WinUtil",
[System.Windows.MessageBoxButton]::OK,
[System.Windows.MessageBoxImage]::Warning
) | Out-Null
}
}
}
})
if ($entryInfo.Registry -and @($entryInfo.Registry)[0].Values -and $entryInfo.Link) {
$textBlock = New-Object Windows.Controls.TextBlock
$textBlock.Name = $comboBox.Name + "Link"
$textBlock.Text = "(?)"
$textBlock.ToolTip = $entryInfo.Link
$textBlock.Style = $HoverTextBlockStyle
$textBlock.UseLayoutRounding = $true
$textBlock.VerticalAlignment = "Center"
$textBlock.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "FontSize")
$textBlock.Tag = $comboBox
$textBlock.Add_MouseUp({
[System.Object]$Sender = $args[0]
Start-Process $Sender.ToolTip -ErrorAction Stop
})
$horizontalStackPanel.Children.Add($textBlock) | Out-Null
$sync[$textBlock.Name] = $textBlock
}
}
"Button" {
$button = New-Object Windows.Controls.Button
$button.Name = $entryInfo.Name
$button.Content = $entryInfo.Content
$button.HorizontalAlignment = "Left"
$button.SetResourceReference([Windows.Controls.Control]::MarginProperty, "ButtonMargin")
$button.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "ButtonFontSize")
if ($entryInfo.ButtonWidth) {
$baseWidth = [int]$entryInfo.ButtonWidth
$button.Width = [math]::Max($baseWidth, 350)
}
[System.Windows.Automation.AutomationProperties]::SetName($button, $entryInfo.Content)
$stackPanelContainer.Children.Add($button) | Out-Null
$sync[$entryInfo.Name] = $button
if ($null -eq $sync.Buttons) {
$sync.Buttons = [System.Collections.Generic.List[PSObject]]::new()
}
if ($sync.Buttons -notcontains $button.Name) {
$button.Add_Click({
[System.Object]$Sender = $args[0]
Invoke-WPFButton $Sender.name
})
$sync.Buttons.Add($button.Name) | Out-Null
}
}
"RadioButton" {
# Check if a container for this GroupName already exists
if (-not $radioButtonGroups.ContainsKey($entryInfo.GroupName)) {
# Create a StackPanel for this group
$groupStackPanel = New-Object Windows.Controls.StackPanel
$groupStackPanel.Orientation = "Vertical"
[System.Windows.Automation.AutomationProperties]::SetName($groupStackPanel, $entryInfo.GroupName)
$radioButtonGroups[$entryInfo.GroupName] = $groupStackPanel
# Add the group container to the ItemsControl
$stackPanelContainer.Children.Add($groupStackPanel) | Out-Null
}
else {
# Retrieve the existing group container
$groupStackPanel = $radioButtonGroups[$entryInfo.GroupName]
}
# Create the RadioButton
$radioButton = New-Object Windows.Controls.RadioButton
$radioButton.Name = $entryInfo.Name
$radioButton.GroupName = $entryInfo.GroupName
$radioButton.Content = $entryInfo.Content
$radioButton.HorizontalAlignment = "Left"
$radioButton.SetResourceReference([Windows.Controls.Control]::MarginProperty, "CheckBoxMargin")
$radioButton.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "ButtonFontSize")
$radioButton.ToolTip = $entryInfo.Description
$radioButton.UseLayoutRounding = $true
[System.Windows.Automation.AutomationProperties]::SetName($radioButton, $entryInfo.Content)
if ($entryInfo.Checked -eq $true) {
$radioButton.IsChecked = $true
}
# Add the RadioButton to the group container
$groupStackPanel.Children.Add($radioButton) | Out-Null
$sync[$entryInfo.Name] = $radioButton
}
"Note" {
$textBlock = New-Object Windows.Controls.TextBlock
$textBlock.TextWrapping = "Wrap"
$textBlock.Margin = "5,5,5,5"
$textBlock.UseLayoutRounding = $true
$bulletBadge = [Windows.Documents.InlineUIContainer]::new((New-WinUtilFossBadge -Size 18 -Round))
$bulletBadge.BaselineAlignment = [Windows.BaselineAlignment]::Center
$textRun = New-Object Windows.Documents.Run
$textRun.Text = " $($entryInfo.Content)"
$textRun.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "FontSize")
$textRun.Foreground = [Windows.Media.SolidColorBrush]::new([Windows.Media.Color]::FromRgb(19, 143, 83))
$textBlock.Inlines.Add($bulletBadge)
$textBlock.Inlines.Add($textRun)
$stackPanelContainer.Children.Add($textBlock) | Out-Null
}
default {
$horizontalStackPanel = New-Object Windows.Controls.StackPanel
$horizontalStackPanel.Orientation = "Horizontal"
[System.Windows.Automation.AutomationProperties]::SetName($horizontalStackPanel, $entryInfo.Content)
$checkBox = New-Object Windows.Controls.CheckBox
$checkBox.Name = $entryInfo.Name
$checkBox.Content = $entryInfo.Content
$checkBox.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "FontSize")
$checkBox.ToolTip = $entryInfo.Description
$checkBox.SetResourceReference([Windows.Controls.Control]::MarginProperty, "CheckBoxMargin")
$checkBox.UseLayoutRounding = $true
[System.Windows.Automation.AutomationProperties]::SetName($checkBox, $entryInfo.Content)
if ($entryInfo.Checked -eq $true) {
$checkBox.IsChecked = $entryInfo.Checked
}
$horizontalStackPanel.Children.Add($checkBox) | Out-Null
if ($entryInfo.Link) {
$textBlock = New-Object Windows.Controls.TextBlock
$textBlock.Name = $checkBox.Name + "Link"
$textBlock.Text = "(?)"
$textBlock.ToolTip = $entryInfo.Link
$textBlock.Style = $HoverTextBlockStyle
$textBlock.UseLayoutRounding = $true
$textBlock.VerticalAlignment = "Center"
$textBlock.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "FontSize")
$textBlock.Tag = $checkBox
$textBlock.Add_MouseUp({
[System.Object]$Sender = $args[0]
Start-Process $Sender.ToolTip -ErrorAction Stop
})
$updateLinkMargin = {
[System.Object]$Sender = $args[0]
$linkedCheckBox = $Sender.Tag
$MarginTopBase = if ($linkedCheckBox) { $linkedCheckBox.Margin.Top } else { 0 }
$Sender.Margin = New-Object Windows.Thickness(
[math]::Round($Sender.FontSize * 0.5),
($MarginTopBase - [math]::Round($Sender.FontSize / 2)),
0, 0
)
}
$textBlock.Add_Loaded($updateLinkMargin)
$fontSizeDescriptor = [System.ComponentModel.DependencyPropertyDescriptor]::FromProperty(
[Windows.Controls.Control]::FontSizeProperty,
[Windows.Controls.TextBlock]
)
$fontSizeDescriptor.AddValueChanged($textBlock, $updateLinkMargin)
$horizontalStackPanel.Children.Add($textBlock) | Out-Null
$sync[$textBlock.Name] = $textBlock
}
$stackPanelContainer.Children.Add($horizontalStackPanel) | Out-Null
$sync[$entryInfo.Name] = $checkBox
$sync[$entryInfo.Name].Add_Checked({
[System.Object]$Sender = $args[0]
Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName $Sender.name
})
$sync[$entryInfo.Name].Add_Unchecked({
[System.Object]$Sender = $args[0]
Invoke-WPFSelectedCheckboxesUpdate -type "Remove" -checkboxName $Sender.name
})
}
}
}
}
}
}