From 30fde39f67a61fef80f575c76b04dc406e43b86a Mon Sep 17 00:00:00 2001 From: Chris Titus Date: Wed, 1 Jul 2026 22:16:05 -0500 Subject: [PATCH] Render install app entries incrementally --- .../private/Initialize-InstallAppEntry.ps1 | 15 ++++-- .../Initialize-InstallCategoryAppList.ps1 | 15 ++++-- .../Start-WinUtilInstallAppRendering.ps1 | 51 +++++++++++++++++++ pester/install-rendering.Tests.ps1 | 39 ++++++++++++++ pester/xaml.Tests.ps1 | 3 ++ speed-todo.md | 10 ++-- 6 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 functions/private/Start-WinUtilInstallAppRendering.ps1 create mode 100644 pester/install-rendering.Tests.ps1 diff --git a/functions/private/Initialize-InstallAppEntry.ps1 b/functions/private/Initialize-InstallAppEntry.ps1 index e17b2e7c..fa6e0685 100644 --- a/functions/private/Initialize-InstallAppEntry.ps1 +++ b/functions/private/Initialize-InstallAppEntry.ps1 @@ -13,11 +13,13 @@ function Initialize-InstallAppEntry { $appKey ) + $app = $sync.configs.applicationsHashtable.$appKey + # Create the outer Border for the application type $border = New-Object Windows.Controls.Border $border.Style = $sync.Form.Resources.AppEntryBorderStyle $border.Tag = $appKey - $border.ToolTip = $Apps.$appKey.description + $border.ToolTip = $app.description $border.Add_MouseLeftButtonUp({ $childCheckbox = ($this.Child | Where-Object {$_.Template.TargetType -eq [System.Windows.Controls.Checkbox]})[0] $childCheckBox.isChecked = -not $childCheckbox.IsChecked @@ -61,10 +63,10 @@ function Initialize-InstallAppEntry { # Create the TextBlock for the application name $appName = New-Object Windows.Controls.TextBlock $appName.Style = $sync.Form.Resources.AppEntryNameStyle - $appName.Text = $Apps.$appKey.content + $appName.Text = $app.content # Add FOSS label after the name if FOSS - if ($Apps.$appKey.foss -eq $true) { + if ($app.foss -eq $true) { $fossRun = [System.Windows.Documents.Run]::new(" $([char]0x25CF)") $fossRun.Foreground = [Windows.Media.SolidColorBrush]::new([Windows.Media.Color]::FromRgb(110, 255, 114)) $fossRun.FontSize = 11.5 @@ -74,10 +76,13 @@ function Initialize-InstallAppEntry { $checkBox.Content = $appName # Add accessibility properties to make the elements screen reader friendly - $checkBox.SetValue([Windows.Automation.AutomationProperties]::NameProperty, $Apps.$appKey.content) - $border.SetValue([Windows.Automation.AutomationProperties]::NameProperty, $Apps.$appKey.content) + $checkBox.SetValue([Windows.Automation.AutomationProperties]::NameProperty, $app.content) + $border.SetValue([Windows.Automation.AutomationProperties]::NameProperty, $app.content) $border.Child = $checkBox + if ($sync.selectedApps -contains $appKey) { + $checkBox.IsChecked = $true + } # Add the border to the corresponding Category $TargetElement.Children.Add($border) | Out-Null return $checkbox diff --git a/functions/private/Initialize-InstallCategoryAppList.ps1 b/functions/private/Initialize-InstallCategoryAppList.ps1 index f736342b..e574fb17 100644 --- a/functions/private/Initialize-InstallCategoryAppList.ps1 +++ b/functions/private/Initialize-InstallCategoryAppList.ps1 @@ -16,7 +16,7 @@ function Initialize-InstallCategoryAppList { $Apps ) - # Pre-group apps by category + # Pre-group apps by category before creating WPF controls. $appsByCategory = @{} foreach ($appKey in $Apps.Keys) { $category = $Apps.$appKey.Category @@ -25,6 +25,8 @@ function Initialize-InstallCategoryAppList { } $appsByCategory[$category] += $appKey } + $sync.InstallAppRenderQueue = [System.Collections.Queue]::new() + foreach ($category in $($appsByCategory.Keys | Sort-Object)) { # Create a container for category label + apps $categoryContainer = New-Object Windows.Controls.StackPanel @@ -89,9 +91,12 @@ function Initialize-InstallCategoryAppList { # Add the entire category container to the target element $null = $TargetElement.Items.Add($categoryContainer) - # Add apps to the wrap panel - $appsByCategory[$category] | Sort-Object | ForEach-Object { - $sync.$_ = $(Initialize-InstallAppEntry -TargetElement $wrapPanel -AppKey $_) - } + $sync.InstallAppRenderQueue.Enqueue([pscustomobject]@{ + Category = $category + TargetElement = $wrapPanel + AppKeys = @($appsByCategory[$category] | Sort-Object) + }) } + + Start-WinUtilInstallAppRendering } diff --git a/functions/private/Start-WinUtilInstallAppRendering.ps1 b/functions/private/Start-WinUtilInstallAppRendering.ps1 new file mode 100644 index 00000000..3274f821 --- /dev/null +++ b/functions/private/Start-WinUtilInstallAppRendering.ps1 @@ -0,0 +1,51 @@ +function Start-WinUtilInstallAppRendering { + if ($null -eq $sync.InstallAppRenderQueue) { + return + } + + $sync.InstallAppEntriesRendered = $false + + $renderCategory = { + param($CategoryBatch) + + foreach ($appKey in $CategoryBatch.AppKeys) { + $sync.$appKey = Initialize-InstallAppEntry -TargetElement $CategoryBatch.TargetElement -AppKey $appKey + } + + if ($sync.currentTab -eq "Install" -and $sync.SearchBar -and -not [string]::IsNullOrWhiteSpace($sync.SearchBar.Text)) { + Find-AppsByNameOrDescription -SearchString $sync.SearchBar.Text + } + } + + if ($sync.Form -and $sync.Form.Dispatcher -and ("System.Windows.Threading.DispatcherTimer" -as [type])) { + $timer = New-Object System.Windows.Threading.DispatcherTimer + $timer.Interval = [TimeSpan]::FromMilliseconds(1) + $timer.Add_Tick({ + $timer.Stop() + + if ($sync.InstallAppRenderQueue.Count -gt 0) { + $categoryBatch = $sync.InstallAppRenderQueue.Dequeue() + & $renderCategory $categoryBatch + } + + if ($sync.InstallAppRenderQueue.Count -gt 0) { + $timer.Start() + return + } + + $sync.InstallAppEntriesRendered = $true + Write-WinUtilPerformanceCheckpoint -Name "Install app entries rendered" + }) + $sync.InstallAppRenderTimer = $timer + $timer.Start() + return + } + + while ($sync.InstallAppRenderQueue.Count -gt 0) { + $categoryBatch = $sync.InstallAppRenderQueue.Dequeue() + & $renderCategory $categoryBatch + } + + $sync.InstallAppEntriesRendered = $true + Write-WinUtilPerformanceCheckpoint -Name "Install app entries rendered" +} diff --git a/pester/install-rendering.Tests.ps1 b/pester/install-rendering.Tests.ps1 new file mode 100644 index 00000000..4c479a7b --- /dev/null +++ b/pester/install-rendering.Tests.ps1 @@ -0,0 +1,39 @@ +#=========================================================================== +# Tests - Install tab rendering +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +} + +Describe "Install app rendering startup contract" { + It "queues app entries after creating category containers" { + $categoryScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallCategoryAppList.ps1") -Raw + + $categoryScript | Should -Match '\$sync\.InstallAppRenderQueue = \[System\.Collections\.Queue\]::new\(\)' + $categoryScript | Should -Match 'Start-WinUtilInstallAppRendering' + $categoryScript | Should -Match 'Pre-group apps by category before creating WPF controls' + } + + It "renders queued apps through a dispatcher timer when a form dispatcher exists" { + $renderScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilInstallAppRendering.ps1") -Raw + + $renderScript | Should -Match 'System\.Windows\.Threading\.DispatcherTimer' + $renderScript | Should -Match 'Initialize-InstallAppEntry' + $renderScript | Should -Match 'Install app entries rendered' + } + + It "keeps app-entry metadata lookup independent from the old caller scope" { + $entryScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallAppEntry.ps1") -Raw + + $entryScript | Should -Match '\$app = \$sync\.configs\.applicationsHashtable\.\$appKey' + $entryScript | Should -Not -Match '\$Apps\.\$appKey' + } + + It "restores delayed app checkbox state from selected apps" { + $entryScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallAppEntry.ps1") -Raw + + $entryScript | Should -Match '\$sync\.selectedApps -contains \$appKey' + $entryScript | Should -Match '\$checkBox\.IsChecked = \$true' + } +} diff --git a/pester/xaml.Tests.ps1 b/pester/xaml.Tests.ps1 index 3966c1de..09df6311 100644 --- a/pester/xaml.Tests.ps1 +++ b/pester/xaml.Tests.ps1 @@ -305,6 +305,9 @@ Describe "XAML and sync wiring" { "InstallAppAreaScrollViewer", "InstallAppAreaOverlay", "InstallAppAreaOverlayText", + "InstallAppRenderQueue", + "InstallAppRenderTimer", + "InstallAppEntriesRendered", "ProgressBar", "progressBarTextBlock", "Win11ISOImageInfo", diff --git a/speed-todo.md b/speed-todo.md index abf03977..290f8bdb 100644 --- a/speed-todo.md +++ b/speed-todo.md @@ -20,11 +20,11 @@ WinUtil currently does too much work before the first GUI paint. Work from the t ## P2 - Install Tab Rendering -- [ ] Reduce up-front creation of all install app entries. -- [ ] Keep category/app grouping as pure data before touching WPF controls. -- [ ] Create WPF controls only on the UI thread. -- [ ] Investigate replacing eager app-entry creation with item templates or incremental category rendering. -- [ ] Verify search, selected-app counts, right-click popup, install, uninstall, and import/export selection state still work. +- [x] Reduce up-front creation of all install app entries. +- [x] Keep category/app grouping as pure data before touching WPF controls. +- [x] Create WPF controls only on the UI thread. +- [x] Investigate replacing eager app-entry creation with item templates or incremental category rendering. +- [x] Verify search, selected-app counts, right-click popup, install, uninstall, and import/export selection state still work. ## P3 - Toggle And Registry Startup Cost