Adds automatic column resizing to ListViews

- Enables automatic horizontal and vertical scrollbars for ListViews in the UI to improve navigation.
- Introduces functions to dynamically calculate and apply column widths based on the visible content and header text.
- Triggers column auto-resizing across various modules whenever ListView data is updated or refreshed.
- Renames a path normalization function and updates an event handler parameter name for clarity.
This commit is contained in:
rbalsleyMSFT
2026-03-24 16:28:29 -07:00
parent bae29fd9c7
commit d6361dac4d
9 changed files with 251 additions and 8 deletions
@@ -182,6 +182,7 @@ function Add-BYOApplication {
# Refresh the ListView to show the changes
$listView.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $listView
# Reset state
$State.Data.editingBYOApplication = $null
@@ -212,6 +213,7 @@ function Add-BYOApplication {
CopyStatus = ""
}
$listView.Items.Add($application)
Request-ListViewColumnAutoResize -ListView $listView
}
# Clear form and update button states for both add and update operations
@@ -285,6 +287,7 @@ function Add-AppsScriptVariable {
}
$State.Data.appsScriptVariablesDataList.Add($newItem)
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables
$State.Controls.txtAppsScriptKey.Clear()
$State.Controls.txtAppsScriptValue.Clear()
# Update the header checkbox state
@@ -311,6 +314,7 @@ function Remove-SelectedAppsScriptVariable {
$State.Data.appsScriptVariablesDataList.Remove($itemToRemove)
}
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables
# Update the header checkbox state
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
@@ -669,6 +669,7 @@ function Update-UIFromConfig {
}
# Update the ListView's ItemsSource after populating the data list
$lstAppsScriptVars.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
Request-ListViewColumnAutoResize -ListView $lstAppsScriptVars
# Update the header checkbox state
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
Update-SelectAllHeaderCheckBoxState -ListView $lstAppsScriptVars -HeaderCheckBox $State.Controls.chkSelectAllAppsScriptVariables
@@ -737,6 +738,7 @@ function Update-UIFromConfig {
}
}
$State.Controls.lstUSBDrives.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstUSBDrives
# Update the Select All header checkbox state
$headerChk = $State.Controls.chkSelectAllUSBDrivesHeader
@@ -797,6 +799,7 @@ function Update-UIFromConfig {
}
}
$State.Controls.lstAdditionalFFUs.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAdditionalFFUs
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs
if ($null -ne $headerChk) {
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstAdditionalFFUs -HeaderCheckBox $headerChk
@@ -856,7 +859,7 @@ function Invoke-RestoreDefaults {
$rootPath = $State.FFUDevelopmentPath
# Normalize potential array values to single strings
function Normalize-PathScalar {
function Get-PathScalar {
param([object]$value)
if ($null -eq $value) { return $null }
if ($value -is [System.Array]) {
@@ -871,14 +874,14 @@ function Invoke-RestoreDefaults {
}
$appsPath = Join-Path $rootPath 'Apps'
$driversRaw = Normalize-PathScalar -value $State.Controls.txtDriversFolder.Text
$driversRaw = Get-PathScalar -value $State.Controls.txtDriversFolder.Text
if ([string]::IsNullOrWhiteSpace($driversRaw)) {
$driversPath = Join-Path $rootPath 'Drivers'
}
else {
$driversPath = $driversRaw
}
$ffuCaptureRaw = Normalize-PathScalar -value $State.Controls.txtFFUCaptureLocation.Text
$ffuCaptureRaw = Get-PathScalar -value $State.Controls.txtFFUCaptureLocation.Text
$ffuCapturePath = if ([string]::IsNullOrWhiteSpace($ffuCaptureRaw)) { Join-Path $rootPath 'FFU' } else { $ffuCaptureRaw }
$captureISOPath = Join-Path $rootPath 'WinPECaptureFFUFiles\WinPE-Capture.iso'
@@ -1060,6 +1063,7 @@ function Import-ConfigSupplementalAssets {
})
}
$State.Controls.lstWingetResults.ItemsSource = $appsBuffer.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
$loadedWinget = $true
if ($null -ne $State.Controls.wingetSearchPanel) {
$State.Controls.wingetSearchPanel.Visibility = 'Visible'
@@ -1194,6 +1198,7 @@ function Import-ConfigSupplementalAssets {
}
}
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
if (Get-Command -Name Update-SelectAllHeaderCheckBoxState -ErrorAction SilentlyContinue) {
$headerChk = $State.Controls.chkSelectAllDriverModels
if ($null -ne $headerChk) {
@@ -373,6 +373,7 @@ function Search-DriverModels {
}
# The view will automatically refresh. No need to call .Refresh() explicitly for filtering.
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
$filteredCount = 0
if ($null -ne $collectionView) {
foreach ($item in $collectionView) { $filteredCount++ }
@@ -715,6 +716,7 @@ function Import-DriversJson {
# Update the UI and apply any existing filter
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
Search-DriverModels -filterText $State.Controls.txtModelFilter.Text -State $State
$message = "Driver import complete.`nNew models added: $newModelsAdded`nExisting models updated: $existingModelsUpdated"
@@ -786,6 +788,7 @@ function Invoke-GetModels {
# Update the UI ItemsSource to point to the new list and clear the filter
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
$State.Controls.txtModelFilter.Text = ""
if ($State.Data.allDriverModels.Count -gt 0) {
@@ -24,7 +24,7 @@ function Register-EventHandlers {
# Define a handler to validate pasted text, ensuring it's only integers
$integerPastingHandler = {
param($sender, $pastingEventArgs)
param($eventSource, $pastingEventArgs)
if ($pastingEventArgs.DataObject.GetDataPresent([string])) {
$pastedText = $pastingEventArgs.DataObject.GetData([string])
# Check if the pasted text consists ONLY of one or more digits.
@@ -345,6 +345,7 @@ function Register-EventHandlers {
$driveObject | Add-Member -MemberType NoteProperty -Name 'IsSelected' -Value $false -Force
$localState.Controls.lstUSBDrives.Items.Add($driveObject)
}
Request-ListViewColumnAutoResize -ListView $localState.Controls.lstUSBDrives
if ($localState.Controls.lstUSBDrives.Items.Count -gt 0) {
$localState.Controls.lstUSBDrives.SelectedIndex = 0
}
@@ -591,6 +591,9 @@ function Initialize-DynamicUIElements {
}
)
# Keep driver model columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels -FixedColumnIndexes @(0)
# Winget Search ListView setup
$wingetGridView = New-Object System.Windows.Controls.GridView
$State.Controls.lstWingetResults.View = $wingetGridView # Assign GridView to ListView first
@@ -750,6 +753,9 @@ function Initialize-DynamicUIElements {
}
)
# Keep Winget result columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults -FixedColumnIndexes @(0)
# BYO Applications ListView setup
$byoAppsGridView = New-Object System.Windows.Controls.GridView
$State.Controls.lstApplications.View = $byoAppsGridView
@@ -773,6 +779,9 @@ function Initialize-DynamicUIElements {
Add-SortableColumn -gridView $byoAppsGridView -header "Ignore Exit Codes" -binding "IgnoreExitCodes" -width 120 -headerHorizontalAlignment Left
Add-SortableColumn -gridView $byoAppsGridView -header "Copy Status" -binding "CopyStatus" -width 150 -headerHorizontalAlignment Left
# Keep BYO application columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstApplications -FixedColumnIndexes @(0)
# Apps Script Variables ListView setup
# Bind ItemsSource to the data list
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
@@ -826,6 +835,9 @@ function Initialize-DynamicUIElements {
}
}
)
# Keep apps script variable columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables -FixedColumnIndexes @(0)
}
else {
WriteLog "Warning: lstAppsScriptVariables.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
@@ -899,6 +911,9 @@ function Initialize-DynamicUIElements {
}
}
)
# Keep USB drive columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstUSBDrives -FixedColumnIndexes @(0)
}
else {
WriteLog "Warning: lstUSBDrives.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
@@ -945,6 +960,9 @@ function Initialize-DynamicUIElements {
}
}
)
# Keep additional FFU columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstAdditionalFFUs -FixedColumnIndexes @(0)
}
else {
WriteLog "Warning: lstAdditionalFFUs.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
@@ -20,6 +20,7 @@ function Update-ListViewPriorities {
}
}
$ListView.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $ListView
}
# Function to move selected item to the top
@@ -133,6 +134,7 @@ function Update-ListViewItemStatus {
if ($null -ne $itemToUpdate) {
$itemToUpdate.$StatusProperty = $StatusValue
$ListView.Items.Refresh() # Refresh the view to show the change
Request-ListViewColumnAutoResize -ListView $ListView
}
else {
# Log if item not found (for debugging)
@@ -494,6 +496,209 @@ function Add-SelectableGridViewColumn {
WriteLog "Add-SelectableGridViewColumn: Successfully added selectable column to '$($ListView.Name)'."
}
# Function to request a deferred GridView column auto-size pass
function Request-ListViewColumnAutoResize {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[System.Windows.Controls.ListView]$ListView
)
# Skip startup calls until the visual tree has finished loading.
if (-not $ListView.IsLoaded) {
return
}
# Ensure the ListView has registered auto-resize metadata before scheduling work.
$autoResizeStateKey = 'FFU.ListViewColumnAutoResizeState'
if (-not $ListView.Resources.Contains($autoResizeStateKey)) {
return
}
if (-not ($ListView.View -is [System.Windows.Controls.GridView])) {
return
}
$autoResizeState = $ListView.Resources[$autoResizeStateKey]
if ($autoResizeState.ResizePending) {
return
}
$autoResizeState.ResizePending = $true
$previousErrorActionPreference = $ErrorActionPreference
try {
$ErrorActionPreference = 'Stop'
$gridView = [System.Windows.Controls.GridView]$ListView.View
$fixedColumnIndexes = @($autoResizeState.FixedColumnIndexes)
$visibleItems = [System.Collections.Generic.List[object]]::new()
if ($null -ne $ListView.ItemsSource) {
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($ListView.ItemsSource)
if ($null -ne $collectionView) {
foreach ($visibleItem in $collectionView) {
$visibleItems.Add($visibleItem)
}
}
}
else {
foreach ($visibleItem in $ListView.Items) {
$visibleItems.Add($visibleItem)
}
}
$ListView.UpdateLayout()
$columnIndex = 0
foreach ($column in $gridView.Columns) {
if ($fixedColumnIndexes -contains $columnIndex) {
$columnIndex++
continue
}
if ($null -eq $column) {
$columnIndex++
continue
}
$headerText = ""
$propertyName = $null
if ($null -ne $column.DisplayMemberBinding -and $null -ne $column.DisplayMemberBinding.Path) {
$propertyName = [string]$column.DisplayMemberBinding.Path.Path
}
if ($column.Header -is [System.Windows.Controls.GridViewColumnHeader]) {
if (-not [string]::IsNullOrWhiteSpace([string]$column.Header.Content)) {
$headerText = [string]$column.Header.Content
}
if ([string]::IsNullOrWhiteSpace($propertyName) -and -not [string]::IsNullOrWhiteSpace([string]$column.Header.Tag)) {
$propertyName = [string]$column.Header.Tag
}
}
elseif (-not [string]::IsNullOrWhiteSpace([string]$column.Header)) {
$headerText = [string]$column.Header
}
if ([string]::IsNullOrWhiteSpace($headerText)) {
$headerText = $propertyName
}
$headerMeasureBlock = New-Object System.Windows.Controls.TextBlock
$headerMeasureBlock.Text = if ([string]::IsNullOrWhiteSpace($headerText)) { ' ' } else { $headerText }
$headerMeasureBlock.FontFamily = $ListView.FontFamily
$headerMeasureBlock.FontSize = $ListView.FontSize
$headerMeasureBlock.FontStyle = $ListView.FontStyle
$headerMeasureBlock.FontWeight = $ListView.FontWeight
$headerMeasureBlock.FontStretch = $ListView.FontStretch
$headerMeasureBlock.Measure([System.Windows.Size]::new([double]::PositiveInfinity, [double]::PositiveInfinity))
$calculatedWidth = [math]::Ceiling($headerMeasureBlock.DesiredSize.Width + 36)
foreach ($item in $visibleItems) {
if ($null -eq $item -or [string]::IsNullOrWhiteSpace($propertyName)) {
continue
}
$itemProperty = $null
if ($null -ne $item.PSObject -and $null -ne $item.PSObject.Properties) {
$matchedProperties = $item.PSObject.Properties.Match($propertyName)
if ($null -ne $matchedProperties -and $matchedProperties.Count -gt 0) {
$itemProperty = $matchedProperties | Select-Object -First 1
}
}
if ($null -eq $itemProperty) {
continue
}
$itemText = [string]$itemProperty.Value
$extraWidth = 28
switch ($propertyName) {
'Architecture' {
$extraWidth = 52
}
'AdditionalExitCodes' {
$extraWidth = 44
}
'IgnoreNonZeroExitCodes' {
$itemText = ' '
$extraWidth = 48
}
'IgnoreExitCodes' {
$extraWidth = 28
}
}
$itemMeasureBlock = New-Object System.Windows.Controls.TextBlock
$itemMeasureBlock.Text = if ([string]::IsNullOrWhiteSpace($itemText)) { ' ' } else { $itemText }
$itemMeasureBlock.FontFamily = $ListView.FontFamily
$itemMeasureBlock.FontSize = $ListView.FontSize
$itemMeasureBlock.FontStyle = $ListView.FontStyle
$itemMeasureBlock.FontWeight = $ListView.FontWeight
$itemMeasureBlock.FontStretch = $ListView.FontStretch
$itemMeasureBlock.Measure([System.Windows.Size]::new([double]::PositiveInfinity, [double]::PositiveInfinity))
$itemWidth = [math]::Ceiling($itemMeasureBlock.DesiredSize.Width + $extraWidth)
if ($itemWidth -gt $calculatedWidth) {
$calculatedWidth = $itemWidth
}
}
if ($propertyName -eq 'IgnoreNonZeroExitCodes') {
$calculatedWidth = [math]::Max($calculatedWidth, 120)
}
$column.Width = [math]::Max($calculatedWidth, 40)
$columnIndex++
}
}
catch {
WriteLog "Request-ListViewColumnAutoResize: Failed for '$($ListView.Name)': $($_.Exception.Message)"
if (-not [string]::IsNullOrWhiteSpace($_.InvocationInfo.PositionMessage)) {
WriteLog $_.InvocationInfo.PositionMessage
}
if (-not [string]::IsNullOrWhiteSpace($_.ScriptStackTrace)) {
WriteLog $_.ScriptStackTrace
}
}
finally {
$ErrorActionPreference = $previousErrorActionPreference
$autoResizeState.ResizePending = $false
}
}
# Function to enable reusable auto-resizing for GridView-backed ListViews
function Enable-ListViewColumnAutoResize {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[System.Windows.Controls.ListView]$ListView,
[int[]]$FixedColumnIndexes = @()
)
$autoResizeStateKey = 'FFU.ListViewColumnAutoResizeState'
# Only GridView-backed lists can participate in column auto-sizing.
if (-not ($ListView.View -is [System.Windows.Controls.GridView])) {
WriteLog "Enable-ListViewColumnAutoResize: ListView '$($ListView.Name)' is not using a GridView. Skipping registration."
return
}
if ($ListView.Resources.Contains($autoResizeStateKey)) {
return
}
$autoResizeState = [PSCustomObject]@{
FixedColumnIndexes = @($FixedColumnIndexes)
ResizePending = $false
}
$ListView.Resources[$autoResizeStateKey] = $autoResizeState
}
# Function to update the IsChecked state of a "Select All" header CheckBox
function Update-SelectAllHeaderCheckBoxState {
param(
@@ -573,6 +778,7 @@ function Invoke-ListViewItemToggle {
# Toggle the IsSelected property
$selectedItem.IsSelected = -not $selectedItem.IsSelected
$ListView.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $ListView
# Update the 'Select All' header checkbox state
$headerChk = $State.Controls[$HeaderCheckBoxKeyName]
@@ -743,6 +949,8 @@ function Invoke-ListViewSort {
$newView.Filter = $existingFilter
}
}
Request-ListViewColumnAutoResize -ListView $listView
}
# --------------------------------------------------------------------------
@@ -1043,6 +1251,7 @@ function Clear-ListViewContent {
}
$ListViewControl.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $ListViewControl
# Clear any specified textboxes
if ($null -ne $TextBoxesToClear) {
@@ -59,6 +59,7 @@ function Search-WingetApps {
# Update the ListView's ItemsSource using the passed-in State object
$State.Controls.lstWingetResults.ItemsSource = $finalAppList.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
# Update status text
$statusText = ""
@@ -178,6 +179,7 @@ function Import-WingetList {
}
$State.Controls.lstWingetResults.ItemsSource = $newAppListForItemsSource.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
$State.Controls.txtAppListJsonPath.Text = $ofd.FileName
[System.Windows.MessageBox]::Show("Winget app list imported successfully.", "Success", "OK", "Information")
@@ -268,6 +268,7 @@ function Update-AdditionalFFUList {
foreach ($it in $items) { $listView.Items.Add($it) | Out-Null }
WriteLog "Additional FFUs: Found $($listView.Items.Count) FFU files in $ffuFolder."
}
Request-ListViewColumnAutoResize -ListView $listView
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs
if ($null -ne $headerChk) {
Update-SelectAllHeaderCheckBoxState -ListView $listView -HeaderCheckBox $headerChk