public/controls/New-UiStatusBar.ps1
|
function New-UiStatusBar { <# .SYNOPSIS Creates a status bar docked to the bottom (or top) of the window. .DESCRIPTION Themed bar with freeform child controls. Drop New-UiSpacer between items to push everything after it to the right side. -AutoProgress embeds a progress bar that Write-Progress from button actions drives automatically. .PARAMETER Content Scriptblock defining the bar's child controls. Optional when -DefaultText is supplied. .PARAMETER Variable Session name for the bar. When omitted, a synthetic name is minted and the bar stays discoverable via the IsStatusBar tag fallback. .PARAMETER Location 'Bottom' (default) or 'Top'. .PARAMETER DefaultText Initial status text. Prepends a TextBlock that becomes the canonical status label Set-UiStatusBar / Write-Status target. .PARAMETER AutoProgress Embeds a right-anchored progress bar that updates from Write-Progress emitted by button actions. .PARAMETER AutoCancel Embeds a Cancel button that becomes visible while an async action is running. Click calls Stop-UiAsync. .PARAMETER Intercept Intercepts Write-Warning and Write-Error from button actions, displaying them as clickable badge counters in the status bar. Click a badge to see a popup with timestamped message details. Badges reset on each new action unless -Persist is also specified. .PARAMETER CaptureHost Requires -Intercept. Also intercepts Write-Host and Write-Information from button actions, counting them on a console badge and displaying the latest message in the status text label. Captures from every button, output window or not; add -NoOutputOnly to count only the windowless ones. .PARAMETER CaptureVerbose Requires -Intercept. Adds a badge that counts Write-Verbose output from button actions. Records only flow when the action's $VerbosePreference allows them, same as a console. .PARAMETER CaptureDebug Requires -Intercept. Adds a badge that counts Write-Debug output from button actions. Gated by $DebugPreference, same as -CaptureVerbose. Not compatible with New-UiWindow -Debug, which routes debug lines to the real console instead - the badge stays at zero there. .PARAMETER CaptureAll Requires -Intercept. Shorthand for -CaptureHost -CaptureVerbose -CaptureDebug (every stream a console would show lands on the bar). .PARAMETER NoOutputOnly Requires -Intercept. Only intercepts from buttons that do NOT have an output window. Prevents duplicate badge notifications when warnings and errors are already visible in Show-UiOutput. .PARAMETER Persist Requires -Intercept. Keeps badge counters and popup messages across button actions instead of resetting on each new click. Useful for cumulative tracking across a workflow. .PARAMETER MaxMessages Requires -Intercept. Maximum number of messages per badge popup before oldest entries are dropped. Defaults to 100. .PARAMETER Inline Force docking to the immediate parent instead of the window outer panel. Tab and Expander nesting are already auto-detected; this is the escape hatch for everything else. .PARAMETER WPFProperties Hashtable of additional WPF properties to set on the bar root. .EXAMPLE New-UiStatusBar -DefaultText 'Ready' .EXAMPLE New-UiStatusBar -Content { New-UiLabel -Text 'Working...' New-UiSpacer New-UiButton -Text 'Cancel' -NoOutput -Action { Stop-UiAsync } } .EXAMPLE New-UiStatusBar -DefaultText 'Ready' -AutoProgress -AutoCancel .EXAMPLE New-UiStatusBar -Intercept -CaptureHost -AutoProgress -DefaultText 'Ready' Intercepts warnings/errors as badge counters and routes Write-Host to the status text. Click a badge to see accumulated messages. .EXAMPLE New-UiStatusBar -Intercept -CaptureAll -Persist -DefaultText 'Ready' Console-grade capture: warnings, errors, Write-Host and Write-Information, verbose, and debug all land as badges, and counts stack across clicks. #> [CmdletBinding()] param( [scriptblock]$Content, [Alias('Name')] [string]$Variable, [ValidateSet('Bottom', 'Top')] [string]$Location = 'Bottom', [string]$DefaultText, [switch]$AutoProgress, [switch]$AutoCancel, [switch]$Intercept, [switch]$CaptureHost, [switch]$CaptureVerbose, [switch]$CaptureDebug, [switch]$CaptureAll, [switch]$NoOutputOnly, [switch]$Persist, [ValidateRange(1, 10000)] [int]$MaxMessages = 100, [switch]$Inline, [hashtable]$WPFProperties ) if (!$Content -and !$PSBoundParameters.ContainsKey('DefaultText')) { throw "New-UiStatusBar: supply -Content, -DefaultText, or both." } # Warn on dependent parameters used without -Intercept if (!$Intercept) { if ($CaptureHost) { Write-Warning 'New-UiStatusBar: -CaptureHost requires -Intercept and will be ignored.' } if ($CaptureVerbose) { Write-Warning 'New-UiStatusBar: -CaptureVerbose requires -Intercept and will be ignored.' } if ($CaptureDebug) { Write-Warning 'New-UiStatusBar: -CaptureDebug requires -Intercept and will be ignored.' } if ($CaptureAll) { Write-Warning 'New-UiStatusBar: -CaptureAll requires -Intercept and will be ignored.' } if ($NoOutputOnly) { Write-Warning 'New-UiStatusBar: -NoOutputOnly requires -Intercept and will be ignored.' } if ($Persist) { Write-Warning 'New-UiStatusBar: -Persist requires -Intercept and will be ignored.' } if ($PSBoundParameters.ContainsKey('MaxMessages')) { Write-Warning 'New-UiStatusBar: -MaxMessages requires -Intercept and will be ignored.' } } # -CaptureAll fans out to the individual switches. Plain bools from here down - assigning $true into a [switch] variable quietly turns it into a bool whose .IsPresent reads $null. $wantHost = $CaptureHost.IsPresent -or $CaptureAll.IsPresent $wantVerbose = $CaptureVerbose.IsPresent -or $CaptureAll.IsPresent $wantDebug = $CaptureDebug.IsPresent -or $CaptureAll.IsPresent $session = Assert-UiSession -CallerName 'New-UiStatusBar' # Border root, not the actual StatusBar control - its layout quirks ended up not being worth the fight $bar = [System.Windows.Controls.Border]::new() Set-StatusBarStyle -StatusBar $bar if ($WPFProperties) { if ($WPFProperties.ContainsKey('Tag')) { Write-Warning "New-UiStatusBar: -WPFProperties Tag is reserved and will be ignored." $WPFProperties = @{} + $WPFProperties [void]$WPFProperties.Remove('Tag') } if ($WPFProperties.Count) { Set-UiProperties -Control $bar -Properties $WPFProperties } } # Inner DockPanel: LastChildFill stretches the spacer to fill the gap $innerPanel = [System.Windows.Controls.DockPanel]::new() $innerPanel.LastChildFill = $true $bar.Child = $innerPanel # Yeah this hashtable grows into a bit of a beast by the time Intercept is done with it (~20 keys). # Would benefit from a proper class at some point but it works fine as a bag of state for now. $meta = @{ IsStatusBar = $true InnerPanel = $innerPanel } # Severity-reset timer lives on the UI thread so its Tick outlives background-action teardown. # Set-UiStatusBar reuses it via Stop/Start rather than creating new timers. $severityTimer = [System.Windows.Threading.DispatcherTimer]::new() $severityTimer.Tag = $bar $severityTimer.Add_Tick({ param($sender, $eventArgs) $sender.Stop() $targetBar = $sender.Tag if (!$targetBar) { return } Set-StatusBarSeverityVisual -Bar $targetBar -Severity Info $barMeta = if ($targetBar.Tag -is [hashtable]) { $targetBar.Tag } else { @{} } $barMeta.Severity = 'Info' # Tint expiry is not a progress event - a ManualBar hold keeps its value and sweep through it. Every armed tint (Set-UiStatusBar, intercepted warnings and errors, the cancel flash) expires through this one Tick, so this is the guard that keeps them all from wiping a held bar. if ($barMeta.ProgressBar -and !$barMeta.ManualBar) { $barMeta.ProgressBar.Visibility = [System.Windows.Visibility]::Hidden $barMeta.ProgressBar.Value = 0 $barMeta.ProgressBar.IsIndeterminate = $false if ($barMeta.ProgressBar.Tag -is [hashtable]) { $barMeta.ProgressBar.Tag.Severity = 'Info' $barMeta.ProgressBar.Tag.BrushTag = 'AccentBrush' Set-ProgressBarStyle -ProgressBar $barMeta.ProgressBar } } }) $meta.SeverityTimer = $severityTimer # Severity indicator glyph (checkmark, warning, error) shown during active tints $severityIcon = [System.Windows.Controls.TextBlock]@{ FontFamily = [PsUi.ModuleContext]::ActiveIconFontFamily FontSize = 12 VerticalAlignment = 'Center' Visibility = 'Collapsed' Margin = [System.Windows.Thickness]::new(0, 0, 4, 0) } [void]$innerPanel.Children.Add($severityIcon) $meta.SeverityIcon = $severityIcon # -DefaultText prepends the canonical status label so user -Content flows in after it if ($PSBoundParameters.ContainsKey('DefaultText')) { $defaultLabel = [System.Windows.Controls.TextBlock]@{ Text = $DefaultText VerticalAlignment = 'Center' } [void]$innerPanel.Children.Add($defaultLabel) } # Swap CurrentParent so -Content children populate the inner panel if ($Content) { $oldParent = $session.CurrentParent $session.CurrentParent = $innerPanel try { Invoke-UiContent -Content $Content -CallerName 'New-UiStatusBar' } catch { $session.CurrentParent = $oldParent throw } $session.CurrentParent = $oldParent } # Locate the first spacer to split left vs right docked content $children = @($innerPanel.Children) $spacerIndex = -1 for ($idx = 0; $idx -lt $children.Count; $idx++) { $tag = if ($children[$idx] -is [System.Windows.FrameworkElement]) { $children[$idx].Tag } else { $null } if ($tag -is [hashtable] -and $tag.IsSpacer) { $spacerIndex = $idx break } } if ($spacerIndex -ge 0) { # Reorder: left items, right items (reversed), spacer last $innerPanel.Children.Clear() for ($idx = 0; $idx -lt $spacerIndex; $idx++) { [System.Windows.Controls.DockPanel]::SetDock($children[$idx], 'Left') [void]$innerPanel.Children.Add($children[$idx]) } # DockPanel docks Right-to-left in z-order, so reverse to match user-written order for ($idx = $children.Count - 1; $idx -gt $spacerIndex; $idx--) { [System.Windows.Controls.DockPanel]::SetDock($children[$idx], 'Right') [void]$innerPanel.Children.Add($children[$idx]) } [void]$innerPanel.Children.Add($children[$spacerIndex]) } else { foreach ($child in $innerPanel.Children) { [System.Windows.Controls.DockPanel]::SetDock($child, 'Left') } } Set-StatusBarChildLayout -Panel $innerPanel # Cache the first non-icon TextBlock as the canonical status label. # Glyphs use an icon font (MDL2 or Fluent) and are also TextBlocks - writing status text into one produces missing-glyph boxes. # Test-IconFont covers bare names and the fallback-chain form. $firstText = $null foreach ($child in $innerPanel.Children) { if ($child -is [System.Windows.Controls.TextBlock]) { $isIconFont = $child.FontFamily -and (Test-IconFont $child.FontFamily) if (!$firstText -and !$isIconFont) { $firstText = $child } $child.TextTrimming = 'CharacterEllipsis' $child.TextWrapping = 'NoWrap' $child.HorizontalAlignment = 'Left' $child.Margin = [System.Windows.Thickness]::new(4, 0, 4, 0) } } $meta.StatusText = $firstText # Wrap user content in a clipping border so overflow truncates instead of bleeding over badges $contentPanel = [System.Windows.Controls.DockPanel]::new() $contentPanel.LastChildFill = $true $userChildren = @($innerPanel.Children) $innerPanel.Children.Clear() foreach ($moved in $userChildren) { [void]$contentPanel.Children.Add($moved) } $contentClip = [System.Windows.Controls.Border]@{ ClipToBounds = $true } $contentClip.Child = $contentPanel # Severity DFS walks this panel for contrast calculation, not the outer panel with system controls $meta.InnerPanel = $contentPanel $meta.ContentClip = $contentClip if ($AutoProgress) { $autoBar = [System.Windows.Controls.ProgressBar]@{ Width = 140 Height = 12 Minimum = 0 Maximum = 100 Value = 0 VerticalAlignment = 'Center' Visibility = 'Hidden' Margin = [System.Windows.Thickness]::new(0, 0, 0, 0) } # Tag must be a hashtable before styling - Set-UiStatusBar and the timer Tick handler both reach into it to update fill color when severity changes $autoBar.Tag = @{ BrushTag = 'AccentBrush'; Severity = 'Info' } try { Set-ProgressBarStyle -ProgressBar $autoBar } catch { Write-Debug "AutoProgress: Set-ProgressBarStyle failed: $_" } # Small label above the progress bar showing Write-Progress text $progressLabel = [System.Windows.Controls.TextBlock]@{ FontSize = 9 TextTrimming = 'CharacterEllipsis' VerticalAlignment = 'Bottom' HorizontalAlignment = 'Left' Visibility = 'Collapsed' Margin = [System.Windows.Thickness]::new(0, 0, 0, 1) } $progressLabel.SetResourceReference( [System.Windows.Controls.TextBlock]::ForegroundProperty, 'ControlForegroundBrush') # Wrap label + bar in a vertical stack so they dock as one unit $progressStack = [System.Windows.Controls.StackPanel]@{ VerticalAlignment = 'Center' Margin = [System.Windows.Thickness]::new(8, 0, 4, 0) } [void]$progressStack.Children.Add($progressLabel) [void]$progressStack.Children.Add($autoBar) [System.Windows.Controls.DockPanel]::SetDock($progressStack, 'Right') $innerPanel.Children.Insert(0, $progressStack) $meta.AutoProgress = $true $meta.ProgressBar = $autoBar $meta.ProgressLabel = $progressLabel } if ($AutoCancel) { $cancelButton = [System.Windows.Controls.Button]@{ Content = 'Cancel' Padding = [System.Windows.Thickness]::new(10, 2, 10, 2) Margin = [System.Windows.Thickness]::new(4, 0, 0, 0) VerticalAlignment = 'Center' Visibility = [System.Windows.Visibility]::Hidden } $cancelButton.Add_Click({ try { Stop-UiAsync } catch { Write-Debug "AutoCancel: Stop-UiAsync threw: $_" } }) try { Set-ButtonStyle -Button $cancelButton } catch { Write-Debug "AutoCancel: Set-ButtonStyle failed: $_" } # Match the compact sizing that Set-StatusBarChildCompact gives user-placed buttons $cancelButton.Height = 26 $cancelButton.Padding = [System.Windows.Thickness]::new(10, 0, 10, 0) $cancelButton.Margin = [System.Windows.Thickness]::new(2, 0, 2, 0) $cancelButton.FocusVisualStyle = $null [System.Windows.Controls.DockPanel]::SetDock($cancelButton, 'Right') $innerPanel.Children.Insert(0, $cancelButton) $meta.AutoCancel = $true $meta.CancelButton = $cancelButton } if ($Intercept) { $warnMessages = [System.Collections.Generic.List[hashtable]]::new() $errorMessages = [System.Collections.Generic.List[hashtable]]::new() # Warning badge: clickable pill counter with a popup of timestamped messages $warnBadge = New-StatusBarBadge -Severity Warning $warnPopupSplat = @{ Severity = 'Warning' PlacementTarget = $warnBadge.Badge BadgeInfo = $warnBadge MessageList = $warnMessages Bar = $bar } $warnPopup = New-StatusBarMessagePopup @warnPopupSplat # Error badge: same structure, different severity color $errBadge = New-StatusBarBadge -Severity Error $errPopupSplat = @{ Severity = 'Error' PlacementTarget = $errBadge.Badge BadgeInfo = $errBadge MessageList = $errorMessages Bar = $bar } $errPopup = New-StatusBarMessagePopup @errPopupSplat # Wire badge click to toggle popup $capturedWarnPopup = $warnPopup.Popup $warnBadge.Badge.Add_MouseLeftButtonDown({ $capturedWarnPopup.IsOpen = !$capturedWarnPopup.IsOpen }.GetNewClosure()) $capturedErrPopup = $errPopup.Popup $errBadge.Badge.Add_MouseLeftButtonDown({ $capturedErrPopup.IsOpen = !$capturedErrPopup.IsOpen }.GetNewClosure()) # Badge is a safe zone for its popup - suppress auto-close while hovering $warnBadge.Badge.Add_MouseEnter({ $capturedWarnPopup.StaysOpen = $true }.GetNewClosure()) $warnBadge.Badge.Add_MouseLeave({ $capturedWarnPopup.StaysOpen = $false }.GetNewClosure()) $errBadge.Badge.Add_MouseEnter({ $capturedErrPopup.StaysOpen = $true }.GetNewClosure()) $errBadge.Badge.Add_MouseLeave({ $capturedErrPopup.StaysOpen = $false }.GetNewClosure()) # Insert badges right-docked, before AutoProgress/AutoCancel (insert at 0) [System.Windows.Controls.DockPanel]::SetDock($errBadge.Badge, 'Right') $innerPanel.Children.Insert(0, $errBadge.Badge) [System.Windows.Controls.DockPanel]::SetDock($warnBadge.Badge, 'Right') $innerPanel.Children.Insert(0, $warnBadge.Badge) # Build console output badge when CaptureHost is enabled if ($wantHost) { $hostMessages = [System.Collections.Generic.List[hashtable]]::new() $hostBadge = New-StatusBarBadge -Severity Info $hostPopupSplat = @{ Severity = 'Info' PlacementTarget = $hostBadge.Badge BadgeInfo = $hostBadge MessageList = $hostMessages Bar = $bar } $hostPopup = New-StatusBarMessagePopup @hostPopupSplat $capturedHostPopup = $hostPopup.Popup $hostBadge.Badge.Add_MouseLeftButtonDown({ $capturedHostPopup.IsOpen = !$capturedHostPopup.IsOpen }.GetNewClosure()) $hostBadge.Badge.Add_MouseEnter({ $capturedHostPopup.StaysOpen = $true }.GetNewClosure()) $hostBadge.Badge.Add_MouseLeave({ $capturedHostPopup.StaysOpen = $false }.GetNewClosure()) # Console badge goes left of the warning badge [System.Windows.Controls.DockPanel]::SetDock($hostBadge.Badge, 'Right') $innerPanel.Children.Insert(0, $hostBadge.Badge) } # Verbose badge, same wiring one notch further left if ($wantVerbose) { $verboseMessages = [System.Collections.Generic.List[hashtable]]::new() $verboseBadge = New-StatusBarBadge -Severity Verbose $verbosePopupSplat = @{ Severity = 'Verbose' PlacementTarget = $verboseBadge.Badge BadgeInfo = $verboseBadge MessageList = $verboseMessages Bar = $bar } $verbosePopup = New-StatusBarMessagePopup @verbosePopupSplat $capturedVerbosePopup = $verbosePopup.Popup $verboseBadge.Badge.Add_MouseLeftButtonDown({ $capturedVerbosePopup.IsOpen = !$capturedVerbosePopup.IsOpen }.GetNewClosure()) $verboseBadge.Badge.Add_MouseEnter({ $capturedVerbosePopup.StaysOpen = $true }.GetNewClosure()) $verboseBadge.Badge.Add_MouseLeave({ $capturedVerbosePopup.StaysOpen = $false }.GetNewClosure()) [System.Windows.Controls.DockPanel]::SetDock($verboseBadge.Badge, 'Right') $innerPanel.Children.Insert(0, $verboseBadge.Badge) } # Debug badge if ($wantDebug) { $debugMessages = [System.Collections.Generic.List[hashtable]]::new() $debugBadge = New-StatusBarBadge -Severity Debug $debugPopupSplat = @{ Severity = 'Debug' PlacementTarget = $debugBadge.Badge BadgeInfo = $debugBadge MessageList = $debugMessages Bar = $bar } $debugPopup = New-StatusBarMessagePopup @debugPopupSplat $capturedDebugPopup = $debugPopup.Popup $debugBadge.Badge.Add_MouseLeftButtonDown({ $capturedDebugPopup.IsOpen = !$capturedDebugPopup.IsOpen }.GetNewClosure()) $debugBadge.Badge.Add_MouseEnter({ $capturedDebugPopup.StaysOpen = $true }.GetNewClosure()) $debugBadge.Badge.Add_MouseLeave({ $capturedDebugPopup.StaysOpen = $false }.GetNewClosure()) [System.Windows.Controls.DockPanel]::SetDock($debugBadge.Badge, 'Right') $innerPanel.Children.Insert(0, $debugBadge.Badge) } $meta.Intercept = $true $meta.CaptureHost = $wantHost $meta.CaptureVerbose = $wantVerbose $meta.CaptureDebug = $wantDebug $meta.NoOutputOnly = $NoOutputOnly.IsPresent $meta.Persist = $Persist.IsPresent $meta.MaxMessages = $MaxMessages $meta.WarningBadge = $warnBadge $meta.ErrorBadge = $errBadge $meta.WarningPopup = $warnPopup $meta.ErrorPopup = $errPopup $meta.WarningMessages = $warnMessages $meta.ErrorMessages = $errorMessages if ($wantHost) { $meta.HostBadge = $hostBadge $meta.HostPopup = $hostPopup $meta.HostMessages = $hostMessages } if ($wantVerbose) { $meta.VerboseBadge = $verboseBadge $meta.VerbosePopup = $verbosePopup $meta.VerboseMessages = $verboseMessages } if ($wantDebug) { $meta.DebugBadge = $debugBadge $meta.DebugPopup = $debugPopup $meta.DebugMessages = $debugMessages } } # Content clip fills remaining space after right-docked system controls [void]$innerPanel.Children.Add($contentClip) $bar.Tag = $meta # Identify the window's outer DockPanel so the bar can dock there when at root $window = $session.Window $chromeInfo = $window.Tag $outerPanel = $null if ($chromeInfo -is [hashtable] -and $chromeInfo.ContainsKey('ContentArea')) { $contentArea = $chromeInfo['ContentArea'] if ($contentArea.Child -is [System.Windows.Controls.DockPanel]) { $outerPanel = $contentArea.Child } } # Walk up the parent chain to decide where to dock. # Tabs and expanders get a local bar; everything else docks to the window unless -Inline says otherwise. $dockToWindow = !$Inline.IsPresent if ($dockToWindow) { $walker = $session.CurrentParent while ($walker) { if ($walker -is [System.Windows.Controls.TabItem] -or $walker -is [System.Windows.Controls.Expander]) { $dockToWindow = $false break } if ($walker -eq $outerPanel) { break } $walker = $walker.Parent } } $docked = $false if ($dockToWindow -and $outerPanel) { # Insert at 0 so the content clip remains the last child (LastChildFill gives it the gap) [System.Windows.Controls.DockPanel]::SetDock($bar, $Location) [void]$outerPanel.Children.Insert(0, $bar) $docked = $true $meta.IsWindowBar = $true } # Fallback: dock to the immediate parent, adapting to whatever container type it is if (!$docked) { $parent = $session.CurrentParent if ($parent -is [System.Windows.Controls.DockPanel]) { [System.Windows.Controls.DockPanel]::SetDock($bar, $Location) [void]$parent.Children.Add($bar) } elseif ($parent -is [System.Windows.Controls.Panel]) { if ($Location -eq 'Top') { [void]$parent.Children.Insert(0, $bar) } else { [void]$parent.Children.Add($bar) } } elseif ($parent -is [System.Windows.Controls.ItemsControl]) { [void]$parent.Items.Add($bar) } elseif ($parent -is [System.Windows.Controls.ContentControl]) { $parent.Content = $bar } } # Anonymous bars still need a name for Register-UiControlComplete; the IsStatusBar tag keeps them findable via Resolve-UiStatusBar's fallback $varName = if ($Variable) { $Variable } else { '_anonStatusBar_' + [System.Guid]::NewGuid().ToString('N').Substring(0, 8) } Register-UiControlComplete -Name $varName -Control $bar -RegisterTheme } |